diff --git a/app/api/dao-voters/route.ts b/app/api/dao-voters/route.ts new file mode 100644 index 0000000..31a89f1 --- /dev/null +++ b/app/api/dao-voters/route.ts @@ -0,0 +1,70 @@ +/** + * "Who actually decides": scans the governor's VoteCast events into a voter + * participation leaderboard, and the vote-token's DelegateVotesChanged events + * into a delegate voting-power leaderboard + a concentration metric. Registry + * intelligence the official DAO doesn't surface. + */ +import { NextResponse } from "next/server"; +import { parseAbiItem } from "viem"; +import { DAO_ADDRESSES } from "lightnode-sdk"; +import { findEvents, type GovernorChain } from "@/lib/dao-governor-scan"; +import { aggregateVoters, latestDelegateWeights, concentrationPct, type VoteEvent, type DelegateEvent } from "@/lib/dao-votes"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 30; + +const VOTE_CAST = parseAbiItem( + "event VoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason)", +); +const DELEGATE_VOTES_CHANGED = parseAbiItem( + "event DelegateVotesChanged(address indexed delegate, uint256 previousVotes, uint256 newVotes)", +); + +const TOP_N = 15; + +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]; + // The vote token (ballots) is where DelegateVotesChanged lives; LightChain's + // is the native predeploy, which may not emit it (delegates list degrades to empty). + const [voteScan, delegateScan] = await Promise.all([ + findEvents(addresses.governor, chain, VOTE_CAST).catch(() => null), + findEvents(addresses.ballots as `0x${string}`, chain, DELEGATE_VOTES_CHANGED).catch(() => null), + ]); + + const voteEvents: VoteEvent[] = (voteScan?.events ?? []).map((log) => { + const a = (log as unknown as { args: { voter: `0x${string}`; support: number; weight: bigint } }).args; + return { voter: a.voter, support: Number(a.support), weightWei: a.weight }; + }); + const delegateEvents: DelegateEvent[] = (delegateScan?.events ?? []).map((log) => { + const a = (log as unknown as { args: { delegate: `0x${string}`; newVotes: bigint } }).args; + return { delegate: a.delegate, newVotesWei: a.newVotes }; + }); + + const { rows, uniqueVoters, totalVotes } = aggregateVoters(voteEvents); + const delegates = latestDelegateWeights(delegateEvents); + + return NextResponse.json({ + chain, + explorer: addresses.explorer, + totalVotes, + uniqueVoters, + delegateCount: delegates.length, + top5ConcentrationPct: concentrationPct(delegates, 5), + voters: rows.slice(0, TOP_N).map((r) => ({ + voter: r.voter, + votes: r.votes, + forVotes: r.forVotes, + against: r.against, + abstain: r.abstain, + lastWeightWei: r.lastWeightWei.toString(), + })), + delegates: delegates.slice(0, TOP_N).map((d) => ({ delegate: d.delegate, weightWei: d.weightWei.toString() })), + fetchedAt: Date.now(), + }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message?.split("\n")[0] ?? "fetch failed" }, { status: 500 }); + } +} diff --git a/app/build/dao/page.tsx b/app/build/dao/page.tsx index ada73f6..a49881b 100644 --- a/app/build/dao/page.tsx +++ b/app/build/dao/page.tsx @@ -15,6 +15,7 @@ import { VotingPowerCard } from "./voting-power-card"; import { QuorumLine } from "./quorum-line"; import { GovernorDrift } from "./governor-drift"; import { ProposalAnalytics } from "./proposal-analytics"; +import { VotersPanel } from "./voters-panel"; const CHAIN_META: Record = { ethereum: { label: "Ethereum", icon: "/logos/eth.svg" }, @@ -396,6 +397,11 @@ export default function DaoPanel() { +
+

Who decides ยท {chain}

+ +
+

Ethereum vs LightChain

diff --git a/app/build/dao/voters-panel.tsx b/app/build/dao/voters-panel.tsx new file mode 100644 index 0000000..191e09b --- /dev/null +++ b/app/build/dao/voters-panel.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Users, Crown, ExternalLink } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { short } from "@/components/build/console/panel-kit"; +import { formatLcaiWei } from "./dao-math"; +import type { DaoChain } from "./dao-chain"; + +interface VoterRow { + voter: string; + votes: number; + forVotes: number; + against: number; + abstain: number; + lastWeightWei: string; +} +interface DelegateRow { + delegate: string; + weightWei: string; +} +interface VotersResp { + explorer: string; + totalVotes: number; + uniqueVoters: number; + delegateCount: number; + top5ConcentrationPct: number; + voters: VoterRow[]; + delegates: DelegateRow[]; + error?: string; +} + +const SYMBOL: Record = { ethereum: "LCAIB", lightchain: "LCAI" }; + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function VotersPanel({ chain }: { chain: DaoChain }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let live = true; + setLoading(true); + setData(null); + fetch(`/api/dao-voters?chain=${chain}`) + .then((r) => r.json()) + .then((d: VotersResp) => { + if (live && !d.error) setData(d); + }) + .catch(() => {}) + .finally(() => live && setLoading(false)); + return () => { + live = false; + }; + }, [chain]); + + if (loading) return
; + if (!data || data.totalVotes === 0) { + return ( +
+ No votes recorded on {chain} yet. +
+ ); + } + + return ( +
+
+ + + + +
+ +
+ + +
+
+ ); +} + +function DelegateBoard({ rows, explorer, chain }: { rows: DelegateRow[]; explorer: string; chain: DaoChain }) { + if (rows.length === 0) { + return ( +
+ No delegate weight events on this chain (native voting reports power without a delegation log). +
+ ); + } + return ( +
+

+ Top delegates by voting power +

+ +
+ ); +} + +function VoterBoard({ rows, explorer, chain }: { rows: VoterRow[]; explorer: string; chain: DaoChain }) { + return ( + + ); +} diff --git a/lib/dao-governor-scan.ts b/lib/dao-governor-scan.ts index fbe7ec3..b80cbcc 100644 --- a/lib/dao-governor-scan.ts +++ b/lib/dao-governor-scan.ts @@ -8,7 +8,7 @@ * 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"; +import { createPublicClient, http, parseAbiItem, type AbiEvent } from "viem"; export type GovernorChain = "ethereum" | "lightchain"; @@ -53,10 +53,11 @@ function withTimeout(p: Promise, ms: number, label: string): Promise { } /** - * 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. + * Generic chunked event scan from a contract's deployment block to head, racing + * a deadline across the RPC list. Returns the client that answered (reuse it for + * follow-up reads), the decoded events, and the head block. */ -export async function findGovernorEvents(governor: `0x${string}`, chain: GovernorChain) { +export async function findEvents(address: `0x${string}`, chain: GovernorChain, event: AbiEvent) { const errors: string[] = []; for (const rpc of RPCS_BY_CHAIN[chain]) { try { @@ -72,7 +73,7 @@ export async function findGovernorEvents(governor: `0x${string}`, chain: Governo 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 })), + windows.map((w) => pub.getLogs({ address, event, fromBlock: w.from, toBlock: w.to })), ); return { pub, events: chunks.flat(), head }; })(), @@ -86,3 +87,8 @@ export async function findGovernorEvents(governor: `0x${string}`, chain: Governo } throw new Error("all RPCs failed: " + errors.join(" | ")); } + +/** Scan every `ProposalCreated` event for a governor (thin wrapper over findEvents). */ +export function findGovernorEvents(governor: `0x${string}`, chain: GovernorChain) { + return findEvents(governor, chain, PROPOSAL_CREATED); +} diff --git a/lib/dao-votes.ts b/lib/dao-votes.ts new file mode 100644 index 0000000..c364f6c --- /dev/null +++ b/lib/dao-votes.ts @@ -0,0 +1,68 @@ +/** + * Pure aggregation of governor VoteCast + DelegateVotesChanged events into a + * "who actually decides" view: a voter participation leaderboard and a delegate + * voting-power leaderboard. No network, so it unit-tests directly. + * + * OZ support encoding: 0 = Against, 1 = For, 2 = Abstain. + */ +export interface VoteEvent { + voter: string; + support: number; + weightWei: bigint; +} +export interface DelegateEvent { + delegate: string; + newVotesWei: bigint; +} + +export interface VoterRow { + voter: string; + votes: number; + forVotes: number; + against: number; + abstain: number; + lastWeightWei: bigint; +} + +export function aggregateVoters(events: VoteEvent[]): { rows: VoterRow[]; uniqueVoters: number; totalVotes: number } { + const byVoter = new Map(); + for (const e of events) { + const key = e.voter.toLowerCase(); + const row = byVoter.get(key) ?? { voter: e.voter, votes: 0, forVotes: 0, against: 0, abstain: 0, lastWeightWei: 0n }; + row.votes += 1; + if (e.support === 1) row.forVotes += 1; + else if (e.support === 0) row.against += 1; + else if (e.support === 2) row.abstain += 1; + // Events arrive chronologically, so the last seen weight is the most recent. + row.lastWeightWei = e.weightWei; + byVoter.set(key, row); + } + const rows = [...byVoter.values()].sort((a, b) => { + if (b.votes !== a.votes) return b.votes - a.votes; + return b.lastWeightWei > a.lastWeightWei ? 1 : b.lastWeightWei < a.lastWeightWei ? -1 : 0; + }); + return { rows, uniqueVoters: byVoter.size, totalVotes: events.length }; +} + +export interface DelegateRow { + delegate: string; + weightWei: bigint; +} + +/** Latest cumulative voting weight per delegate (events must be chronological). */ +export function latestDelegateWeights(events: DelegateEvent[]): DelegateRow[] { + // Dedup case-insensitively but keep the original-cased address for display. + const latest = new Map(); + for (const e of events) latest.set(e.delegate.toLowerCase(), { delegate: e.delegate, weightWei: e.newVotesWei }); + return [...latest.values()] + .filter((d) => d.weightWei > 0n) + .sort((a, b) => (b.weightWei > a.weightWei ? 1 : b.weightWei < a.weightWei ? -1 : 0)); +} + +/** Share of total delegated weight held by the top N delegates (0..100). */ +export function concentrationPct(rows: DelegateRow[], topN: number): number { + const total = rows.reduce((sum, r) => sum + r.weightWei, 0n); + if (total === 0n) return 0; + const top = rows.slice(0, topN).reduce((sum, r) => sum + r.weightWei, 0n); + return Math.round(Number((top * 10_000n) / total) / 100); +} diff --git a/tests/unit/dao-votes.test.ts b/tests/unit/dao-votes.test.ts new file mode 100644 index 0000000..e8e1a63 --- /dev/null +++ b/tests/unit/dao-votes.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { aggregateVoters, latestDelegateWeights, concentrationPct } from "../../lib/dao-votes"; + +const e = (n: number) => BigInt(n) * 10n ** 18n; +const A = "0xAAaAAa00000000000000000000000000000000aa"; +const B = "0xBBbBBb00000000000000000000000000000000bb"; +const C = "0xCCcCCc00000000000000000000000000000000cc"; + +describe("aggregateVoters", () => { + it("tallies votes per voter and breaks down support (0=against,1=for,2=abstain)", () => { + const { rows, uniqueVoters, totalVotes } = aggregateVoters([ + { voter: A, support: 1, weightWei: e(100) }, + { voter: A, support: 0, weightWei: e(120) }, + { voter: B, support: 2, weightWei: e(50) }, + ]); + expect(totalVotes).toBe(3); + expect(uniqueVoters).toBe(2); + const a = rows.find((r) => r.voter === A)!; + expect(a.votes).toBe(2); + expect(a.forVotes).toBe(1); + expect(a.against).toBe(1); + expect(a.lastWeightWei).toBe(e(120)); // most recent weight wins + }); + + it("ranks by participation, then by last weight", () => { + const { rows } = aggregateVoters([ + { voter: A, support: 1, weightWei: e(10) }, + { voter: B, support: 1, weightWei: e(10) }, + { voter: B, support: 1, weightWei: e(10) }, + { voter: C, support: 1, weightWei: e(999) }, + ]); + expect(rows[0].voter).toBe(B); // 2 votes + // A and C both have 1 vote; C has higher weight so ranks above A. + expect(rows[1].voter).toBe(C); + expect(rows[2].voter).toBe(A); + }); + + it("is case-insensitive on the voter key", () => { + const { uniqueVoters } = aggregateVoters([ + { voter: A.toLowerCase(), support: 1, weightWei: e(1) }, + { voter: A.toUpperCase(), support: 1, weightWei: e(1) }, + ]); + expect(uniqueVoters).toBe(1); + }); +}); + +describe("latestDelegateWeights", () => { + it("keeps the latest cumulative weight per delegate and drops zeros", () => { + const rows = latestDelegateWeights([ + { delegate: A, newVotesWei: e(100) }, + { delegate: A, newVotesWei: e(250) }, // later wins + { delegate: B, newVotesWei: e(500) }, + { delegate: C, newVotesWei: e(10) }, + { delegate: C, newVotesWei: 0n }, // delegated away -> dropped + ]); + expect(rows.map((r) => r.delegate)).toEqual([B, A]); // sorted desc, C excluded + expect(rows[1].weightWei).toBe(e(250)); + }); +}); + +describe("concentrationPct", () => { + it("computes the top-N share of total delegated weight", () => { + const rows = [ + { delegate: A, weightWei: e(60) }, + { delegate: B, weightWei: e(30) }, + { delegate: C, weightWei: e(10) }, + ]; + expect(concentrationPct(rows, 1)).toBe(60); + expect(concentrationPct(rows, 2)).toBe(90); + }); + it("returns 0 for an empty set", () => { + expect(concentrationPct([], 5)).toBe(0); + }); +});