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
33 changes: 32 additions & 1 deletion app/api/dao-overview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createPublicClient>;

Expand All @@ -53,6 +64,22 @@ async function firstReachableClient(chain: "ethereum" | "lightchain"): Promise<P
throw new Error(`no ${chain} RPC reachable`);
}

async function readSchedule(pub: Pub, governor: `0x${string}`, timelock: `0x${string}`, chain: "ethereum" | "lightchain") {
const spb = SECONDS_PER_BLOCK[chain];
const [delayBlocks, periodBlocks, thresholdWei, minDelay] = await Promise.all([
pub.readContract({ address: governor, abi: SCHEDULE_ABI, functionName: "votingDelay" }).catch(() => 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),
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions app/api/dao-proposals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -221,6 +224,7 @@ export async function GET(req: Request) {
addresses,
proposals,
total,
headBlock: headBlock.toString(),
hasMore: total > proposals.length,
fetchedAt: Date.now(),
});
Expand Down
8 changes: 8 additions & 0 deletions app/build/dao/dao-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export const GOVERNOR: Record<DaoChain, `0x${string}`> = {
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<DaoChain, number> = {
ethereum: 12,
lightchain: 6,
};

export function daoPublicClient(chain: DaoChain) {
return createPublicClient({ transport: http(DAO_RPC[chain]) });
}
Expand Down
18 changes: 18 additions & 0 deletions app/build/dao/dao-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion app/build/dao/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -265,6 +285,11 @@ export default function DaoPanel() {
<CastVote chain={chain} proposalId={p.id} onVoted={() => void load(chain, limit)} />
)}

{(() => {
const t = timingLabel(p, data?.headBlock, chain);
return t ? <p className="text-[11px] font-medium text-primary">{t}</p> : null;
})()}

{(p.voteStart || p.voteEnd) && (
<p className="text-[11px] text-content-soft">Voting window: block {p.voteStart ?? "?"} → {p.voteEnd ?? "?"}</p>
)}
Expand Down
53 changes: 43 additions & 10 deletions app/build/dao/treasury-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<DaoChain, string> = { 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 (
<p className="px-1 text-[11px] leading-relaxed text-content-soft">
<span className="font-medium text-content-default">Lifecycle:</span> {delay} delay → <span className="text-content-default">{vote} voting</span> → {queue} timelock queue before execution.
{schedule.proposalThresholdWei !== "0" && (
<> Proposing needs {threshold} {VOTE_SYMBOL[chain]}.</>
)}
</p>
);
}

function Stat({ icon, label, value, href }: { icon: React.ReactNode; label: string; value: string; href?: string }) {
const body = (
<div className="flex items-center gap-2.5">
Expand Down Expand Up @@ -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 (
<div className="grid grid-cols-2 gap-4 rounded-2xl border border-bdr-soft bg-card/60 px-4 py-3.5 backdrop-blur-sm sm:grid-cols-4">
<Stat icon={<Landmark className="size-4" />} label="Treasury" value={`${formatLcaiWei(BigInt(data.treasuryWei), 2)} LCAI`} href={`${data.explorer}/address/${data.treasury}`} />
{data.feePoolWei != null && data.feePool ? (
<Stat icon={<Coins className="size-4" />} label="Fee pool" value={`${formatLcaiWei(BigInt(data.feePoolWei), 2)} LCAI`} href={`${data.explorer}/address/${data.feePool}`} />
) : (
<Stat icon={<Coins className="size-4" />} label="Vote supply" value={supply} />
)}
<Stat icon={<Target className="size-4" />} label="Quorum" value={qPct ? `${qPct % 1 === 0 ? qPct : qPct.toFixed(1)}% of supply` : "n/a"} />
<Stat icon={<Landmark className="size-4" />} label="Governor" value={shortAddr(data.governor)} href={`${data.explorer}/address/${data.governor}`} />
<div className="space-y-3 rounded-2xl border border-bdr-soft bg-card/60 px-4 py-3.5 backdrop-blur-sm">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat icon={<Landmark className="size-4" />} label="Treasury" value={`${formatLcaiWei(BigInt(data.treasuryWei), 2)} LCAI`} href={`${data.explorer}/address/${data.treasury}`} />
{data.feePoolWei != null && data.feePool ? (
<Stat icon={<Coins className="size-4" />} label="Fee pool" value={`${formatLcaiWei(BigInt(data.feePoolWei), 2)} LCAI`} href={`${data.explorer}/address/${data.feePool}`} />
) : (
<Stat icon={<Coins className="size-4" />} label="Vote supply" value={supply} />
)}
<div title={quorumHint}>
<Stat icon={<Target className="size-4" />} label="Quorum" value={quorumValue} />
</div>
<Stat icon={<Landmark className="size-4" />} label="Governor" value={shortAddr(data.governor)} href={`${data.explorer}/address/${data.governor}`} />
</div>
{data.schedule && <ScheduleLine chain={chain} schedule={data.schedule} />}
</div>
);
}
Expand Down
26 changes: 25 additions & 1 deletion tests/unit/dao-math.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 }));
Expand Down
Loading