diff --git a/app/api/dao-overview/route.ts b/app/api/dao-overview/route.ts index e26f531..56d284a 100644 --- a/app/api/dao-overview/route.ts +++ b/app/api/dao-overview/route.ts @@ -37,6 +37,17 @@ const QUORUM_ABI = parseAbi([ "function quorumNumerator() view returns (uint256)", "function quorumDenominator() view returns (uint256)", ]); +const SCHEDULE_ABI = parseAbi([ + "function votingDelay() view returns (uint256)", + "function votingPeriod() view returns (uint256)", + "function proposalThreshold() view returns (uint256)", +]); +const TIMELOCK_ABI = parseAbi(["function getMinDelay() view returns (uint256)"]); + +// Both governors count voting in blocks (CLOCK_MODE=blocknumber), so we convert +// votingDelay/votingPeriod to real time with the measured mainnet block time: +// Ethereum ~12s (slot), LightChain ~6s. Timelock delay is already in seconds. +const SECONDS_PER_BLOCK: Record<"ethereum" | "lightchain", number> = { ethereum: 12, lightchain: 6 }; type Pub = ReturnType; @@ -53,6 +64,22 @@ async function firstReachableClient(chain: "ethereum" | "lightchain"): Promise

0n), + pub.readContract({ address: governor, abi: SCHEDULE_ABI, functionName: "votingPeriod" }).catch(() => 0n), + pub.readContract({ address: governor, abi: SCHEDULE_ABI, functionName: "proposalThreshold" }).catch(() => 0n), + pub.readContract({ address: timelock, abi: TIMELOCK_ABI, functionName: "getMinDelay" }).catch(() => 0n), + ]); + return { + votingDelaySeconds: Number(delayBlocks as bigint) * spb, + votingPeriodSeconds: Number(periodBlocks as bigint) * spb, + timelockSeconds: Number(minDelay as bigint), + proposalThresholdWei: (thresholdWei as bigint).toString(), + }; +} + async function readQuorumConfig(pub: Pub, governor: `0x${string}`) { const [numerator, denominator] = await Promise.all([ pub.readContract({ address: governor, abi: QUORUM_ABI, functionName: "quorumNumerator" }).catch(() => 0n), @@ -99,7 +126,10 @@ export async function GET(req: Request) { const chain = (url.searchParams.get("chain") ?? "ethereum") === "lightchain" ? "lightchain" : "ethereum"; const addresses = DAO_ADDRESSES[chain]; const pub = await firstReachableClient(chain); - const detail = chain === "ethereum" ? await readEthereumOverview(pub) : await readLightchainOverview(pub); + const [detail, schedule] = await Promise.all([ + chain === "ethereum" ? readEthereumOverview(pub) : readLightchainOverview(pub), + readSchedule(pub, addresses.governor, addresses.timelock, chain), + ]); return NextResponse.json({ chain, governor: addresses.governor, @@ -108,6 +138,7 @@ export async function GET(req: Request) { feePool: chain === "lightchain" ? FEE_POOL : null, explorer: addresses.explorer, ...detail, + schedule, fetchedAt: Date.now(), }); } catch (e) { diff --git a/app/api/dao-proposals/route.ts b/app/api/dao-proposals/route.ts index c5000b4..e5d8b44 100644 --- a/app/api/dao-proposals/route.ts +++ b/app/api/dao-proposals/route.ts @@ -160,6 +160,9 @@ export async function GET(req: Request) { 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); const total = events.length; const recent = events.slice().reverse().slice(0, limit); const abi = GOVERNOR_ABI; @@ -221,6 +224,7 @@ export async function GET(req: Request) { addresses, proposals, total, + headBlock: headBlock.toString(), hasMore: total > proposals.length, fetchedAt: Date.now(), }); diff --git a/app/build/dao/dao-chain.ts b/app/build/dao/dao-chain.ts index 5b46b70..f49ca80 100644 --- a/app/build/dao/dao-chain.ts +++ b/app/build/dao/dao-chain.ts @@ -38,6 +38,14 @@ export const GOVERNOR: Record = { lightchain: DAO_ADDRESSES.lightchain.governor, }; +// Both governors count voting in BLOCKS (CLOCK_MODE=blocknumber), so block time +// is needed to turn votingDelay/votingPeriod + deadline distance into real time. +// Measured on mainnet: Ethereum ~12.04s (slot time), LightChain ~6.00s. +export const SECONDS_PER_BLOCK: Record = { + ethereum: 12, + lightchain: 6, +}; + export function daoPublicClient(chain: DaoChain) { return createPublicClient({ transport: http(DAO_RPC[chain]) }); } diff --git a/app/build/dao/dao-math.ts b/app/build/dao/dao-math.ts index aa2a75a..7403152 100644 --- a/app/build/dao/dao-math.ts +++ b/app/build/dao/dao-math.ts @@ -61,6 +61,24 @@ export function quorumPercent(numerator: string, denominator: string): number { return (n / d) * 100; } +/** Human duration from seconds: "1 day", "7 days", "12 hours", "30 min". */ +export function humanizeDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; + const days = seconds / 86_400; + if (days >= 1) { + const rounded = Math.round(days * 10) / 10; + const label = rounded % 1 === 0 ? String(rounded) : rounded.toFixed(1); + return `${label} day${rounded === 1 ? "" : "s"}`; + } + const hours = seconds / 3_600; + if (hours >= 1) { + const h = Math.round(hours); + return `${h} hour${h === 1 ? "" : "s"}`; + } + const mins = Math.max(1, Math.round(seconds / 60)); + return `${mins} min`; +} + /** Format a wei amount as a human LCAI/LCAIB string. Display-only (may approximate huge values). */ export function formatLcaiWei(wei: bigint, maxFrac = 0): string { const n = Number(wei) / 1e18; diff --git a/app/build/dao/page.tsx b/app/build/dao/page.tsx index 61689c2..36eb835 100644 --- a/app/build/dao/page.tsx +++ b/app/build/dao/page.tsx @@ -8,7 +8,8 @@ import { CodeTabs } from "@/components/build/console/code-tabs"; import { Notice, short } from "@/components/build/console/panel-kit"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { DAO_EXPLORER, type DaoChain } from "./dao-chain"; +import { DAO_EXPLORER, SECONDS_PER_BLOCK, type DaoChain } from "./dao-chain"; +import { humanizeDuration } from "./dao-math"; import { TreasuryBar } from "./treasury-bar"; import { VotingPowerCard } from "./voting-power-card"; import { QuorumLine } from "./quorum-line"; @@ -47,9 +48,28 @@ interface DaoResponse { addresses?: { governor: string }; total: number; hasMore: boolean; + headBlock?: string; proposals: Proposal[]; } +/** Human "voting ends/opens in ~X" from the proposal's block window vs the head. */ +function timingLabel(p: Proposal, headBlock: string | undefined, chain: DaoChain): string | null { + if (!headBlock) return null; + try { + const head = BigInt(headBlock); + if (head === 0n) return null; + const spb = SECONDS_PER_BLOCK[chain]; + const state = p.stateLabel.toLowerCase(); + const deadline = BigInt(p.deadlineBlock ?? p.voteEnd ?? "0"); + const start = BigInt(p.voteStart ?? "0"); + if (state === "active" && deadline > head) return `Voting ends in ~${humanizeDuration(Number(deadline - head) * spb)}`; + if (state === "pending" && start > head) return `Voting opens in ~${humanizeDuration(Number(start - head) * spb)}`; + return null; + } catch { + return null; + } +} + function snippetFor(chain: DaoChain): string { const rpcVar = chain === "ethereum" ? "ETH_RPC" : "LIGHTCHAIN_RPC"; const tokenNote = @@ -265,6 +285,11 @@ export default function DaoPanel() { void load(chain, limit)} /> )} + {(() => { + const t = timingLabel(p, data?.headBlock, chain); + return t ?

{t}

: null; + })()} + {(p.voteStart || p.voteEnd) && (

Voting window: block {p.voteStart ?? "?"} → {p.voteEnd ?? "?"}

)} diff --git a/app/build/dao/treasury-bar.tsx b/app/build/dao/treasury-bar.tsx index 1f547c9..1c46b12 100644 --- a/app/build/dao/treasury-bar.tsx +++ b/app/build/dao/treasury-bar.tsx @@ -2,9 +2,15 @@ import { useEffect, useState } from "react"; import { Landmark, Coins, Target, ExternalLink } from "lucide-react"; -import { formatLcaiWei, quorumPercent } from "./dao-math"; +import { formatLcaiWei, quorumPercent, humanizeDuration } from "./dao-math"; import type { DaoChain } from "./dao-chain"; +interface Schedule { + votingDelaySeconds: number; + votingPeriodSeconds: number; + timelockSeconds: number; + proposalThresholdWei: string; +} interface Overview { chain: DaoChain; governor: string; @@ -15,9 +21,27 @@ interface Overview { feePoolWei: string | null; voteToken: { address: string; symbol: string; totalSupplyWei: string | null }; quorum: { numerator: string; denominator: string }; + schedule?: Schedule; error?: string; } +const VOTE_SYMBOL: Record = { ethereum: "LCAIB", lightchain: "LCAI" }; + +function ScheduleLine({ chain, schedule }: { chain: DaoChain; schedule: Schedule }) { + const vote = humanizeDuration(schedule.votingPeriodSeconds); + const delay = humanizeDuration(schedule.votingDelaySeconds); + const queue = humanizeDuration(schedule.timelockSeconds); + const threshold = formatLcaiWei(BigInt(schedule.proposalThresholdWei), 0); + return ( +

+ Lifecycle: {delay} delay → {vote} voting → {queue} timelock queue before execution. + {schedule.proposalThresholdWei !== "0" && ( + <> Proposing needs {threshold} {VOTE_SYMBOL[chain]}. + )} +

+ ); +} + function Stat({ icon, label, value, href }: { icon: React.ReactNode; label: string; value: string; href?: string }) { const body = (
@@ -65,16 +89,25 @@ export function TreasuryBar({ chain }: { chain: DaoChain }) { const qPct = quorumPercent(data.quorum.numerator, data.quorum.denominator); const supply = data.voteToken.totalSupplyWei ? `${formatLcaiWei(BigInt(data.voteToken.totalSupplyWei), 0)} ${sym}` : "native stake"; + const quorumValue = qPct ? `${qPct % 1 === 0 ? qPct : qPct.toFixed(1)}% of supply` : "n/a"; + const quorumHint = data.voteToken.totalSupplyWei + ? `${quorumValue} - needs ${formatLcaiWei((BigInt(data.voteToken.totalSupplyWei) * BigInt(data.quorum.numerator)) / BigInt(data.quorum.denominator || "1"), 0)} ${VOTE_SYMBOL[chain]} of For+Abstain to be valid` + : `${quorumValue} of the native vote supply`; return ( -
- } label="Treasury" value={`${formatLcaiWei(BigInt(data.treasuryWei), 2)} LCAI`} href={`${data.explorer}/address/${data.treasury}`} /> - {data.feePoolWei != null && data.feePool ? ( - } label="Fee pool" value={`${formatLcaiWei(BigInt(data.feePoolWei), 2)} LCAI`} href={`${data.explorer}/address/${data.feePool}`} /> - ) : ( - } label="Vote supply" value={supply} /> - )} - } label="Quorum" value={qPct ? `${qPct % 1 === 0 ? qPct : qPct.toFixed(1)}% of supply` : "n/a"} /> - } label="Governor" value={shortAddr(data.governor)} href={`${data.explorer}/address/${data.governor}`} /> +
+
+ } label="Treasury" value={`${formatLcaiWei(BigInt(data.treasuryWei), 2)} LCAI`} href={`${data.explorer}/address/${data.treasury}`} /> + {data.feePoolWei != null && data.feePool ? ( + } label="Fee pool" value={`${formatLcaiWei(BigInt(data.feePoolWei), 2)} LCAI`} href={`${data.explorer}/address/${data.feePool}`} /> + ) : ( + } label="Vote supply" value={supply} /> + )} +
+ } label="Quorum" value={quorumValue} /> +
+ } label="Governor" value={shortAddr(data.governor)} href={`${data.explorer}/address/${data.governor}`} /> +
+ {data.schedule && }
); } diff --git a/tests/unit/dao-math.test.ts b/tests/unit/dao-math.test.ts index 6a41991..1ab0b36 100644 --- a/tests/unit/dao-math.test.ts +++ b/tests/unit/dao-math.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { quorumStatus, delegationStatus, quorumPercent, formatLcaiWei } from "../../app/build/dao/dao-math"; +import { quorumStatus, delegationStatus, quorumPercent, formatLcaiWei, humanizeDuration } from "../../app/build/dao/dao-math"; const e = (n: number) => BigInt(n) * 10n ** 18n; // n LCAI in wei const ZERO = "0x0000000000000000000000000000000000000000"; @@ -86,6 +86,30 @@ describe("quorumPercent", () => { }); }); +describe("humanizeDuration (block-derived voting timeline)", () => { + it("formats the Ethereum DAO: 7-day vote, 2-day queue, 1-day delay", () => { + expect(humanizeDuration(50_400 * 12)).toBe("7 days"); // votingPeriod blocks * 12s + expect(humanizeDuration(172_800)).toBe("2 days"); // timelock + expect(humanizeDuration(7_200 * 12)).toBe("1 day"); // votingDelay + }); + it("formats the LightChain native DAO's 14-day vote (still old setting)", () => { + expect(humanizeDuration(201_600 * 6)).toBe("14 days"); + expect(humanizeDuration(14_400 * 6)).toBe("1 day"); + }); + it("falls back to hours and minutes under a day", () => { + expect(humanizeDuration(43_200)).toBe("12 hours"); + expect(humanizeDuration(3_600)).toBe("1 hour"); + expect(humanizeDuration(900)).toBe("15 min"); + }); + it("keeps one decimal for fractional days", () => { + expect(humanizeDuration(Math.round(1.5 * 86_400))).toBe("1.5 days"); + }); + it("guards non-positive input", () => { + expect(humanizeDuration(0)).toBe("0s"); + expect(humanizeDuration(-5)).toBe("0s"); + }); +}); + describe("formatLcaiWei", () => { it("formats whole tokens with grouping", () => { expect(formatLcaiWei(e(1_234_567), 0)).toBe((1234567).toLocaleString(undefined, { maximumFractionDigits: 0 }));