From ebd880a081d1155260423dfee4bbb389cb1c61ce Mon Sep 17 00:00:00 2001 From: Chinedu Asotie Date: Thu, 22 Oct 2026 09:02:46 +0100 Subject: [PATCH 1/4] refactor: consolidate comment hashing/verification into wallet/signMessage (#677) --- .../governance/[id]/ProposalDetailClient.tsx | 4 +- src/hooks/useGovernance.ts | 5 +- src/lib/governanceStore.ts | 84 +------------------ src/lib/wallet/signMessage.test.ts | 53 ++++++++++++ 4 files changed, 61 insertions(+), 85 deletions(-) create mode 100644 src/lib/wallet/signMessage.test.ts diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx index d29b207..85b0ac7 100644 --- a/src/app/governance/[id]/ProposalDetailClient.tsx +++ b/src/app/governance/[id]/ProposalDetailClient.tsx @@ -22,8 +22,8 @@ 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"; -const MAX_COMMENT_LENGTH = 2000; const MIN_COMMENT_LENGTH = 1; const PAGE_SIZE = 20; const RATE_LIMIT_WINDOW_MS = 10_000; @@ -203,7 +203,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; 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/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/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"); + }); +}); From 2fa0df8ad1dd8b47161cb8d09b201d42a0d1625b Mon Sep 17 00:00:00 2001 From: Chinedu Asotie Date: Thu, 22 Oct 2026 09:03:11 +0100 Subject: [PATCH 2/4] refactor: use canonical isConfusableAddress in useDelegation (#678) --- src/hooks/useDelegation.ts | 14 +++--------- src/lib/isConfusableAddress.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 src/lib/isConfusableAddress.test.ts 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/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); + }); +}); From 46db3b49d5c81f28d793a90478beec67d499f1c8 Mon Sep 17 00:00:00 2001 From: Chinedu Asotie Date: Thu, 22 Oct 2026 09:03:24 +0100 Subject: [PATCH 3/4] refactor: remove dead VoterHistory export from archive page (#679) --- src/app/governance/archive/page.tsx | 32 ----------------------------- 1 file changed, 32 deletions(-) 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} - -
  • - ))} -
- )} -
- ); -} From 89db68fd15872221ae623e6c687d2e471bb07edd Mon Sep 17 00:00:00 2001 From: Chinedu Asotie Date: Thu, 22 Oct 2026 09:04:36 +0100 Subject: [PATCH 4/4] feat: show quorum, turnout and abstain data on proposal detail (#680) --- .../governance/[id]/ProposalDetailClient.tsx | 62 +++++++++++++++++ src/lib/governanceApi.ts | 6 ++ src/lib/governanceImpact.tally.test.ts | 67 +++++++++++++++++++ src/lib/governanceImpact.ts | 56 ++++++++++++++++ src/lib/i18n/messages/en.ts | 11 +++ src/lib/i18n/messages/es.ts | 11 +++ 6 files changed, 213 insertions(+) create mode 100644 src/lib/governanceImpact.tally.test.ts diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx index 85b0ac7..53c2fda 100644 --- a/src/app/governance/[id]/ProposalDetailClient.tsx +++ b/src/app/governance/[id]/ProposalDetailClient.tsx @@ -23,6 +23,8 @@ 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 MIN_COMMENT_LENGTH = 1; const PAGE_SIZE = 20; @@ -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); @@ -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/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/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;