diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx
index d29b207..53c2fda 100644
--- a/src/app/governance/[id]/ProposalDetailClient.tsx
+++ b/src/app/governance/[id]/ProposalDetailClient.tsx
@@ -22,8 +22,10 @@ import { useVotingPower } from "@/hooks/useVotingPower";
import { timeRemaining } from "@/lib/time";
import { getMessage } from "@/i18n/messages";
import { signCommentMessage, verifySignedComment } from "@/lib/wallet/signMessage";
+import { COMMENT_MAX_LENGTH } from "@/lib/governanceStore";
+import { computeProposalTally } from "@/lib/governanceImpact";
+import { useTranslation } from "@/lib/i18n/I18nProvider";
-const MAX_COMMENT_LENGTH = 2000;
const MIN_COMMENT_LENGTH = 1;
const PAGE_SIZE = 20;
const RATE_LIMIT_WINDOW_MS = 10_000;
@@ -108,7 +110,12 @@ function renderSafeMarkdown(text: string): React.ReactNode[] {
return nodes;
}
+function formatPercent(fraction: number): string {
+ return `${(fraction * 100).toFixed(1)}%`;
+}
+
export default function ProposalDetailClient({ proposalId }: { proposalId: string }) {
+ const { t } = useTranslation();
const { isConnected, address: userAddress } = useWalletStore();
const { proposal, isLoading, error } = useProposal(proposalId);
@@ -191,6 +198,8 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
return null;
}, []);
+ const tally = computeProposalTally(proposal);
+
const handlePostComment = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
@@ -203,7 +212,7 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
return;
}
- const validation = validateCommentText(commentText, MAX_COMMENT_LENGTH);
+ const validation = validateCommentText(commentText, COMMENT_MAX_LENGTH);
if (!validation.valid || commentText.trim().length < MIN_COMMENT_LENGTH) {
setErrorMsg(validation.error || getMessage("solve.governance.emptyCommentError"));
return;
@@ -362,6 +371,59 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
{proposal.votesAgainst.toLocaleString()}
+
+ {tally && (
+
+
+ {t("governance.quorum.title")}
+
+
+
+ {t("governance.quorum.turnout")}
+ {formatPercent(tally.turnoutFraction)}
+
+
+
+ {t("governance.quorum.required", { percent: formatPercent(tally.quorumFraction) })}
+ {" · "}
+
+ {t(tally.quorumMet ? "governance.quorum.met" : "governance.quorum.notMet")}
+
+
+
+
+ {t("governance.quorum.approval", {
+ percent: formatPercent(tally.approvalFraction),
+ threshold: formatPercent(tally.approvalThreshold),
+ })}
+ {" · "}
+
+ {t(tally.approvalMet ? "governance.quorum.passing" : "governance.quorum.failing")}
+
+
+
+
{t("governance.quorum.abstain")}
+
{tally.votesAbstain.toLocaleString()}
+
+
+ )}
{/* ── Community Discussion / Comment Thread Section ── */}
diff --git a/src/app/governance/archive/page.tsx b/src/app/governance/archive/page.tsx
index d911996..45816bd 100644
--- a/src/app/governance/archive/page.tsx
+++ b/src/app/governance/archive/page.tsx
@@ -1,9 +1,6 @@
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 { toCsv } from '@/lib/csv';
const STATUSES = ['all', 'passed', 'rejected', 'expired', 'executed'] as const;
type Status = (typeof STATUSES)[number];
@@ -226,32 +223,3 @@ 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();
- const csv = toCsv(votes.map((v) => ({ proposalId: v.proposalId, choice: v.choice, weight: v.weight, timestamp: v.timestamp })));
- return (
-
- Voter history
-
- Export CSV
-
- {votes.length === 0 ? (
- No votes recorded for this address.
- ) : (
-
- {votes.map((v) => (
- -
-
- {v.proposalId}
-
- {v.choice}
- {v.weight}
-
-
- ))}
-
- )}
-
- );
-}
diff --git a/src/hooks/useDelegation.ts b/src/hooks/useDelegation.ts
index f83298d..f34c079 100644
--- a/src/hooks/useDelegation.ts
+++ b/src/hooks/useDelegation.ts
@@ -2,6 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useVotingPower } from './useVotingPower';
import { secureLogger } from '../lib/secureLogging';
+// Canonical address-poisoning check lives in the shared address module.
+export { isConfusableAddress } from '../lib/stellarAddress';
+
/**
* Voting delegation flow (issues.md #41).
*
@@ -72,17 +75,6 @@ export interface UseDelegationResult {
const SELF_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';
-/**
- * Detects visually confusable addresses (address-poisoning defence).
- * Compares the leading and trailing characters of two addresses.
- */
-export function isConfusableAddress(a: string, b: string): boolean {
- if (!a || !b || a === b) return false;
- const prefix = (s: string) => s.slice(0, 6).toUpperCase();
- const suffix = (s: string) => s.slice(-6).toUpperCase();
- return prefix(a) === prefix(b) || suffix(a) === suffix(b);
-}
-
/**
* Sanitises free-form delegate profile text before rendering.
* Strips control characters and collapses whitespace.
diff --git a/src/hooks/useGovernance.ts b/src/hooks/useGovernance.ts
index 4d0edfa..7f0e87c 100644
--- a/src/hooks/useGovernance.ts
+++ b/src/hooks/useGovernance.ts
@@ -17,8 +17,7 @@ import type {
ProposalFilters,
} from "@/lib/governanceApi";
import { validateCommentText } from "@/lib/textSafety";
-
-const MAX_COMMENT_LENGTH = 500;
+import { COMMENT_MAX_LENGTH } from "@/lib/governanceStore";
// ─── useProposals ─────────────────────────────────────────────────────────────
@@ -98,7 +97,7 @@ export function useProposalComments(proposalId: string): UseProposalCommentsResu
async (author: string, text: string) => {
setPostError(null);
- const validation = validateCommentText(text, MAX_COMMENT_LENGTH);
+ const validation = validateCommentText(text, COMMENT_MAX_LENGTH);
if (!validation.valid) {
setPostError(validation.error ?? "Invalid comment text.");
return;
diff --git a/src/lib/governanceApi.ts b/src/lib/governanceApi.ts
index dabf8c7..dacd57b 100644
--- a/src/lib/governanceApi.ts
+++ b/src/lib/governanceApi.ts
@@ -30,6 +30,12 @@ export type GovernanceProposal = {
status: "active" | "passed" | "rejected";
votesFor: number;
votesAgainst: number;
+ /** Optional tally fields; the quorum section is hidden when they are absent. */
+ votesAbstain?: number;
+ totalSupply?: number;
+ quorumFraction?: number;
+ approvalThreshold?: number;
+ abstainCountsTowardQuorum?: boolean;
createdAt: string;
deadline: string;
};
diff --git a/src/lib/governanceImpact.tally.test.ts b/src/lib/governanceImpact.tally.test.ts
new file mode 100644
index 0000000..58d0d61
--- /dev/null
+++ b/src/lib/governanceImpact.tally.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { computeProposalTally } from "./governanceImpact";
+
+describe("computeProposalTally", () => {
+ it("computes VIP-1 style data (abstain counts toward quorum)", () => {
+ const t = computeProposalTally({
+ votesFor: 125000,
+ votesAgainst: 42000,
+ votesAbstain: 8000,
+ totalSupply: 400000,
+ quorumFraction: 0.4,
+ approvalThreshold: 0.66,
+ abstainCountsTowardQuorum: true,
+ });
+ expect(t?.turnoutFraction).toBeCloseTo(175000 / 400000);
+ expect(t?.quorumMet).toBe(true);
+ expect(t?.approvalFraction).toBeCloseTo(125000 / 167000);
+ expect(t?.approvalMet).toBe(true);
+ });
+
+ it("excludes abstain from turnout when not configured", () => {
+ const base = {
+ votesFor: 100,
+ votesAgainst: 100,
+ votesAbstain: 100,
+ totalSupply: 1000,
+ quorumFraction: 0.25,
+ approvalThreshold: 0.5,
+ };
+ expect(computeProposalTally({ ...base, abstainCountsTowardQuorum: false })?.quorumMet).toBe(false);
+ expect(computeProposalTally({ ...base, abstainCountsTowardQuorum: true })?.quorumMet).toBe(true);
+ });
+
+ it("reports failing approval below threshold", () => {
+ const t = computeProposalTally({
+ votesFor: 50,
+ votesAgainst: 50,
+ totalSupply: 100,
+ quorumFraction: 0.4,
+ approvalThreshold: 0.66,
+ });
+ expect(t?.approvalMet).toBe(false);
+ expect(t?.votesAbstain).toBe(0);
+ });
+
+ it("returns null when fields are missing or unusable", () => {
+ expect(computeProposalTally({ votesFor: 1, votesAgainst: 1 })).toBeNull();
+ expect(
+ computeProposalTally({ votesFor: 1, votesAgainst: 1, totalSupply: 0, quorumFraction: 0.4, approvalThreshold: 0.5 }),
+ ).toBeNull();
+ expect(
+ computeProposalTally({ votesFor: 1, votesAgainst: 1, totalSupply: 10, approvalThreshold: 0.5 }),
+ ).toBeNull();
+ });
+
+ it("does not produce NaN when nobody voted", () => {
+ const t = computeProposalTally({
+ votesFor: 0,
+ votesAgainst: 0,
+ totalSupply: 10,
+ quorumFraction: 0.4,
+ approvalThreshold: 0.5,
+ });
+ expect(t?.approvalFraction).toBe(0);
+ expect(t?.approvalMet).toBe(false);
+ });
+});
diff --git a/src/lib/governanceImpact.ts b/src/lib/governanceImpact.ts
index 8604fe7..97c2dc0 100644
--- a/src/lib/governanceImpact.ts
+++ b/src/lib/governanceImpact.ts
@@ -44,3 +44,59 @@ export function simulateBondThreshold(
wouldGain,
};
}
+
+/** Fields of a proposal that feed the quorum/approval calculation. */
+export type ProposalTallyInput = {
+ votesFor: number;
+ votesAgainst: number;
+ votesAbstain?: number;
+ totalSupply?: number;
+ quorumFraction?: number;
+ approvalThreshold?: number;
+ abstainCountsTowardQuorum?: boolean;
+};
+
+export type ProposalTally = {
+ votesAbstain: number;
+ /** Participation as a fraction of total supply (abstain counted only when configured). */
+ turnoutFraction: number;
+ quorumFraction: number;
+ quorumMet: boolean;
+ /** votesFor / (votesFor + votesAgainst); 0 when nobody has voted for or against. */
+ approvalFraction: number;
+ approvalThreshold: number;
+ approvalMet: boolean;
+};
+
+const isPositive = (n: number | undefined): n is number =>
+ typeof n === "number" && Number.isFinite(n) && n > 0;
+
+const isFraction = (n: number | undefined): n is number =>
+ typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
+
+/**
+ * Quorum, turnout and approval status for a proposal. Returns `null` when the
+ * optional tally fields are missing or unusable (e.g. `totalSupply` of 0) so
+ * callers hide the section instead of rendering NaN/Infinity.
+ */
+export function computeProposalTally(proposal: ProposalTallyInput): ProposalTally | null {
+ const { totalSupply, quorumFraction, approvalThreshold } = proposal;
+ if (!isPositive(totalSupply) || !isFraction(quorumFraction) || !isFraction(approvalThreshold)) {
+ return null;
+ }
+ const votesAbstain = proposal.votesAbstain ?? 0;
+ const counted =
+ proposal.votesFor + proposal.votesAgainst + (proposal.abstainCountsTowardQuorum ? votesAbstain : 0);
+ const turnoutFraction = counted / totalSupply;
+ const decisive = proposal.votesFor + proposal.votesAgainst;
+ const approvalFraction = decisive > 0 ? proposal.votesFor / decisive : 0;
+ return {
+ votesAbstain,
+ turnoutFraction,
+ quorumFraction,
+ quorumMet: turnoutFraction >= quorumFraction,
+ approvalFraction,
+ approvalThreshold,
+ approvalMet: decisive > 0 && approvalFraction >= approvalThreshold,
+ };
+}
diff --git a/src/lib/governanceStore.ts b/src/lib/governanceStore.ts
index 51eb34c..683d8d7 100644
--- a/src/lib/governanceStore.ts
+++ b/src/lib/governanceStore.ts
@@ -52,22 +52,12 @@ export type GovernanceProposal = {
};
/**
- * Canonical signed-comment message format (documented spec):
- *
- * stellar-governance-comment:v1\n
- * proposalId:\n
- * textHash:\n
- * timestamp:\n
- *
- * The text is Unicode NFC-normalised before hashing so that visually identical
- * comments produce identical signatures. The timestamp is bound into the signed
- * payload together with the proposalId, which prevents replaying a signature on
- * another proposal or outside the freshness window.
+ * Comment length bounds: single source of truth for the UI and the hooks.
+ * Signing, hashing and verification (and the canonical message spec) live in
+ * `src/lib/wallet/signMessage.ts`.
*/
-export const COMMENT_MESSAGE_VERSION = "stellar-governance-comment:v1";
export const COMMENT_MIN_LENGTH = 1;
-export const COMMENT_MAX_LENGTH = 2000;
-export const COMMENT_TIMESTAMP_WINDOW_SECONDS = 300;
+export const COMMENT_MAX_LENGTH = 500;
export type CommentSignature = {
/** Base64 signature produced by SEP-53 message signing (or the signMessage shim). */
@@ -210,72 +200,6 @@ export function getGovernanceProposalById(id: string): GovernanceProposal | unde
return INITIAL_PROPOSALS.find((p) => p.id.toLowerCase() === id.toLowerCase());
}
-/**
- * Builds the canonical, signed message for a comment. The text is NFC-normalised
- * before hashing so that equivalent Unicode inputs sign identically.
- */
-export function buildCommentMessage(
- proposalId: string,
- text: string,
- timestamp: number
-): string {
- return [
- COMMENT_MESSAGE_VERSION,
- `proposalId:${proposalId}`,
- `textHash:${hashCommentText(text)}`,
- `timestamp:${timestamp}`,
- ].join("\n");
-}
-
-/**
- * sha256 hex of the NFC-normalised comment text. Uses WebCrypto when available
- * and falls back to a deterministic FNV-1a hex digest in non-crypto environments
- * (e.g. tests) so the canonical format stays stable.
- */
-export function hashCommentText(text: string): string {
- const normalised = text.normalize("NFC");
- const cryptoObj = (globalThis as { crypto?: Crypto }).crypto;
- if (cryptoObj?.subtle) {
- // Synchronous callers need a stable digest; WebCrypto is async, so we use a
- // deterministic fallback here and let callers that need sha256 await it.
- return fnv1aHex(normalised);
- }
- return fnv1aHex(normalised);
-}
-
-function fnv1aHex(input: string): string {
- let hash = 0x811c9dc5;
- for (let i = 0; i < input.length; i += 1) {
- hash ^= input.charCodeAt(i);
- hash = Math.imul(hash, 0x01000193) >>> 0;
- }
- return hash.toString(16).padStart(8, "0");
-}
-
-/**
- * Pure verification of a signed comment. Returns true only when the signature
- * covers the canonical message for this proposalId/text/timestamp and the
- * timestamp is inside the freshness window. `verify` is injected so the store
- * stays free of a hard stellar-sdk dependency at module load time; callers pass
- * `(message, signature, publicKey) => Keypair.fromPublicKey(publicKey).verify(...)`.
- */
-export function verifySignedComment(
- comment: ProposalComment,
- verify: (message: string, signature: string, publicKey: string) => boolean,
- nowSeconds: number = Math.floor(Date.now() / 1000)
-): boolean {
- const sig = comment.signature;
- if (!sig || !sig.signature || !sig.timestamp) return false;
- if (Math.abs(nowSeconds - sig.timestamp) > COMMENT_TIMESTAMP_WINDOW_SECONDS) return false;
- if (sig.textHash !== hashCommentText(comment.text)) return false;
- const message = buildCommentMessage(comment.proposalId, comment.text, sig.timestamp);
- try {
- return verify(message, sig.signature, comment.author);
- } catch {
- return false;
- }
-}
-
/**
* Cursor pagination over the comment thread. Comments are returned newest-first
* and the cursor is the id of the last comment in the page.
diff --git a/src/lib/i18n/messages/en.ts b/src/lib/i18n/messages/en.ts
index e69de29..5af29da 100644
--- a/src/lib/i18n/messages/en.ts
+++ b/src/lib/i18n/messages/en.ts
@@ -0,0 +1,11 @@
+export const en = {
+ "governance.quorum.title": "Quorum and approval",
+ "governance.quorum.turnout": "Turnout",
+ "governance.quorum.required": "Quorum requires {percent} of total supply",
+ "governance.quorum.met": "Quorum met",
+ "governance.quorum.notMet": "Quorum not met",
+ "governance.quorum.approval": "Approval {percent} of for and against votes (needs {threshold})",
+ "governance.quorum.passing": "Passing",
+ "governance.quorum.failing": "Not passing",
+ "governance.quorum.abstain": "Votes Abstain",
+} as const;
diff --git a/src/lib/i18n/messages/es.ts b/src/lib/i18n/messages/es.ts
index e69de29..93c75ae 100644
--- a/src/lib/i18n/messages/es.ts
+++ b/src/lib/i18n/messages/es.ts
@@ -0,0 +1,11 @@
+export const es = {
+ "governance.quorum.title": "Quórum y aprobación",
+ "governance.quorum.turnout": "Participación",
+ "governance.quorum.required": "El quórum requiere {percent} del suministro total",
+ "governance.quorum.met": "Quórum alcanzado",
+ "governance.quorum.notMet": "Quórum no alcanzado",
+ "governance.quorum.approval": "Aprobación del {percent} de los votos a favor y en contra (requiere {threshold})",
+ "governance.quorum.passing": "Aprobada",
+ "governance.quorum.failing": "No aprobada",
+ "governance.quorum.abstain": "Votos de abstención",
+} as const;
diff --git a/src/lib/isConfusableAddress.test.ts b/src/lib/isConfusableAddress.test.ts
new file mode 100644
index 0000000..028908f
--- /dev/null
+++ b/src/lib/isConfusableAddress.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { isConfusableAddress as fromHook } from "@/hooks/useDelegation";
+import { isConfusableAddress } from "./stellarAddress";
+
+const BASE = "GDW4UXK66PDDK4CDDUJGNPFZHBZDWAJNNUE5ZEQYN5S3DISNGXZIVAIV";
+// Same first 8 chars, different elsewhere (prefix-only match).
+const PREFIX_ONLY = "GDW4UXK6" + "A".repeat(40) + "B".repeat(8);
+// Same last 8 chars, different elsewhere (suffix-only match).
+const SUFFIX_ONLY = "GCCCCCCC" + "D".repeat(40) + BASE.slice(-8);
+const BOTH = BASE.slice(0, 8) + "E".repeat(40) + BASE.slice(-8);
+const UNRELATED = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H";
+
+describe("isConfusableAddress", () => {
+ it("has real-length 56-char fixtures", () => {
+ for (const a of [BASE, PREFIX_ONLY, SUFFIX_ONLY, BOTH, UNRELATED]) {
+ expect(a).toHaveLength(56);
+ }
+ });
+
+ it("flags shared prefix, shared suffix and both", () => {
+ expect(isConfusableAddress(PREFIX_ONLY, BASE)).toBe(true);
+ expect(isConfusableAddress(SUFFIX_ONLY, BASE)).toBe(true);
+ expect(isConfusableAddress(BOTH, BASE)).toBe(true);
+ });
+
+ it("does not flag unrelated or identical addresses", () => {
+ expect(isConfusableAddress(UNRELATED, BASE)).toBe(false);
+ expect(isConfusableAddress(BASE, BASE)).toBe(false);
+ });
+
+ it("is the same function the delegation flow exposes", () => {
+ expect(fromHook).toBe(isConfusableAddress);
+ });
+});
diff --git a/src/lib/wallet/signMessage.test.ts b/src/lib/wallet/signMessage.test.ts
new file mode 100644
index 0000000..d0b8997
--- /dev/null
+++ b/src/lib/wallet/signMessage.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Keypair } from "stellar-sdk";
+import {
+ SIGNATURE_WINDOW_MS,
+ buildCanonicalMessage,
+ hashCommentText,
+ signComment,
+ verifySignedComment,
+} from "./signMessage";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("hashCommentText", () => {
+ it("exercises crypto.subtle.digest and returns a sha256 hex digest", async () => {
+ const spy = vi.spyOn(globalThis.crypto.subtle, "digest");
+ const hash = await hashCommentText("hello");
+ expect(spy).toHaveBeenCalledWith("SHA-256", expect.anything());
+ expect(hash).toBe("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
+ });
+
+ it("is deterministic and NFC/trim normalised", async () => {
+ const a = await hashCommentText(" café ");
+ const b = await hashCommentText("café");
+ expect(a).toBe(b);
+ });
+});
+
+describe("verifySignedComment", () => {
+ const keypair = Keypair.random();
+ const payload = { proposalId: "VIP-1", text: "hello", timestamp: 1_700_000_000_000 };
+
+ it("accepts a fresh, valid signature", async () => {
+ const signed = await signComment(keypair, payload);
+ expect(await verifySignedComment(signed, payload.timestamp + 1000)).toBe(true);
+ });
+
+ it("rejects signatures outside the freshness window", async () => {
+ const signed = await signComment(keypair, payload);
+ const now = payload.timestamp + SIGNATURE_WINDOW_MS + 1;
+ expect(await verifySignedComment(signed, now)).toBe(false);
+ });
+
+ it("rejects tampered text", async () => {
+ const signed = await signComment(keypair, payload);
+ expect(await verifySignedComment({ ...signed, text: "other" }, payload.timestamp)).toBe(false);
+ });
+
+ it("builds the canonical message", () => {
+ expect(buildCanonicalMessage("VIP-1", "abc", 5)).toBe("stellar-comment:VIP-1:abc:5");
+ });
+});