Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions src/app/governance/[id]/ProposalDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -362,6 +371,59 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
<div className="text-lg font-bold text-vx-amber num">{proposal.votesAgainst.toLocaleString()}</div>
</div>
</div>

{tally && (
<section aria-labelledby="quorum-heading" className="pt-4 border-t border-vx-line space-y-3">
<h3 id="quorum-heading" className="text-xs font-semibold text-vx-text">
{t("governance.quorum.title")}
</h3>
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-vx-muted">{t("governance.quorum.turnout")}</span>
<span className="num text-vx-text">{formatPercent(tally.turnoutFraction)}</span>
</div>
<div
role="progressbar"
aria-label={t("governance.quorum.turnout")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(Math.min(tally.turnoutFraction, 1) * 100)}
className="relative h-2 rounded-full bg-vx-surface border border-vx-border overflow-hidden"
>
<div
className={`h-full ${tally.quorumMet ? "bg-vx-sage" : "bg-vx-amber"}`}
style={{ width: `${Math.min(tally.turnoutFraction, 1) * 100}%` }}
/>
<div
aria-hidden="true"
className="absolute top-0 bottom-0 w-0.5 bg-vx-text"
style={{ left: `${tally.quorumFraction * 100}%` }}
/>
</div>
<p className="text-xs text-vx-muted mt-1">
{t("governance.quorum.required", { percent: formatPercent(tally.quorumFraction) })}
{" · "}
<span className={tally.quorumMet ? "text-vx-sage" : "text-vx-amber"}>
{t(tally.quorumMet ? "governance.quorum.met" : "governance.quorum.notMet")}
</span>
</p>
</div>
<p className="text-xs text-vx-muted">
{t("governance.quorum.approval", {
percent: formatPercent(tally.approvalFraction),
threshold: formatPercent(tally.approvalThreshold),
})}
{" · "}
<span className={tally.approvalMet ? "text-vx-sage" : "text-vx-amber"}>
{t(tally.approvalMet ? "governance.quorum.passing" : "governance.quorum.failing")}
</span>
</p>
<div className="bg-vx-surface/40 p-3 rounded-lg border border-vx-border">
<div className="text-xs text-vx-muted">{t("governance.quorum.abstain")}</div>
<div className="text-lg font-bold text-vx-text num">{tally.votesAbstain.toLocaleString()}</div>
</div>
</section>
)}
</div>

{/* ── Community Discussion / Comment Thread Section ── */}
Expand Down
32 changes: 0 additions & 32 deletions src/app/governance/archive/page.tsx
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -226,32 +223,3 @@ export default async function GovernanceArchivePage({
</main>
);
}

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 (
<section>
<h2 className="text-lg font-semibold">Voter history</h2>
<a href={`data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`} download={`voter-${address}.csv`} className="text-sm underline">
Export CSV
</a>
{votes.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground">No votes recorded for this address.</p>
) : (
<ul className="mt-4 divide-y rounded border">
{votes.map((v) => (
<li key={`${v.proposalId}-${v.timestamp}`} className="flex flex-wrap items-center justify-between gap-2 p-3 text-sm">
<Link href={`/governance/${v.proposalId}`} className="underline-offset-2 hover:underline">
{v.proposalId}
</Link>
<span>{v.choice}</span>
<span>{v.weight}</span>
<time dateTime={v.timestamp}>{v.timestamp}</time>
</li>
))}
</ul>
)}
</section>
);
}
14 changes: 3 additions & 11 deletions src/hooks/useDelegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions src/hooks/useGovernance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/lib/governanceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
67 changes: 67 additions & 0 deletions src/lib/governanceImpact.tally.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions src/lib/governanceImpact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Loading
Loading