Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
13 changes: 13 additions & 0 deletions frontend/app/[locale]/dashboard/group/[id]/GroupClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -121,6 +122,18 @@ export default function GroupClient({ params }: { params: Promise<{ id: string }
)}
<GroupActivity groupId={id} contractAddress={cacheKey} startLedger={0} />
<PoolChat poolId={id} isMember={isMember} />
{pool.governance_contract_id && !isPendingAddress(pool.contract_address) && (
<GovernancePanel
poolId={pool.id}
governanceContractId={pool.governance_contract_id}
poolContractAddress={pool.contract_address}
poolType={pool.type}
isAdmin={
!!address && !!poolAdmin && address.toLowerCase() === poolAdmin.toLowerCase()
}
isMember={isMember}
/>
)}
</div>

{/* ── Right column: actions + members ──────────────────────────── */}
Expand Down
117 changes: 117 additions & 0 deletions frontend/app/api/governance/route.ts
Original file line number Diff line number Diff line change
@@ -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=<id>[&proposal_id=<hex>]
* 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 }
)
}
}
204 changes: 204 additions & 0 deletions frontend/components/governance/create-proposal-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>
) => Promise<boolean>
}

const APPLICABLE_POOL_TYPES: Record<string, string[]> = {
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<GovernanceProposalType | "">("")
const [description, setDescription] = useState("")
const [paramValue, setParamValue] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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<string, string> = {}
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("createProposal")}</DialogTitle>
<DialogDescription>{t("subtitle")}</DialogDescription>
</DialogHeader>

<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="proposal-type">{t("typeLabel")}</Label>
<Select
value={proposalType}
onValueChange={(v) => {
setProposalType(v as GovernanceProposalType)
setParamValue("")
}}
>
<SelectTrigger id="proposal-type" className="w-full">
<SelectValue placeholder={t("typeLabel")} />
</SelectTrigger>
<SelectContent>
{availableTypes.map((p) => (
<SelectItem key={p.value} value={p.value}>
{t(`types.${p.value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{needsParam && (
<div className="space-y-2">
<Label htmlFor="proposal-param">{selectedMeta!.paramLabel}</Label>
<Input
id="proposal-param"
type="number"
min="0"
value={paramValue}
onChange={(e) => setParamValue(e.target.value)}
placeholder="0"
/>
</div>
)}

<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="proposal-description">{t("descriptionLabel")}</Label>
<span
className={`text-xs ${
description.length > GOVERNANCE_DESCRIPTION_MAX
? "text-destructive"
: "text-muted-foreground"
}`}
>
{t("charCount", { count: description.length, max: GOVERNANCE_DESCRIPTION_MAX })}
</span>
</div>
<Textarea
id="proposal-description"
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, GOVERNANCE_DESCRIPTION_MAX))}
placeholder={t("descriptionPlaceholder")}
rows={4}
/>
</div>

{error && <p className="text-sm text-destructive">{error}</p>}
</div>

<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => handleClose(false)} disabled={submitting}>
{t("cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={
submitting ||
!proposalType ||
description.trim().length === 0 ||
(needsParam && !paramValue)
}
>
{submitting && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
{submitting ? t("submitting") : t("submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading
Loading