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
90 changes: 90 additions & 0 deletions app/api/dao-analytics/route.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createPublicClient>;

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<ProposalOutcome> {
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<T, R>(items: T[], size: number, fn: (item: T) => Promise<R>): Promise<R[]> {
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 });
}
}
101 changes: 5 additions & 96 deletions app/api/dao-proposals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,16 @@
* 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";
// The full-history scan fans out ~20 parallel getLogs; give it room beyond the
// 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;
Expand All @@ -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.
Expand All @@ -91,64 +59,6 @@ async function readQuorum(
}
}

function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_, 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);
Expand All @@ -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;
Expand Down
132 changes: 132 additions & 0 deletions app/build/dao/governor-drift.tsx
Original file line number Diff line number Diff line change
@@ -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<DaoChain, { label: string; icon: string }> = {
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<ChainStats | null> {
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 (
<div className="flex items-center justify-end gap-1.5">
<Image src={CHAIN_META[chain].icon} alt="" width={14} height={14} className="size-3.5 rounded-full object-contain" />
<span className="text-xs font-semibold text-content-primary">{CHAIN_META[chain].label}</span>
</div>
);
}

export function GovernorDrift() {
const [eth, setEth] = useState<ChainStats | null>(null);
const [lc, setLc] = useState<ChainStats | null>(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 <div className="h-64 animate-pulse rounded-2xl border border-bdr-soft bg-surface-base-faint" />;
if (!eth || !lc) return null;

const driftCount = ROWS.filter((r) => eth[r.key] !== lc[r.key]).length;

return (
<div className="overflow-hidden rounded-2xl border border-bdr-soft bg-card/60 backdrop-blur-sm">
<div className="flex items-center gap-2 border-b border-bdr-soft px-4 py-3">
<ArrowLeftRight className="size-4 text-primary" />
<h3 className="text-sm font-semibold text-content-primary">Governor drift</h3>
<span className="ml-auto text-[11px] text-content-soft">{driftCount} of {ROWS.length} parameters differ</span>
</div>

<div className="grid grid-cols-[1fr_auto_auto] gap-x-5 gap-y-0 px-4 py-2 sm:gap-x-8">
<span />
<ChainHead chain="ethereum" />
<ChainHead chain="lightchain" />
{ROWS.map((r) => {
const differs = eth[r.key] !== lc[r.key];
return (
<Row key={r.key} label={r.label} eth={eth[r.key]} lc={lc[r.key]} differs={differs} />
);
})}
</div>

<p className="border-t border-bdr-soft px-4 py-3 text-[11px] leading-relaxed text-content-soft">
Governance is live on <span className="text-content-default">Ethereum</span> ({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.
</p>
</div>
);
}

function Row({ label, eth, lc, differs }: { label: string; eth: string; lc: string; differs: boolean }) {
return (
<>
<div className={cn("border-t border-bdr-soft/60 py-2 text-xs", differs ? "text-content-primary" : "text-content-soft")}>
{label}
{differs && <span className="ml-1.5 rounded bg-warning/15 px-1 py-0.5 text-[9px] font-semibold uppercase text-warning">differs</span>}
</div>
<div className={cn("border-t border-bdr-soft/60 py-2 text-right text-xs tabular-nums", differs ? "font-semibold text-content-primary" : "text-content-default")}>{eth}</div>
<div className={cn("border-t border-bdr-soft/60 py-2 text-right text-xs tabular-nums", differs ? "font-semibold text-content-primary" : "text-content-default")}>{lc}</div>
</>
);
}
12 changes: 12 additions & 0 deletions app/build/dao/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DaoChain, { label: string; icon: string }> = {
ethereum: { label: "Ethereum", icon: "/logos/eth.svg" },
Expand Down Expand Up @@ -344,6 +346,16 @@ export default function DaoPanel() {
)}
</ConsolePanel>

<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-soft">Outcome analytics · {chain}</h2>
<ProposalAnalytics chain={chain} />
</section>

<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-soft">Ethereum vs LightChain</h2>
<GovernorDrift />
</section>

<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-soft">The SDK behind it</h2>
<CodeTabs tabs={[{ label: "TypeScript", code: snippetFor(chain) }]} />
Expand Down
Loading
Loading