diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83ee7e3..9c1ba1c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,6 +95,10 @@ jobs: working-directory: smartcontract run: cargo build --target wasm32-unknown-unknown --release --manifest-path=contracts/flexible/Cargo.toml + - name: Build Governance contract + working-directory: smartcontract + run: cargo build --target wasm32-unknown-unknown --release --manifest-path=contracts/governance/Cargo.toml + - name: Verify WASM artifacts working-directory: smartcontract run: | diff --git a/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx b/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx index a33912e..5a6d2b4 100644 --- a/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx +++ b/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx @@ -23,6 +23,7 @@ interface Pool { contract_address: string token_address: string pool_members?: { member_address: string }[] + governance_contract_id?: string | null } const isPendingAddress = (addr: string) => !addr || addr === "pending_deployment" @@ -121,6 +122,18 @@ export default function GroupClient({ params }: { params: Promise<{ id: string } )} + {pool.governance_contract_id && !isPendingAddress(pool.contract_address) && ( + + )} {/* ── Right column: actions + members ──────────────────────────── */} diff --git a/frontend/app/api/governance/route.ts b/frontend/app/api/governance/route.ts new file mode 100644 index 0000000..0a5186b --- /dev/null +++ b/frontend/app/api/governance/route.ts @@ -0,0 +1,117 @@ +/** + * /api/governance — Off-chain mirror of DAO governance votes (issue #207). + * + * Proposals live on-chain; this endpoint only maintains the realtime vote + * mirror in `governance_votes` so clients can update counts without + * re-querying Soroban RPC. + * + * GET /api/governance?pool_id=[&proposal_id=] + * Returns mirrored votes, newest first. + * + * POST /api/governance { pool_id, proposal_id, voter_address, vote } + * Upserts the caller's mirrored vote. One row per (proposal_id, voter). + */ + +import { getAdminClient } from "@/lib/supabase-admin" +import { NextRequest, NextResponse } from "next/server" +import { readLimiter, writeLimiter } from "@/lib/rate-limit" + +const HEX_RE = /^[0-9a-fA-F]{1,128}$/ + +export async function GET(req: NextRequest) { + const limited = readLimiter(req) + if (limited) return limited + + const poolId = req.nextUrl.searchParams.get("pool_id") + const proposalId = req.nextUrl.searchParams.get("proposal_id") + + if (!poolId) { + return NextResponse.json({ error: "pool_id required" }, { status: 400 }) + } + + try { + let query = getAdminClient() + .from("governance_votes") + .select("proposal_id, voter_address, vote, created_at") + .eq("pool_id", poolId) + + if (proposalId) query = query.eq("proposal_id", proposalId.toLowerCase()) + + const { data, error } = await query.order("created_at", { ascending: false }) + + if (error) throw error + + return NextResponse.json(data ?? [], { + headers: { "Cache-Control": "private, no-cache" }, + }) + } catch (error) { + console.error("Governance votes fetch error:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch governance votes" }, + { status: 500 } + ) + } +} + +export async function POST(req: NextRequest) { + const limited = writeLimiter(req) + if (limited) return limited + + let body: { pool_id?: string; proposal_id?: string; voter_address?: string; vote?: boolean } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const { pool_id, proposal_id, voter_address, vote } = body + const voter = voter_address?.toLowerCase() + + if (!pool_id || !proposal_id || !voter || typeof vote !== "boolean") { + return NextResponse.json( + { error: "pool_id, proposal_id, voter_address and vote are required" }, + { status: 400 } + ) + } + if (!HEX_RE.test(proposal_id)) { + return NextResponse.json({ error: "proposal_id must be a hex string" }, { status: 400 }) + } + + // Only actual members of the pool may contribute to the mirror. + const { data: member } = await getAdminClient() + .from("pool_members") + .select("id") + .eq("pool_id", pool_id) + .eq("member_address", voter) + .maybeSingle() + + if (!member) { + return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 }) + } + + try { + const { data, error } = await getAdminClient() + .from("governance_votes") + .upsert( + { + pool_id, + proposal_id: proposal_id.toLowerCase(), + voter_address: voter, + vote, + }, + { onConflict: "proposal_id,voter_address" } + ) + .select("proposal_id, voter_address, vote, created_at") + .single() + + if (error) throw error + + return NextResponse.json(data, { status: 201 }) + } catch (error) { + console.error("Governance vote mirror error:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to record governance vote" }, + { status: 500 } + ) + } +} diff --git a/frontend/components/governance/create-proposal-dialog.tsx b/frontend/components/governance/create-proposal-dialog.tsx new file mode 100644 index 0000000..237bf43 --- /dev/null +++ b/frontend/components/governance/create-proposal-dialog.tsx @@ -0,0 +1,204 @@ +"use client" + +import { useMemo, useState } from "react" +import { useTranslations } from "next-intl" +import { Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { + GOVERNANCE_DESCRIPTION_MAX, + PROPOSAL_TYPES, + encodeParamHex, + type GovernanceProposalType, +} from "@/lib/governance" + +interface CreateProposalDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + poolType: string + onSubmit: ( + proposalType: GovernanceProposalType, + description: string, + paramsHex: Record + ) => Promise +} + +const APPLICABLE_POOL_TYPES: Record = { + ChangeDepositAmount: ["flexible", "rotational"], + ExtendDeadline: ["rotational", "target"], + AddPenalty: ["flexible", "rotational", "target"], + RemovePenalty: ["flexible", "rotational", "target"], + ChangeQuorum: ["flexible", "rotational", "target"], +} + +export function CreateProposalDialog({ + open, + onOpenChange, + poolType, + onSubmit, +}: CreateProposalDialogProps) { + const t = useTranslations("governance") + + const availableTypes = useMemo( + () => PROPOSAL_TYPES.filter((p) => APPLICABLE_POOL_TYPES[p.value]?.includes(poolType)), + [poolType] + ) + + const [proposalType, setProposalType] = useState("") + const [description, setDescription] = useState("") + const [paramValue, setParamValue] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const selectedMeta = availableTypes.find((p) => p.value === proposalType) ?? null + const needsParam = !!selectedMeta?.paramKey + + const reset = () => { + setProposalType("") + setDescription("") + setParamValue("") + setSubmitting(false) + setError(null) + } + + const handleClose = (next: boolean) => { + if (!next) reset() + onOpenChange(next) + } + + const handleSubmit = async () => { + if (!proposalType || description.trim().length === 0 || (needsParam && !paramValue)) { + setError(t("fillRequired")) + return + } + setSubmitting(true) + setError(null) + try { + let params: Record = {} + if (needsParam) { + params = { + [selectedMeta!.paramKey]: encodeParamHex(Number(paramValue.replace(/,/g, ""))), + } + } + const ok = await onSubmit(proposalType, description.trim(), params) + if (!ok) { + setError(t("toastError")) + setSubmitting(false) + return + } + reset() + onOpenChange(false) + } catch { + setError(t("toastError")) + setSubmitting(false) + } + } + + return ( + + + + {t("createProposal")} + {t("subtitle")} + + +
+
+ + +
+ + {needsParam && ( +
+ + setParamValue(e.target.value)} + placeholder="0" + /> +
+ )} + +
+
+ + GOVERNANCE_DESCRIPTION_MAX + ? "text-destructive" + : "text-muted-foreground" + }`} + > + {t("charCount", { count: description.length, max: GOVERNANCE_DESCRIPTION_MAX })} + +
+