From 8afa23caf24b2960532f7b5ef1c3b47fbadf6363 Mon Sep 17 00:00:00 2001 From: Dev Fashman Date: Mon, 24 Aug 2026 19:42:46 -0400 Subject: [PATCH 1/2] feat(governance): add DAO-style governance voting for pool parameters Implements issue #207. Smart contracts: - New governance contract (contracts/governance): member-only proposal creation (max 3 active), 48h voting window, one-vote-per-member, against-quorum auto-rejection, quorum-gated execution via CPI to the target pool, ChangeQuorum self-application, expiry finalization. - All three pool contracts (flexible, rotational, target) gain set_governance_contract + apply_governance_proposal entrypoints (callable by admin or the registered governance contract) covering deposit amount, round duration/deadline and penalty percentage, plus penalty_percentage/governance_contract views. - CI builds the new contract's wasm alongside the others. Frontend: - Governance panel with proposal list (color-coded status badges), vote cards with quorum progress bar, and create-proposal dialog with dynamic per-type fields and 500-char description limit. - useGovernance hook: on-chain reads/mutations + Supabase Realtime subscription on the governance_votes mirror for live vote counts. - /api/governance endpoint maintaining the off-chain vote mirror. - Pure governance helpers in lib/ with unit tests (registered in test:unit); i18n messages for en/es. Supabase: - governance_votes table with RLS + realtime publication. - pools.governance_contract_id column gating the UI tab. --- .github/workflows/test.yml | 4 + .../dashboard/group/[id]/GroupClient.tsx | 14 + frontend/app/api/governance/route.ts | 117 ++++ .../governance/create-proposal-dialog.tsx | 204 ++++++ .../governance/governance-panel.tsx | 151 ++++ .../components/governance/proposal-list.tsx | 109 +++ frontend/components/governance/vote-card.tsx | 160 +++++ frontend/hooks/useGovernance.ts | 178 +++++ frontend/hooks/useJointSaveContracts.ts | 216 ++++++ frontend/lib/governance.test.ts | 93 +++ frontend/lib/governance.ts | 135 ++++ frontend/messages/en.json | 52 ++ frontend/messages/es.json | 52 ++ frontend/package.json | 2 +- smartcontract/Cargo.lock | 7 + smartcontract/Cargo.toml | 1 + smartcontract/contracts/flexible/src/lib.rs | 91 +++ smartcontract/contracts/flexible/src/tests.rs | 56 +- ...eld_strategy_requires_yield_enabled.1.json | 2 +- smartcontract/contracts/governance/Cargo.toml | 13 + smartcontract/contracts/governance/src/lib.rs | 644 ++++++++++++++++++ .../contracts/governance/src/tests.rs | 528 ++++++++++++++ smartcontract/contracts/rotational/src/lib.rs | 105 ++- .../contracts/rotational/src/tests.rs | 48 +- smartcontract/contracts/target/src/lib.rs | 87 +++ smartcontract/contracts/target/src/tests.rs | 41 +- .../20260824000000_governance_votes.sql | 46 ++ 27 files changed, 3150 insertions(+), 6 deletions(-) create mode 100644 frontend/app/api/governance/route.ts create mode 100644 frontend/components/governance/create-proposal-dialog.tsx create mode 100644 frontend/components/governance/governance-panel.tsx create mode 100644 frontend/components/governance/proposal-list.tsx create mode 100644 frontend/components/governance/vote-card.tsx create mode 100644 frontend/hooks/useGovernance.ts create mode 100644 frontend/lib/governance.test.ts create mode 100644 frontend/lib/governance.ts create mode 100644 smartcontract/contracts/governance/Cargo.toml create mode 100644 smartcontract/contracts/governance/src/lib.rs create mode 100644 smartcontract/contracts/governance/src/tests.rs create mode 100644 supabase/migrations/20260824000000_governance_votes.sql diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 042f658..f2083b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,6 +91,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 ca9a2a7..4668504 100644 --- a/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx +++ b/frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx @@ -8,6 +8,7 @@ import { GroupActivity } from "@/components/group/group-activity" import { GroupActions } from "@/components/group/group-actions" import { RotationalTimelineContainer } from "@/components/group/rotational-timeline-container" import { PoolChat } from "@/components/group/pool-chat" +import { GovernancePanel } from "@/components/governance/governance-panel" import { Button } from "@/components/ui/button" import { ArrowLeft } from "lucide-react" import Link from "next/link" @@ -22,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" @@ -120,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 })} + +
+