From a962bdbbe82bc37acb8fa570b658b60385deb296 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 10:09:19 -0700 Subject: [PATCH] feat: build vote delegation UI --- app/governance/page.tsx | 13 +- src/components/VoteDelegationPanel.tsx | 257 ++++++++++++++++++ .../__tests__/VoteDelegationPanel.test.tsx | 140 ++++++++++ src/hooks/useTransaction.ts | 32 +++ src/utils/governance.ts | 133 +++++++++ vitest.config.ts | 2 +- 6 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 src/components/VoteDelegationPanel.tsx create mode 100644 src/components/__tests__/VoteDelegationPanel.test.tsx create mode 100644 src/hooks/useTransaction.ts diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..c0c1da3 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; +import VoteDelegationPanel from "@/components/VoteDelegationPanel"; import VoteProgressBar from "@/components/VoteProgressBar"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { @@ -21,6 +22,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Active: { color: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", icon: "fiber_manual_record" }, Passed: { color: "bg-primary/15 text-primary border-primary/30", icon: "check_circle" }, Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, }; @@ -121,7 +123,7 @@ function ProposalCard({ proposal }: { proposal: Proposal }) { // ─── Filter tab ─────────────────────────────────────────────────────────────── -const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending"]; +const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Vetoed", "Executed", "Pending"]; function FilterTabs({ active, @@ -172,10 +174,13 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(load, 0); // Refresh every 30 s for real-time vote counts const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + return () => { + window.clearTimeout(timeout); + clearInterval(interval); + }; }, [load]); const filtered = @@ -221,6 +226,8 @@ export default function GovernancePage() { {/* Main content */}
+ + {/* Filter tabs */}
diff --git a/src/components/VoteDelegationPanel.tsx b/src/components/VoteDelegationPanel.tsx new file mode 100644 index 0000000..f519ec5 --- /dev/null +++ b/src/components/VoteDelegationPanel.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { useWallet } from "@/context/WalletContext"; +import { useTransaction } from "@/hooks/useTransaction"; +import { + delegateVotes, + DelegationStatus, + formatVotingPower, + getDelegationStatus, + isValidStellarAddress, + resolveDelegateAddress, + ResolvedDelegateAddress, + undelegateVotes, + wouldCreateDelegationCycle, +} from "@/utils/governance"; + +function shortenAddress(address: string): string { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export default function VoteDelegationPanel() { + const { address, isConnected, connect, signTx } = useWallet(); + const [status, setStatus] = useState(null); + const [delegateInput, setDelegateInput] = useState(""); + const [resolvedDelegate, setResolvedDelegate] = useState(null); + const [error, setError] = useState(null); + const [cycleWarning, setCycleWarning] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isResolving, setIsResolving] = useState(false); + + const delegateTransaction = useTransaction({ + pendingTitle: "Delegating votes...", + successTitle: "Votes delegated", + errorTitle: "Delegation failed", + }); + const undelegateTransaction = useTransaction({ + pendingTitle: "Removing delegation...", + successTitle: "Delegation removed", + errorTitle: "Undelegation failed", + }); + + const loadStatus = useCallback(async () => { + if (!address) { + setStatus(null); + return; + } + + setIsLoading(true); + try { + setStatus(await getDelegationStatus(address)); + } finally { + setIsLoading(false); + } + }, [address]); + + useEffect(() => { + const timeout = window.setTimeout(loadStatus, 0); + return () => window.clearTimeout(timeout); + }, [loadStatus]); + + const resolveInput = useCallback(async () => { + const value = delegateInput.trim(); + setResolvedDelegate(null); + setCycleWarning(false); + + if (!value) { + setError(null); + return null; + } + + setIsResolving(true); + try { + const resolved = await resolveDelegateAddress(value); + setResolvedDelegate(resolved); + setError(null); + if (address) { + setCycleWarning(await wouldCreateDelegationCycle(address, resolved.address)); + } + return resolved; + } catch (resolveError) { + const message = resolveError instanceof Error ? resolveError.message : "Could not resolve delegate address."; + setError(message); + return null; + } finally { + setIsResolving(false); + } + }, [address, delegateInput]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!address) return; + + const resolved = resolvedDelegate ?? (await resolveInput()); + if (!resolved) return; + if (resolved.address === address) { + setError("You cannot delegate to yourself."); + return; + } + if (cycleWarning) return; + + try { + await delegateTransaction.runTransaction(() => delegateVotes(resolved.address, address, signTx)); + setDelegateInput(""); + setResolvedDelegate(null); + await loadStatus(); + } catch (transactionError) { + setError(transactionError instanceof Error ? transactionError.message : "Delegation failed."); + } + }; + + const handleUndelegate = async () => { + if (!address) return; + + try { + await undelegateTransaction.runTransaction(() => undelegateVotes(address, signTx)); + await loadStatus(); + } catch (transactionError) { + setError(transactionError instanceof Error ? transactionError.message : "Undelegation failed."); + } + }; + + const delegateAddress = resolvedDelegate?.address ?? ""; + const delegateInvalid = + !delegateAddress || + !isValidStellarAddress(delegateAddress) || + delegateAddress === address || + cycleWarning || + isResolving; + + return ( +
+
+
+

+ Vote delegation +

+

Delegate governance power

+

+ Let a trusted representative vote with your ILN power, or remove delegation before voting directly. +

+
+ +
+
+

Own balance

+

{status ? formatVotingPower(status.ownVotingPower) : "-"}

+
+
+

Incoming delegations

+

+ {status ? formatVotingPower(status.incomingDelegationPower) : "-"} +

+
+
+

Controlled voting weight

+

+ {status ? formatVotingPower(status.controlledVotingPower) : "-"} +

+
+
+
+ + {!isConnected ? ( +
+ Connect your wallet to manage vote delegation. + +
+ ) : ( +
+
+

Current delegation status

+ {isLoading ? ( +
+ ) : status?.delegatee ? ( +

+ You are delegating to{" "} + {shortenAddress(status.delegatee)} +

+ ) : ( +

Not delegating

+ )} + {status ? ( +

+ {status.incomingDelegatorCount} wallet{status.incomingDelegatorCount === 1 ? "" : "s"}{" "} + {status.incomingDelegatorCount === 1 ? "delegates" : "delegate"} to you. +

+ ) : null} + +
+ +
+ +
+ { + setDelegateInput(event.target.value); + setResolvedDelegate(null); + setCycleWarning(false); + setError(null); + }} + onBlur={() => void resolveInput()} + placeholder="G... or alice*example.com" + className="min-w-0 flex-1 rounded-xl border border-outline-variant/40 bg-surface-container-lowest px-4 py-3 text-sm outline-none transition-colors focus:border-primary" + /> + +
+ + {isResolving ? ( +

Resolving delegate address...

+ ) : null} + {resolvedDelegate ? ( +

+ Resolved to {shortenAddress(resolvedDelegate.address)} + {resolvedDelegate.federationName ? ` from ${resolvedDelegate.federationName}` : ""} +

+ ) : null} + {cycleWarning ? ( +

+ You cannot delegate to an address that delegates back to you +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+
+ )} +
+ ); +} diff --git a/src/components/__tests__/VoteDelegationPanel.test.tsx b/src/components/__tests__/VoteDelegationPanel.test.tsx new file mode 100644 index 0000000..cae2c4f --- /dev/null +++ b/src/components/__tests__/VoteDelegationPanel.test.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import VoteDelegationPanel from "../VoteDelegationPanel"; + +const connectedAddress = `G${"E".repeat(55)}`; +const delegateAddress = `G${"F".repeat(55)}`; +const reverseDelegatorAddress = `G${"D".repeat(55)}`; + +const walletState = { + address: connectedAddress as string | null, + isConnected: true, + connect: vi.fn(), + signTx: vi.fn().mockResolvedValue("signed-xdr"), +}; + +const getDelegationStatus = vi.fn(); +const resolveDelegateAddress = vi.fn(); +const wouldCreateDelegationCycle = vi.fn(); +const delegateVotes = vi.fn(); +const undelegateVotes = vi.fn(); +const addToast = vi.fn(() => "toast-id"); +const updateToast = vi.fn(); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: () => walletState, +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ addToast, updateToast, removeToast: vi.fn() }), +})); + +vi.mock("@/utils/governance", () => ({ + delegateVotes: (...args: unknown[]) => delegateVotes(...args), + formatVotingPower: (power: number) => `${power.toLocaleString()} ILN`, + getDelegationStatus: (...args: unknown[]) => getDelegationStatus(...args), + isValidStellarAddress: (address: string) => /^G[A-Z2-7]{55}$/.test(address), + resolveDelegateAddress: (...args: unknown[]) => resolveDelegateAddress(...args), + undelegateVotes: (...args: unknown[]) => undelegateVotes(...args), + wouldCreateDelegationCycle: (...args: unknown[]) => wouldCreateDelegationCycle(...args), +})); + +describe("VoteDelegationPanel", () => { + beforeEach(() => { + walletState.address = connectedAddress; + walletState.isConnected = true; + walletState.connect.mockReset(); + walletState.signTx.mockResolvedValue("signed-xdr"); + addToast.mockClear(); + updateToast.mockClear(); + getDelegationStatus.mockResolvedValue({ + delegatee: null, + ownVotingPower: 1250, + incomingDelegationPower: 750, + incomingDelegatorCount: 1, + controlledVotingPower: 2000, + }); + resolveDelegateAddress.mockResolvedValue({ input: delegateAddress, address: delegateAddress }); + wouldCreateDelegationCycle.mockResolvedValue(false); + delegateVotes.mockResolvedValue("delegate-hash"); + undelegateVotes.mockResolvedValue("undelegate-hash"); + }); + + it("asks disconnected users to connect before managing delegation", () => { + walletState.address = null; + walletState.isConnected = false; + + render(); + + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); + expect(screen.getByText("Connect your wallet to manage vote delegation.")).toBeInTheDocument(); + expect(walletState.connect).toHaveBeenCalledOnce(); + }); + + it("shows delegation status and controlled voting weight for a connected wallet", async () => { + render(); + + expect(await screen.findByText("Not delegating")).toBeInTheDocument(); + expect(screen.getByText("1,250 ILN")).toBeInTheDocument(); + expect(screen.getByText("750 ILN")).toBeInTheDocument(); + expect(screen.getByText("2,000 ILN")).toBeInTheDocument(); + expect(screen.getByText("1 wallet delegates to you.")).toBeInTheDocument(); + }); + + it("resolves a federation address and delegates votes through the transaction helper", async () => { + resolveDelegateAddress.mockResolvedValue({ + input: "alice*example.com", + address: delegateAddress, + federationName: "alice*example.com", + }); + + render(); + + const input = screen.getByLabelText("Delegate address"); + fireEvent.change(input, { target: { value: "alice*example.com" } }); + fireEvent.blur(input); + + expect(await screen.findByText(/from alice\*example.com/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^delegate$/i })); + + await waitFor(() => expect(delegateVotes).toHaveBeenCalledWith(delegateAddress, connectedAddress, walletState.signTx)); + expect(updateToast).toHaveBeenCalledWith("toast-id", expect.objectContaining({ type: "success" })); + }); + + it("shows the required cycle warning before delegation", async () => { + resolveDelegateAddress.mockResolvedValue({ + input: reverseDelegatorAddress, + address: reverseDelegatorAddress, + }); + wouldCreateDelegationCycle.mockResolvedValue(true); + + render(); + + const input = screen.getByLabelText("Delegate address"); + fireEvent.change(input, { target: { value: reverseDelegatorAddress } }); + fireEvent.blur(input); + + expect( + await screen.findByText("You cannot delegate to an address that delegates back to you"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^delegate$/i })).toBeDisabled(); + }); + + it("undelegates when a current delegatee is present", async () => { + getDelegationStatus.mockResolvedValue({ + delegatee: delegateAddress, + ownVotingPower: 1250, + incomingDelegationPower: 0, + incomingDelegatorCount: 0, + controlledVotingPower: 1250, + }); + + render(); + + await screen.findByText(/You are delegating to/); + fireEvent.click(screen.getByRole("button", { name: /undelegate/i })); + + await waitFor(() => expect(undelegateVotes).toHaveBeenCalledWith(connectedAddress, walletState.signTx)); + }); +}); diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts new file mode 100644 index 0000000..c332b56 --- /dev/null +++ b/src/hooks/useTransaction.ts @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { useToast } from "@/context/ToastContext"; + +interface TransactionCopy { + pendingTitle: string; + successTitle: string; + errorTitle: string; +} + +export function useTransaction({ pendingTitle, successTitle, errorTitle }: TransactionCopy) { + const { addToast, updateToast } = useToast(); + const [isPending, setIsPending] = useState(false); + + const runTransaction = async (transaction: () => Promise): Promise => { + setIsPending(true); + const toastId = addToast({ type: "pending", title: pendingTitle }); + + try { + const result = await transaction(); + updateToast(toastId, { type: "success", title: successTitle }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Transaction failed."; + updateToast(toastId, { type: "error", title: errorTitle, message }); + throw error; + } finally { + setIsPending(false); + } + }; + + return { isPending, runTransaction }; +} diff --git a/src/utils/governance.ts b/src/utils/governance.ts index 49442af..34dfbf7 100644 --- a/src/utils/governance.ts +++ b/src/utils/governance.ts @@ -197,6 +197,17 @@ export function formatVotingPower(power: number): string { const userVotes: Map = new Map(); const vetoHistory: VetoRecord[] = []; +const MOCK_CONNECTED_WALLET = `G${"E".repeat(55)}`; +const MOCK_REVERSE_DELEGATOR = `G${"D".repeat(55)}`; +const voteDelegations: Map = new Map([ + [ + MOCK_REVERSE_DELEGATOR, + MOCK_CONNECTED_WALLET, + ], +]); +const mockIncomingDelegationPower: Map = new Map([ + [MOCK_CONNECTED_WALLET, 750], +]); export function getUserVote(proposalId: number): VoteChoice | undefined { return userVotes.get(proposalId); @@ -294,6 +305,128 @@ export async function getVotingPower(_address: string): Promise { return 1250; // mock: 1,250 ILN tokens } +export interface DelegationStatus { + delegatee: string | null; + ownVotingPower: number; + incomingDelegationPower: number; + incomingDelegatorCount: number; + controlledVotingPower: number; +} + +export interface ResolvedDelegateAddress { + input: string; + address: string; + federationName?: string; +} + +export async function resolveDelegateAddress(input: string): Promise { + const trimmed = input.trim(); + if (isValidStellarAddress(trimmed)) { + return { input: trimmed, address: trimmed }; + } + + if (!trimmed.includes("*")) { + throw new Error("Enter a valid Stellar G-address or federation address."); + } + + const [name, domain] = trimmed.split("*"); + if (!name || !domain) { + throw new Error("Federation addresses must use name*domain.com format."); + } + + const tomlResponse = await fetch(`https://${domain}/.well-known/stellar.toml`); + if (!tomlResponse.ok) { + throw new Error("Could not load federation server for this domain."); + } + + const toml = await tomlResponse.text(); + const server = toml.match(/^FEDERATION_SERVER\s*=\s*["']?([^"'\n]+)["']?/m)?.[1]; + if (!server) { + throw new Error("This domain does not publish a federation server."); + } + + const lookupUrl = new URL(server); + lookupUrl.searchParams.set("q", trimmed); + lookupUrl.searchParams.set("type", "name"); + const lookupResponse = await fetch(lookupUrl.toString()); + if (!lookupResponse.ok) { + throw new Error("Federation lookup failed."); + } + + const result = (await lookupResponse.json()) as { account_id?: string }; + if (!result.account_id || !isValidStellarAddress(result.account_id)) { + throw new Error("Federation lookup did not return a valid Stellar address."); + } + + return { input: trimmed, address: result.account_id, federationName: trimmed }; +} + +export async function getDelegationStatus(address: string): Promise { + await new Promise((r) => setTimeout(r, 200)); + const ownVotingPower = await getVotingPower(address); + const incomingDelegationPower = mockIncomingDelegationPower.get(address) ?? 0; + const incomingDelegatorCount = Array.from(voteDelegations.values()).filter( + (delegatee) => delegatee === address, + ).length; + + return { + delegatee: voteDelegations.get(address) ?? null, + ownVotingPower, + incomingDelegationPower, + incomingDelegatorCount, + controlledVotingPower: ownVotingPower + incomingDelegationPower, + }; +} + +export async function wouldCreateDelegationCycle( + delegator: string, + delegatee: string, +): Promise { + await new Promise((r) => setTimeout(r, 100)); + let cursor: string | undefined = delegatee; + const seen = new Set(); + + while (cursor) { + if (cursor === delegator) return true; + if (seen.has(cursor)) return false; + seen.add(cursor); + cursor = voteDelegations.get(cursor); + } + + return false; +} + +export async function delegateVotes( + delegatee: string, + signerAddress: string, + signTx: (xdr: string) => Promise, +): Promise { + if (!isValidStellarAddress(delegatee)) { + throw new Error("Delegate address must be a valid Stellar G-address."); + } + if (delegatee === signerAddress) { + throw new Error("You cannot delegate to yourself."); + } + if (await wouldCreateDelegationCycle(signerAddress, delegatee)) { + throw new Error("You cannot delegate to an address that delegates back to you."); + } + + await signTx(`delegate_votes:${delegatee}`); + await new Promise((r) => setTimeout(r, 400)); + voteDelegations.set(signerAddress, delegatee); + return Math.random().toString(16).substring(2, 18); +} + +export async function undelegateVotes( + signerAddress: string, + signTx: (xdr: string) => Promise, +): Promise { + await signTx("undelegate_votes"); + await new Promise((r) => setTimeout(r, 400)); + voteDelegations.delete(signerAddress); + return Math.random().toString(16).substring(2, 18); +} + // ─── Proposal creation ──────────────────────────────────────────────────────── /** The four form-level proposal types exposed in the creation UI */ diff --git a/vitest.config.ts b/vitest.config.ts index a222b59..f89dda0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - '@': path.resolve(__dirname, '.'), + '@': path.resolve(__dirname, 'src'), }, }, test: {