diff --git a/app/api/dao-analytics/route.ts b/app/api/dao-analytics/route.ts new file mode 100644 index 0000000..2e2eb36 --- /dev/null +++ b/app/api/dao-analytics/route.ts @@ -0,0 +1,90 @@ +/** + * Governance analytics: scans EVERY proposal for a governor and returns outcome + * stats (pass rate, quorum-hit rate, state breakdown) plus the timeline of what + * the DAO has actually executed, decoded into plain English. This is the + * registry intelligence the official tools don't surface; lightnode reads it. + */ +import { NextResponse } from "next/server"; +import type { createPublicClient } from "viem"; +import { DAO_ADDRESSES, PROPOSAL_STATE_LABEL, GOVERNOR_ABI, decodeGovernanceAction, type ProposalState } from "lightnode-sdk"; +import { findGovernorEvents, type GovernorChain } from "@/lib/dao-governor-scan"; +import { computeOutcomeStats, type ProposalOutcome } from "@/lib/dao-analytics"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 30; + +type Pub = ReturnType; + +interface EventArgs { + proposalId: bigint; + description: string; + targets?: `0x${string}`[]; + values?: bigint[]; + calldatas?: `0x${string}`[]; +} + +function deriveTitle(description: string): string { + const first = description.split(/\r?\n/)[0]?.trim() ?? ""; + const cleaned = first.replace(/^#+\s*/, "").replace(/^Proposal[:\-]?\s*/i, "").replace(/\.$/, ""); + return cleaned.length ? cleaned.slice(0, 100) : "Untitled proposal"; +} + +// Read state + votes + quorum for one proposal. Each read defaults safely so a +// throttled RPC degrades the stats rather than failing the whole scan. +async function readOutcome(pub: Pub, governor: `0x${string}`, id: bigint): Promise { + const [stateRaw, votes, snapshot] = (await Promise.all([ + pub.readContract({ address: governor, abi: GOVERNOR_ABI, functionName: "state", args: [id] }).catch(() => -1), + pub.readContract({ address: governor, abi: GOVERNOR_ABI, functionName: "proposalVotes", args: [id] }).catch(() => [0n, 0n, 0n]), + pub.readContract({ address: governor, abi: GOVERNOR_ABI, functionName: "proposalSnapshot", args: [id] }).catch(() => 0n), + ])) as [number, [bigint, bigint, bigint], bigint]; + const quorumWei = + snapshot > 0n + ? ((await pub.readContract({ address: governor, abi: GOVERNOR_ABI, functionName: "quorum", args: [snapshot] }).catch(() => 0n)) as bigint) + : 0n; + return { + stateLabel: PROPOSAL_STATE_LABEL[stateRaw as ProposalState] ?? "unknown", + votesForWei: votes[1], + votesAbstainWei: votes[2], + quorumWei, + }; +} + +// Process proposals in small concurrent batches to keep free-RPC load sane. +async function mapBatched(items: T[], size: number, fn: (item: T) => Promise): Promise { + const out: R[] = []; + for (let i = 0; i < items.length; i += size) { + out.push(...(await Promise.all(items.slice(i, i + size).map(fn)))); + } + return out; +} + +export async function GET(req: Request) { + try { + const chain = ((new URL(req.url).searchParams.get("chain") ?? "ethereum") === "lightchain" ? "lightchain" : "ethereum") as GovernorChain; + const addresses = DAO_ADDRESSES[chain]; + const { pub, events } = await findGovernorEvents(addresses.governor, chain); + const ordered = events.slice().reverse(); // newest first + + const argsList = ordered.map((log) => (log as unknown as { args: EventArgs }).args); + const outcomes = await mapBatched(argsList, 6, (a) => readOutcome(pub, addresses.governor, a.proposalId)); + const stats = computeOutcomeStats(outcomes); + + // Executed timeline: what governance actually enacted, decoded to English. + const executed = argsList + .map((a, i) => ({ a, state: outcomes[i].stateLabel })) + .filter((x) => x.state === "executed") + .slice(0, 12) + .map(({ a }) => ({ + id: a.proposalId.toString(), + title: deriveTitle(a.description), + actions: (a.targets ?? []).map((target, i) => + decodeGovernanceAction({ target, value: a.values?.[i] ?? 0n, calldata: a.calldatas?.[i] ?? "0x" }), + ), + })); + + return NextResponse.json({ chain, governor: addresses.governor, explorer: addresses.explorer, stats, executed, fetchedAt: Date.now() }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message?.split("\n")[0] ?? "fetch failed" }, { status: 500 }); + } +} diff --git a/app/api/dao-proposals/route.ts b/app/api/dao-proposals/route.ts index e5d8b44..2f16aa6 100644 --- a/app/api/dao-proposals/route.ts +++ b/app/api/dao-proposals/route.ts @@ -10,8 +10,9 @@ * by default to keep RPC pressure low. */ import { NextResponse } from "next/server"; -import { createPublicClient, http, parseAbiItem } from "viem"; +import { createPublicClient, http } from "viem"; import { DAO_ADDRESSES, PROPOSAL_STATE_LABEL, GOVERNOR_ABI, decodeGovernanceAction, type ProposalState } from "lightnode-sdk"; +import { findGovernorEvents, RPCS_BY_CHAIN } from "@/lib/dao-governor-scan"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -19,25 +20,6 @@ export const runtime = "nodejs"; // default serverless budget. export const maxDuration = 30; -// Chain of public RPCs per network. We hit them in order until one returns -// something usable for getLogs. Free public endpoints get rate-limited or -// time out on large block ranges, so the first one to answer wins. -const RPCS_BY_CHAIN: Record<"ethereum" | "lightchain", string[]> = { - ethereum: process.env.LIGHTNODE_ETH_RPC - ? [process.env.LIGHTNODE_ETH_RPC] - : [ - "https://ethereum-rpc.publicnode.com", - "https://eth.merkle.io", - "https://rpc.ankr.com/eth", - "https://eth.drpc.org", - ], - lightchain: ["https://rpc.mainnet.lightchain.ai"], -}; - -const PROPOSAL_CREATED = parseAbiItem( - "event ProposalCreated(uint256 proposalId, address proposer, address[] targets, uint256[] values, string[] signatures, bytes[] calldatas, uint256 voteStart, uint256 voteEnd, string description)", -); - function shortenDescription(s: string, max = 240): string { const trimmed = s.replace(/\s+/g, " ").trim(); if (trimmed.length <= max) return trimmed; @@ -55,20 +37,6 @@ function deriveTitle(description: string): string { return cleaned.length ? cleaned.slice(0, 120) : "Untitled proposal"; } -// Bound each RPC attempt so a slow-but-not-failing endpoint can't eat the whole -// Vercel budget before the loop tries the next one. The full-history scan fans -// out ~20 parallel getLogs, so allow a little more headroom than a recent window. -const RPC_ATTEMPT_TIMEOUT_MS = 9000; - -// Governor deployment blocks. Scanning from here (not a recent window) is the -// only way to surface EVERY proposal. Ethereum mainnet LCAIGovernor 0x6dfa... -// deployed at ~24,350,285 (verified on-chain); LightChain's is young so genesis -// is cheap. -const DEPLOY_BLOCK: Record<"ethereum" | "lightchain", bigint> = { - ethereum: 24_350_000n, - lightchain: 0n, -}; - // Read the quorum requirement (in vote-token wei) at a proposal's snapshot block. // quorum(timepoint) reverts for timepoint 0, so guard on a real snapshot and // fall back to 0n ("quorum unknown") on any failure rather than throwing. @@ -91,64 +59,6 @@ async function readQuorum( } } -function withTimeout(p: Promise, ms: number, label: string): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); - }); - return Promise.race([p, timeout]).finally(() => { - if (timer) clearTimeout(timer); - }); -} - -async function findEventsAcrossRpcs( - addresses: { governor: `0x${string}` }, - chain: "ethereum" | "lightchain", -) { - const errors: string[] = []; - // Public RPCs (notably publicnode) cap getLogs at 50k blocks per call, so we - // chunk. Scanning from the Governor's deployment block (not a recent window) - // is what surfaces EVERY proposal. Parallel keeps it inside the budget. - const CHUNK = 50_000n; - for (const rpc of RPCS_BY_CHAIN[chain]) { - try { - // Wrap the whole per-RPC attempt (head read + chunked getLogs) in one - // deadline so a single stalled socket fails fast and the loop moves on. - const result = await withTimeout( - (async () => { - const pub = createPublicClient({ transport: http(rpc) }); - const head = await pub.getBlockNumber(); - const deploy = DEPLOY_BLOCK[chain]; - const fromBlock = head > deploy ? deploy : 0n; - const windows: Array<{ from: bigint; to: bigint }> = []; - for (let start = fromBlock; start <= head; start += CHUNK) { - const end = start + CHUNK - 1n > head ? head : start + CHUNK - 1n; - windows.push({ from: start, to: end }); - } - const chunks = await Promise.all( - windows.map((w) => - pub.getLogs({ - address: addresses.governor, - event: PROPOSAL_CREATED, - fromBlock: w.from, - toBlock: w.to, - }), - ), - ); - return { pub, events: chunks.flat() }; - })(), - RPC_ATTEMPT_TIMEOUT_MS, - `${chain} RPC ${rpc.replace(/^https?:\/\//, "").slice(0, 24)}`, - ); - return result; - } catch (e) { - errors.push(`${rpc.replace(/^https?:\/\//, "").slice(0, 24)}: ${(e as Error).message?.split("\n")[0]?.slice(0, 80)}`); - continue; - } - } - throw new Error("all RPCs failed: " + errors.join(" | ")); -} - export async function GET(req: Request) { try { const url = new URL(req.url); @@ -159,10 +69,9 @@ export async function GET(req: Request) { // visitor clicks 'See more' a few times. const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? "12"))); const addresses = DAO_ADDRESSES[chain]; - const { pub, events } = await findEventsAcrossRpcs(addresses, chain); - // Current head lets the client turn each proposal's deadline block into a - // human "voting ends in ~X" countdown. - const headBlock = await pub.getBlockNumber().catch(() => 0n); + // `head` comes back from the scan so the client can turn each proposal's + // deadline block into a human "voting ends in ~X" countdown. + const { pub, events, head: headBlock } = await findGovernorEvents(addresses.governor, chain); const total = events.length; const recent = events.slice().reverse().slice(0, limit); const abi = GOVERNOR_ABI; diff --git a/app/build/dao/governor-drift.tsx b/app/build/dao/governor-drift.tsx new file mode 100644 index 0000000..b05ff98 --- /dev/null +++ b/app/build/dao/governor-drift.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { ArrowLeftRight, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { humanizeDuration, quorumPercent, formatLcaiWei } from "./dao-math"; +import type { DaoChain } from "./dao-chain"; + +interface ChainStats { + votingPeriod: string; + votingDelay: string; + timelockQueue: string; + quorum: string; + threshold: string; + proposals: string; + treasury: string; +} + +const CHAIN_META: Record = { + ethereum: { label: "Ethereum", icon: "/logos/eth.svg" }, + lightchain: { label: "LightChain", icon: "/logos/lcai.png" }, +}; +const fmtPct = (p: number) => (p % 1 === 0 ? `${p}%` : `${p.toFixed(1)}%`); + +async function loadChain(chain: DaoChain): Promise { + try { + const [ov, prop] = await Promise.all([ + fetch(`/api/dao-overview?chain=${chain}`).then((r) => r.json()), + fetch(`/api/dao-proposals?chain=${chain}&limit=1`).then((r) => r.json()), + ]); + if (ov.error || !ov.schedule) return null; + const s = ov.schedule; + const sym = chain === "ethereum" ? "LCAIB" : "LCAI"; + return { + votingDelay: humanizeDuration(s.votingDelaySeconds), + votingPeriod: humanizeDuration(s.votingPeriodSeconds), + timelockQueue: humanizeDuration(s.timelockSeconds), + quorum: fmtPct(quorumPercent(ov.quorum.numerator, ov.quorum.denominator)), + threshold: `${formatLcaiWei(BigInt(s.proposalThresholdWei), 0)} ${sym}`, + proposals: String(prop.total ?? 0), + treasury: `${formatLcaiWei(BigInt(ov.treasuryWei), 2)} LCAI`, + }; + } catch { + return null; + } +} + +const ROWS: { key: keyof ChainStats; label: string }[] = [ + { key: "proposals", label: "Proposals created" }, + { key: "votingPeriod", label: "Voting period" }, + { key: "votingDelay", label: "Voting delay" }, + { key: "timelockQueue", label: "Timelock queue" }, + { key: "quorum", label: "Quorum" }, + { key: "threshold", label: "Propose threshold" }, + { key: "treasury", label: "Treasury balance" }, +]; + +function ChainHead({ chain }: { chain: DaoChain }) { + return ( +
+ + {CHAIN_META[chain].label} +
+ ); +} + +export function GovernorDrift() { + const [eth, setEth] = useState(null); + const [lc, setLc] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let live = true; + Promise.all([loadChain("ethereum"), loadChain("lightchain")]) + .then(([e, l]) => { + if (!live) return; + setEth(e); + setLc(l); + }) + .finally(() => live && setLoading(false)); + return () => { + live = false; + }; + }, []); + + if (loading) return
; + if (!eth || !lc) return null; + + const driftCount = ROWS.filter((r) => eth[r.key] !== lc[r.key]).length; + + return ( +
+
+ +

Governor drift

+ {driftCount} of {ROWS.length} parameters differ +
+ +
+ + + + {ROWS.map((r) => { + const differs = eth[r.key] !== lc[r.key]; + return ( + + ); + })} +
+ +

+ Governance is live on Ethereum ({eth.proposals} proposals, {eth.votingPeriod} voting). The + LightChain native DAO is deployed but still on its initial {lc.votingPeriod}/{lc.quorum} settings - the + migration onto LightChain is in progress, which is why the two governors drift. +

+
+ ); +} + +function Row({ label, eth, lc, differs }: { label: string; eth: string; lc: string; differs: boolean }) { + return ( + <> +
+ {label} + {differs && differs} +
+
{eth}
+
{lc}
+ + ); +} diff --git a/app/build/dao/page.tsx b/app/build/dao/page.tsx index e62b9ae..0f7ecfb 100644 --- a/app/build/dao/page.tsx +++ b/app/build/dao/page.tsx @@ -13,6 +13,8 @@ import { humanizeDuration } from "./dao-math"; import { TreasuryBar } from "./treasury-bar"; import { VotingPowerCard } from "./voting-power-card"; import { QuorumLine } from "./quorum-line"; +import { GovernorDrift } from "./governor-drift"; +import { ProposalAnalytics } from "./proposal-analytics"; const CHAIN_META: Record = { ethereum: { label: "Ethereum", icon: "/logos/eth.svg" }, @@ -344,6 +346,16 @@ export default function DaoPanel() { )} +
+

Outcome analytics ยท {chain}

+ +
+ +
+

Ethereum vs LightChain

+ +
+

The SDK behind it

diff --git a/app/build/dao/proposal-analytics.tsx b/app/build/dao/proposal-analytics.tsx new file mode 100644 index 0000000..0c1c300 --- /dev/null +++ b/app/build/dao/proposal-analytics.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BarChart3, CheckCircle2, ScrollText } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { short } from "@/components/build/console/panel-kit"; +import type { DaoChain } from "./dao-chain"; + +interface DecodedAction { + target: string; + valueLcai: number; + label: string; + dangerous: boolean; +} +interface Executed { + id: string; + title: string; + actions: DecodedAction[]; +} +interface Stats { + total: number; + byState: Record; + decided: number; + passed: number; + passRatePct: number; + quorumChecked: number; + quorumReached: number; + quorumHitRatePct: number; +} +interface AnalyticsResp { + stats: Stats; + executed: Executed[]; + explorer: string; + error?: string; +} + +const STATE_TONE: Record = { + executed: "text-success", + succeeded: "text-success", + queued: "text-warning", + active: "text-primary", + pending: "text-warning", + defeated: "text-destructive", + canceled: "text-destructive", + expired: "text-destructive", +}; + +function Tile({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +export function ProposalAnalytics({ chain }: { chain: DaoChain }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let live = true; + setLoading(true); + setData(null); + fetch(`/api/dao-analytics?chain=${chain}`) + .then((r) => r.json()) + .then((d: AnalyticsResp) => { + if (live && !d.error) setData(d); + }) + .catch(() => {}) + .finally(() => live && setLoading(false)); + return () => { + live = false; + }; + }, [chain]); + + if (loading) return
; + if (!data) return null; + const s = data.stats; + if (s.total === 0) { + return ( +
+ No proposals on {chain} yet - nothing to analyze. +
+ ); + } + + return ( +
+
+ + + +
+ +
+ {Object.entries(s.byState) + .sort((a, b) => b[1] - a[1]) + .map(([state, count]) => ( + + {count} + {state} + + ))} +
+ + +
+ ); +} + +function ExecutedTimeline({ executed, explorer, chain }: { executed: Executed[]; explorer: string; chain: DaoChain }) { + if (executed.length === 0) { + return ( +

+ Nothing executed on {chain} yet. +

+ ); + } + return ( +
+

+ What governance has executed +

+
+ {executed.map((p) => ( +
+
+ +

{p.title}

+
+ {p.actions.length > 0 && ( +
+ {p.actions.map((act, i) => ( +
+ + {act.label} +
+ ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/lib/dao-analytics.ts b/lib/dao-analytics.ts new file mode 100644 index 0000000..8df59ee --- /dev/null +++ b/lib/dao-analytics.ts @@ -0,0 +1,57 @@ +/** + * Pure aggregation of governance outcomes across every proposal. No network, so + * it unit-tests directly; the analytics route feeds it the per-proposal reads. + * + * OZ GovernorCountingSimple: For + Abstain count toward quorum (Against excluded); + * a proposal "passed" once it reached Succeeded/Queued/Executed. + */ +export interface ProposalOutcome { + stateLabel: string; + votesForWei: bigint; + votesAbstainWei: bigint; + quorumWei: bigint; // 0 = unknown (snapshot/quorum read failed) +} + +export interface OutcomeStats { + total: number; + byState: Record; + decided: number; + passed: number; + passRatePct: number; + quorumChecked: number; + quorumReached: number; + quorumHitRatePct: number; +} + +const PASSED_STATES = new Set(["succeeded", "queued", "executed"]); +const DECIDED_STATES = new Set(["succeeded", "queued", "executed", "defeated", "expired", "canceled"]); + +export function computeOutcomeStats(proposals: ProposalOutcome[]): OutcomeStats { + const byState: Record = {}; + let passed = 0; + let decided = 0; + let quorumChecked = 0; + let quorumReached = 0; + + for (const p of proposals) { + const label = p.stateLabel.toLowerCase(); + byState[label] = (byState[label] ?? 0) + 1; + if (DECIDED_STATES.has(label)) decided += 1; + if (PASSED_STATES.has(label)) passed += 1; + if (p.quorumWei > 0n) { + quorumChecked += 1; + if (p.votesForWei + p.votesAbstainWei >= p.quorumWei) quorumReached += 1; + } + } + + return { + total: proposals.length, + byState, + decided, + passed, + passRatePct: decided > 0 ? Math.round((passed / decided) * 100) : 0, + quorumChecked, + quorumReached, + quorumHitRatePct: quorumChecked > 0 ? Math.round((quorumReached / quorumChecked) * 100) : 0, + }; +} diff --git a/lib/dao-governor-scan.ts b/lib/dao-governor-scan.ts new file mode 100644 index 0000000..c232029 --- /dev/null +++ b/lib/dao-governor-scan.ts @@ -0,0 +1,79 @@ +/** + * Shared server-side scan for OZ Governor `ProposalCreated` history. Used by the + * DAO proposals + analytics routes so the chunked, multi-RPC getLogs strategy + * lives in one place. + * + * Free public RPCs cap getLogs at ~50k blocks per call and rate-limit large + * ranges, so we chunk from the Governor's deployment block (the only way to + * surface EVERY proposal, not just a recent window) and race a per-attempt + * deadline across a list of endpoints. + */ +import { createPublicClient, http, parseAbiItem } from "viem"; + +export type GovernorChain = "ethereum" | "lightchain"; + +export const RPCS_BY_CHAIN: Record = { + ethereum: process.env.LIGHTNODE_ETH_RPC + ? [process.env.LIGHTNODE_ETH_RPC] + : ["https://ethereum-rpc.publicnode.com", "https://eth.merkle.io", "https://rpc.ankr.com/eth", "https://eth.drpc.org"], + lightchain: ["https://rpc.mainnet.lightchain.ai"], +}; + +// Governor deployment blocks. Ethereum mainnet LCAIGovernor 0x6dfa... deployed at +// ~24,350,285 (verified on-chain); LightChain's is young so genesis is cheap. +export const DEPLOY_BLOCK: Record = { + ethereum: 24_350_000n, + lightchain: 0n, +}; + +export const PROPOSAL_CREATED = parseAbiItem( + "event ProposalCreated(uint256 proposalId, address proposer, address[] targets, uint256[] values, string[] signatures, bytes[] calldatas, uint256 voteStart, uint256 voteEnd, string description)", +); + +const RPC_ATTEMPT_TIMEOUT_MS = 9000; +const CHUNK = 50_000n; + +function withTimeout(p: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([p, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +/** + * Scan every `ProposalCreated` event for a governor. Returns the public client + * that answered (reuse it for follow-up reads), the events, and the head block. + */ +export async function findGovernorEvents(governor: `0x${string}`, chain: GovernorChain) { + const errors: string[] = []; + for (const rpc of RPCS_BY_CHAIN[chain]) { + try { + return await withTimeout( + (async () => { + const pub = createPublicClient({ transport: http(rpc) }); + const head = await pub.getBlockNumber(); + const deploy = DEPLOY_BLOCK[chain]; + const fromBlock = head > deploy ? deploy : 0n; + const windows: Array<{ from: bigint; to: bigint }> = []; + for (let start = fromBlock; start <= head; start += CHUNK) { + const end = start + CHUNK - 1n > head ? head : start + CHUNK - 1n; + windows.push({ from: start, to: end }); + } + const chunks = await Promise.all( + windows.map((w) => pub.getLogs({ address: governor, event: PROPOSAL_CREATED, fromBlock: w.from, toBlock: w.to })), + ); + return { pub, events: chunks.flat(), head }; + })(), + RPC_ATTEMPT_TIMEOUT_MS, + `${chain} RPC ${rpc.replace(/^https?:\/\//, "").slice(0, 24)}`, + ); + } catch (e) { + errors.push(`${rpc.replace(/^https?:\/\//, "").slice(0, 24)}: ${(e as Error).message?.split("\n")[0]?.slice(0, 80)}`); + continue; + } + } + throw new Error("all RPCs failed: " + errors.join(" | ")); +} diff --git a/tests/unit/dao-analytics.test.ts b/tests/unit/dao-analytics.test.ts new file mode 100644 index 0000000..05b77de --- /dev/null +++ b/tests/unit/dao-analytics.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { computeOutcomeStats, type ProposalOutcome } from "../../lib/dao-analytics"; + +const e = (n: number) => BigInt(n) * 10n ** 18n; + +function p(stateLabel: string, forV = 0, abV = 0, quorum = 0): ProposalOutcome { + return { stateLabel, votesForWei: e(forV), votesAbstainWei: e(abV), quorumWei: e(quorum) }; +} + +describe("computeOutcomeStats", () => { + it("counts proposals by state (case-insensitive)", () => { + const s = computeOutcomeStats([p("Executed"), p("executed"), p("Defeated"), p("Active")]); + expect(s.total).toBe(4); + expect(s.byState.executed).toBe(2); + expect(s.byState.defeated).toBe(1); + expect(s.byState.active).toBe(1); + }); + + it("computes pass rate over DECIDED proposals only (active/pending excluded)", () => { + // 3 passed (executed, succeeded, queued), 1 defeated -> 4 decided; active not decided. + const s = computeOutcomeStats([p("executed"), p("succeeded"), p("queued"), p("defeated"), p("active")]); + expect(s.decided).toBe(4); + expect(s.passed).toBe(3); + expect(s.passRatePct).toBe(75); + }); + + it("counts For + Abstain toward quorum, excluding Against", () => { + // forced quorum 300: this one has 200 For + 150 Abstain = 350 >= 300 -> reached. + const reached = computeOutcomeStats([p("executed", 200, 150, 300)]); + expect(reached.quorumChecked).toBe(1); + expect(reached.quorumReached).toBe(1); + expect(reached.quorumHitRatePct).toBe(100); + }); + + it("marks quorum not reached when For+Abstain falls short", () => { + const s = computeOutcomeStats([p("defeated", 100, 50, 300)]); // 150 < 300 + expect(s.quorumReached).toBe(0); + expect(s.quorumHitRatePct).toBe(0); + }); + + it("excludes unknown-quorum proposals (quorumWei 0) from the hit rate", () => { + const s = computeOutcomeStats([p("executed", 500, 0, 0), p("executed", 500, 0, 300)]); + expect(s.quorumChecked).toBe(1); // only the one with a known quorum + expect(s.quorumReached).toBe(1); + expect(s.quorumHitRatePct).toBe(100); + }); + + it("returns zeroed rates with no decided/checked proposals", () => { + const s = computeOutcomeStats([p("active"), p("pending")]); + expect(s.passRatePct).toBe(0); + expect(s.quorumHitRatePct).toBe(0); + expect(s.decided).toBe(0); + }); + + it("handles an empty set", () => { + const s = computeOutcomeStats([]); + expect(s).toMatchObject({ total: 0, decided: 0, passed: 0, passRatePct: 0, quorumHitRatePct: 0 }); + }); +});