diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..3b1fd2f 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; +import VoteDelegationPanel from "@/components/VoteDelegationPanel"; import VoteProgressBar from "@/components/VoteProgressBar"; import TokenAllowlistPanel from "@/components/governance/TokenAllowlistPanel"; import VotingPowerDisplay from "@/components/VotingPowerDisplay"; @@ -29,6 +30,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" }, }; @@ -202,10 +204,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]); useEffect(() => { diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..7902758 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -7,6 +7,7 @@ import { formatTokenAmount } from "@/utils/format"; import FieldTooltip from "./FieldTooltip"; type TokenLike = ApprovedToken | (Partial & Pick); +type TokenIconLike = Pick; interface TokenSelectorProps { label: string; @@ -43,11 +44,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: TokenIconLike): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: TokenIconLike): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } @@ -73,7 +74,7 @@ export function TokenIcon({ token, className = "", }: { - token: Pick; + token: TokenIconLike; className?: string; }) { return ( 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/evidence.ts b/src/utils/evidence.ts index 55396ad..4ae476b 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.slice().buffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..a5cac27 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +type AccountWithHomeDomain = { + home_domain?: string; + homeDomain?: string; +}; + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,7 +16,8 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const accountWithHomeDomain = account as AccountWithHomeDomain; + const homeDomain = accountWithHomeDomain.home_domain ?? accountWithHomeDomain.homeDomain; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/governance.ts b/src/utils/governance.ts index 557f062..30de7bf 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);