From ad9f7b438746b2465c735f05604656cf3ca783f4 Mon Sep 17 00:00:00 2001 From: Elliot Clement Date: Thu, 22 Oct 2026 09:17:16 +0100 Subject: [PATCH 1/4] ci: read Node version from .nvmrc in all workflows (#674) --- .github/workflows/bundle-size-badge.yml | 2 +- .github/workflows/coverage-badge.yml | 2 +- .github/workflows/deploy.yml | 4 ++-- .github/workflows/preview-deploy.yml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bundle-size-badge.yml b/.github/workflows/bundle-size-badge.yml index 0baea0d..9c96a62 100644 --- a/.github/workflows/bundle-size-badge.yml +++ b/.github/workflows/bundle-size-badge.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 20 + node-version-file: '.nvmrc' cache: 'npm' - run: npm ci diff --git a/.github/workflows/coverage-badge.yml b/.github/workflows/coverage-badge.yml index cb3c12f..17bddcd 100644 --- a/.github/workflows/coverage-badge.yml +++ b/.github/workflows/coverage-badge.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: '.nvmrc' cache: npm - name: Install dependencies diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ad87d57..8e3e94b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 20 + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies @@ -71,7 +71,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 20 + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index e51f7c7..0aa7d4b 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -43,7 +43,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 20 + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies @@ -162,7 +162,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies From 93448ad5d2d0956d2a2037e6ceda8e48c3711c4b Mon Sep 17 00:00:00 2001 From: Elliot Clement Date: Thu, 22 Oct 2026 09:17:23 +0100 Subject: [PATCH 2/4] chore: add pre-push typecheck gate matching CI (#673) --- .husky/pre-push | 2 ++ 1 file changed, 2 insertions(+) create mode 100755 .husky/pre-push diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..56b12f4 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +npm run typecheck From 987e4e3aaf78302e0b9ce33e223c5f07b6d2ec04 Mon Sep 17 00:00:00 2001 From: Elliot Clement Date: Thu, 22 Oct 2026 09:17:54 +0100 Subject: [PATCH 3/4] fix(governance): add governanceStore.listProposals/getVoterHistory, toCsv, and fix archive imports (#675) --- src/app/governance/archive/page.test.tsx | 11 ++ src/app/governance/archive/page.tsx | 4 +- .../governance/voter/[address]/page.test.tsx | 13 +++ src/lib/csv.ts | 8 ++ src/lib/governanceStore.ts | 105 ++++++++++++++++++ src/lib/governanceStore.voter.test.ts | 23 ++++ 6 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/app/governance/archive/page.test.tsx create mode 100644 src/app/governance/voter/[address]/page.test.tsx create mode 100644 src/lib/governanceStore.voter.test.ts diff --git a/src/app/governance/archive/page.test.tsx b/src/app/governance/archive/page.test.tsx new file mode 100644 index 0000000..a0c3724 --- /dev/null +++ b/src/app/governance/archive/page.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import GovernanceArchivePage from "./page"; + +describe("GovernanceArchivePage", () => { + it("renders proposals from the governance store", async () => { + render(await GovernanceArchivePage({ searchParams: {} })); + expect(screen.getByRole("heading", { name: "Governance Archive" })).toBeInTheDocument(); + expect(screen.getAllByRole("listitem").length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/governance/archive/page.tsx b/src/app/governance/archive/page.tsx index d911996..7d6952f 100644 --- a/src/app/governance/archive/page.tsx +++ b/src/app/governance/archive/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { governanceStore } from '@/lib/governanceStore'; -import { isValidStellarAddress } from '@/lib/stellarAddress'; +import { isValidStellarPublicKey } from '@/lib/stellarAddress'; import { toCsv } from '@/lib/csv'; const STATUSES = ['all', 'passed', 'rejected', 'expired', 'executed'] as const; @@ -228,7 +228,7 @@ export default async function GovernanceArchivePage({ } export function VoterHistory({ address, votes }: { address: string; votes: Array<{ proposalId: string; choice: string; weight: string; timestamp: string }> }) { - if (!isValidStellarAddress(address)) notFound(); + if (!isValidStellarPublicKey(address)) notFound(); const csv = toCsv(votes.map((v) => ({ proposalId: v.proposalId, choice: v.choice, weight: v.weight, timestamp: v.timestamp }))); return (
diff --git a/src/app/governance/voter/[address]/page.test.tsx b/src/app/governance/voter/[address]/page.test.tsx new file mode 100644 index 0000000..db360e9 --- /dev/null +++ b/src/app/governance/voter/[address]/page.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import VoterHistoryPage from "./page"; + +const ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7"; + +describe("VoterHistoryPage", () => { + it("renders participation stats for a valid address", async () => { + render(await VoterHistoryPage({ params: { address: ADDRESS }, searchParams: {} })); + expect(screen.getByRole("heading", { name: "Voter history" })).toBeInTheDocument(); + expect(screen.getByText("Participation rate")).toBeInTheDocument(); + }); +}); diff --git a/src/lib/csv.ts b/src/lib/csv.ts index ebed5be..af84ed3 100644 --- a/src/lib/csv.ts +++ b/src/lib/csv.ts @@ -73,3 +73,11 @@ export function buildCsv( } return lines.join("\n"); } + +/** Generic row-to-CSV helper; headers come from the first row's keys. */ +export function toCsv(rows: ReadonlyArray>): string { + if (rows.length === 0) return ""; + const headers = Object.keys(rows[0]); + const lines = rows.map((row) => headers.map((key) => escapeCsv(String(row[key] ?? ""))).join(",")); + return [headers.join(","), ...lines].join("\n"); +} diff --git a/src/lib/governanceStore.ts b/src/lib/governanceStore.ts index 51eb34c..75ea730 100644 --- a/src/lib/governanceStore.ts +++ b/src/lib/governanceStore.ts @@ -331,3 +331,108 @@ export function removeProposalComment(proposalId: string, commentId: string): vo if (!list) return; commentsStore[proposalId] = list.filter((c) => c.id !== commentId); } + +export type ArchiveProposal = { + id: string; + title: string; + summary: string; + category: string; + status: GovernanceProposalStatus; + createdAt: string; + tally: { for: number; against: number; abstain: number }; + executed: boolean; +}; + +export type VoterVote = { + proposalId: string; + title: string; + choice: "for" | "against" | "abstain"; + weight: string; + timestamp: string; +}; + +export type VoterHistoryResult = { + votes: VoterVote[]; + totalVotes: number; + participationRate: number; + delegatedTo: string | null; +}; + +export type VoterHistoryOptions = { windowDays?: number; limit?: number; offset?: number }; + +/** Small deterministic string hash (FNV-1a) used to derive stand-in data from an address. */ +function seedFromAddress(address: string): number { + let h = 2166136261; + for (let i = 0; i < address.length; i++) { + h ^= address.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h >>> 0; +} + +/** + * PENDING_BACKEND_ENDPOINTS: GET /api/governance/proposals (archive listing). + * Maps the seed proposals onto the shape the archive page renders. + */ +export async function listProposals(): Promise { + return getGovernanceProposals().map((p) => ({ + id: p.id, + title: p.title, + summary: p.description, + category: p.category, + status: p.status, + createdAt: p.createdAt, + tally: { for: p.votesFor, against: p.votesAgainst, abstain: p.votesAbstain ?? 0 }, + executed: p.status === "executed" || Boolean(p.executedAt), + })); +} + +/** + * PENDING_BACKEND_ENDPOINTS: GET /api/governance/voters/:address/history. + * No per-voter vote log exists yet, so this derives a minimal deterministic + * mock keyed by address. The window is anchored to the newest seed proposal + * so results are stable regardless of the current date. + */ +export async function getVoterHistory( + address: string, + { windowDays = 90, limit = 20, offset = 0 }: VoterHistoryOptions = {}, +): Promise { + const seed = seedFromAddress(address); + const proposals = getGovernanceProposals(); + const choices = ["for", "against", "abstain"] as const; + const newest = Math.max(...proposals.map((p) => Date.parse(p.createdAt))); + const cutoff = newest - windowDays * 86_400_000; + + const cast = proposals + .map((p, i) => ({ p, i })) + .filter(({ i }) => ((seed >>> i) & 1) === 1 || i === seed % proposals.length); + const votes: VoterVote[] = cast + .filter(({ p }) => Date.parse(p.createdAt) >= cutoff) + .map(({ p, i }) => ({ + proposalId: p.id, + title: p.title, + choice: choices[(seed + i) % choices.length], + weight: String(((seed >>> 3) % 9000) + 1000), + timestamp: p.createdAt, + })) + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + + const inWindow = proposals.filter((p) => Date.parse(p.createdAt) >= cutoff).length; + return { + votes: votes.slice(offset, offset + limit), + totalVotes: votes.length, + participationRate: inWindow === 0 ? 0 : votes.length / inWindow, + delegatedTo: seed % 4 === 0 ? proposals[0].proposer : null, + }; +} + +/** Thin namespace over the store functions, consumed by the archive and voter pages. */ +export const governanceStore = { + listProposals, + getVoterHistory, + getProposals: getGovernanceProposals, + getProposalById: getGovernanceProposalById, + getProposalComments, + addProposalComment, + removeProposalComment, +}; diff --git a/src/lib/governanceStore.voter.test.ts b/src/lib/governanceStore.voter.test.ts new file mode 100644 index 0000000..e84db1b --- /dev/null +++ b/src/lib/governanceStore.voter.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { governanceStore } from "./governanceStore"; +import { toCsv } from "./csv"; + +describe("governanceStore archive and voter history", () => { + it("lists proposals in archive shape", async () => { + const list = await governanceStore.listProposals(); + expect(list.length).toBeGreaterThan(0); + expect(list[0]).toHaveProperty("tally.for"); + }); + + it("returns deterministic voter history", async () => { + const a = await governanceStore.getVoterHistory("GABC", { windowDays: 365 }); + const b = await governanceStore.getVoterHistory("GABC", { windowDays: 365 }); + expect(a).toEqual(b); + expect(a.participationRate).toBeGreaterThanOrEqual(0); + expect(a.participationRate).toBeLessThanOrEqual(1); + }); + + it("builds csv from rows", () => { + expect(toCsv([{ a: "1", b: "x,y" }])).toBe('a,b\n1,"x,y"'); + }); +}); From 966d0949d08d6a50c7b80dbbd93a55ceecb99dca Mon Sep 17 00:00:00 2001 From: Elliot Clement Date: Thu, 22 Oct 2026 09:18:28 +0100 Subject: [PATCH 4/4] fix(governance): align delegates page with useDelegation API and fix address validator import (#676) --- src/app/governance/delegates/page.test.tsx | 23 ++ src/app/governance/delegates/page.tsx | 249 ++++++++------------- 2 files changed, 121 insertions(+), 151 deletions(-) create mode 100644 src/app/governance/delegates/page.test.tsx diff --git a/src/app/governance/delegates/page.test.tsx b/src/app/governance/delegates/page.test.tsx new file mode 100644 index 0000000..586cf84 --- /dev/null +++ b/src/app/governance/delegates/page.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import DelegatesPage from "./page"; + +// Mounts the page with the real useDelegation hook so a hook signature change +// fails here instead of only at runtime. +describe("DelegatesPage", () => { + it("renders the directory from the hook and starts a delegation review", async () => { + render(); + expect(screen.getByRole("heading", { name: "Voting delegation" })).toBeInTheDocument(); + expect(await screen.findByText("Stellar Builders")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("button", { name: "Delegate" })[0]); + expect(screen.getByRole("button", { name: /Simulate, sign/ })).toBeInTheDocument(); + }); + + it("rejects an invalid manual address", () => { + render(); + fireEvent.change(screen.getByLabelText("Address"), { target: { value: "not-an-address" } }); + fireEvent.click(screen.getByRole("button", { name: "Review delegation" })); + expect(screen.getByRole("alert")).toHaveTextContent("valid Stellar address"); + }); +}); diff --git a/src/app/governance/delegates/page.tsx b/src/app/governance/delegates/page.tsx index 76a1db7..ad16bfe 100644 --- a/src/app/governance/delegates/page.tsx +++ b/src/app/governance/delegates/page.tsx @@ -1,53 +1,14 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useVotingPower } from '@/hooks/useVotingPower'; -import { useDelegation } from '@/hooks/useDelegation'; +import { useState } from 'react'; +import { useDelegation, type DelegateProfile } from '@/hooks/useDelegation'; import { sanitizeText } from '@/lib/textSafety'; -import { isSameStellarAddress, isValidStellarAddress } from '@/lib/stellarAddress'; -import { secureLogger } from '@/lib/secureLogging'; - -interface DelegateProfile { - address: string; - name: string; - bio: string; - votingPower: number; - participationRate: number; - proposalsVoted: number; -} +import { isValidStellarPublicKey } from '@/lib/stellarAddress'; type SortKey = 'votingPower' | 'participationRate' | 'proposalsVoted'; -type FlowStage = 'idle' | 'review' | 'simulate' | 'sign' | 'pending' | 'done'; -const MOCK_DELEGATES: DelegateProfile[] = [ - { - address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - name: 'Aurora Governance', - bio: 'Active voter focused on protocol upgrades and treasury stewardship.', - votingPower: 1250000, - participationRate: 0.94, - proposalsVoted: 87, - }, - { - address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', - name: 'Nova Collective', - bio: 'Community-run delegate prioritising developer tooling and grants.', - votingPower: 860000, - participationRate: 0.81, - proposalsVoted: 64, - }, - { - address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCWHF', - name: 'Meridian Labs', - bio: 'Security-first delegate reviewing every proposal before voting.', - votingPower: 540000, - participationRate: 0.72, - proposalsVoted: 51, - }, -]; - -function formatPower(value: number): string { - return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value); +function formatPower(value: string): string { + return value; } function formatPercent(value: number): string { @@ -55,93 +16,66 @@ function formatPercent(value: number): string { } export default function DelegatesPage() { - const { votingPower, refresh: refreshVotingPower } = useVotingPower(); const { - currentDelegate, - delegatedPower, - history, - delegate, - revoke, - isPending, + state, + flow, + directory, + directoryLoading, + directoryError, + effectiveVotingPower, + startDelegate, + startRevoke, + confirm, + cancel, + searchDirectory, + sortDirectory, } = useDelegation(); + const { currentDelegate, delegatedPower, history } = state; const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('votingPower'); - const [selected, setSelected] = useState(null); const [manualAddress, setManualAddress] = useState(''); const [confirmedAddress, setConfirmedAddress] = useState(''); - const [stage, setStage] = useState('idle'); - const [error, setError] = useState(null); - - const filtered = useMemo(() => { - const term = search.trim().toLowerCase(); - const list = term - ? MOCK_DELEGATES.filter( - (d) => - d.name.toLowerCase().includes(term) || - d.address.toLowerCase().includes(term), - ) - : MOCK_DELEGATES; - return [...list].sort((a, b) => b[sortKey] - a[sortKey]); - }, [search, sortKey]); + const [localError, setLocalError] = useState(null); - const targetAddress = selected?.address ?? manualAddress.trim(); - const isNewAddress = !MOCK_DELEGATES.some((d) => isSameStellarAddress(d.address, targetAddress)); - const isSelfDelegation = isSameStellarAddress(targetAddress, currentDelegate ?? ''); - const addressValid = isValidStellarAddress(targetAddress); + const isPending = flow.status === 'simulating' || flow.status === 'signing' || flow.status === 'pending'; + const target = flow.target ?? ''; + const error = localError ?? (flow.error ? 'Delegation failed. Please try again.' : null); - const resetFlow = useCallback(() => { - setStage('idle'); - setError(null); + const handleSelect = (d: DelegateProfile) => { + setManualAddress(''); setConfirmedAddress(''); - }, []); - - useEffect(() => { - if (stage === 'done') { - void refreshVotingPower(); - } - }, [stage, refreshVotingPower]); + setLocalError(null); + startDelegate(d.address); + }; const handleReview = () => { - setError(null); - if (!addressValid) { - setError('Enter a valid Stellar address.'); + setLocalError(null); + const address = manualAddress.trim(); + if (!isValidStellarPublicKey(address)) { + setLocalError('Enter a valid Stellar address.'); return; } - if (isSelfDelegation) { - setError('You cannot delegate to your current delegate.'); + startDelegate(address); + }; + + const handleConfirm = () => { + setLocalError(null); + if (flow.isSelfDelegation) { + setLocalError('You cannot delegate to yourself.'); return; } - if (isNewAddress && confirmedAddress.trim() !== targetAddress) { - setError('Confirm the full address before continuing.'); + if (flow.requiresFullAddressConfirmation && confirmedAddress.trim() !== target) { + setLocalError('Confirm the full address before continuing.'); return; } - setStage('review'); + void confirm(); }; - const handleSimulate = () => setStage('simulate'); - - const handleSign = async () => { - setStage('sign'); - try { - await delegate(targetAddress); - setStage('done'); - } catch (err) { - secureLogger.error('delegation failed', err); - setError('Delegation failed. Please try again.'); - setStage('idle'); - } - }; - - const handleRevoke = async () => { - setError(null); - try { - await revoke(); - setStage('done'); - } catch (err) { - secureLogger.error('revoke failed', err); - setError('Revoke failed. Please try again.'); - } + const handleCancel = () => { + setLocalError(null); + setConfirmedAddress(''); + cancel(); }; return ( @@ -167,18 +101,21 @@ export default function DelegatesPage() {
Effective voting power
-
{formatPower(votingPower)}
+
{formatPower(effectiveVotingPower)}
{currentDelegate && ( )} @@ -188,8 +125,8 @@ export default function DelegatesPage() {
    {history.map((entry) => (
  • - {entry.delegate ?? 'Revoked'} - {entry.timestamp} + {entry.action === 'revoke' ? 'Revoked' : entry.delegate} + {new Date(entry.timestamp).toISOString()}
  • ))}
@@ -207,7 +144,10 @@ export default function DelegatesPage() { setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + searchDirectory(e.target.value); + }} placeholder="Search by name or address" className="w-full rounded-md border px-3 py-2 text-sm" /> @@ -216,7 +156,11 @@ export default function DelegatesPage() { Sort delegates { - setSelected(null); - setManualAddress(e.target.value); - resetFlow(); - }} + value={flow.status === 'idle' ? manualAddress : target} + readOnly={flow.status !== 'idle'} + onChange={(e) => setManualAddress(e.target.value)} className="mt-1 w-full rounded-md border px-3 py-2 font-mono text-sm" /> - {isNewAddress && targetAddress && ( + {flow.status === 'review' && flow.requiresFullAddressConfirmation && ( )} + {flow.status === 'review' && flow.isNonExistentAccount && ( +

+ This address is not in the delegate directory and may have no voting history. +

+ )} + {error && (

{error}

)} - {stage === 'idle' && ( + {flow.status === 'idle' && ( - - )} - - {stage === 'simulate' && ( -
-

Simulation succeeded. No state changes were made.

)} - {(stage === 'sign' || stage === 'pending') && ( + {isPending && (

- Waiting for signature… + {flow.status === 'simulating' ? 'Simulating…' : 'Waiting for signature…'}

)} - {stage === 'done' && ( + {flow.status === 'confirmed' && (

Delegation updated. Effective voting power refreshed.