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
70 changes: 70 additions & 0 deletions app/api/dao-voters/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
6 changes: 6 additions & 0 deletions app/build/dao/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DaoChain, { label: string; icon: string }> = {
ethereum: { label: "Ethereum", icon: "/logos/eth.svg" },
Expand Down Expand Up @@ -396,6 +397,11 @@ export default function DaoPanel() {
<ProposalAnalytics chain={chain} activeFilter={stateFilter} onFilter={applyStateFilter} />
</section>

<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-soft">Who decides · {chain}</h2>
<VotersPanel 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 />
Expand Down
150 changes: 150 additions & 0 deletions app/build/dao/voters-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<DaoChain, string> = { ethereum: "LCAIB", lightchain: "LCAI" };

function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-bdr-soft bg-surface-base-faint/40 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-content-soft">{label}</p>
<p className="mt-0.5 text-lg font-semibold tabular-nums text-content-primary">{value}</p>
</div>
);
}

export function VotersPanel({ chain }: { chain: DaoChain }) {
const [data, setData] = useState<VotersResp | null>(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 <div className="h-56 animate-pulse rounded-2xl border border-bdr-soft bg-surface-base-faint" />;
if (!data || data.totalVotes === 0) {
return (
<div className="rounded-2xl border border-dashed border-bdr-soft px-4 py-8 text-center text-sm text-content-soft">
No votes recorded on {chain} yet.
</div>
);
}

return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Votes cast" value={data.totalVotes.toLocaleString()} />
<Stat label="Unique voters" value={data.uniqueVoters.toLocaleString()} />
<Stat label="Delegates" value={data.delegateCount.toLocaleString()} />
<Stat label="Top-5 power" value={`${data.top5ConcentrationPct}%`} />
</div>

<div className="grid gap-4 lg:grid-cols-2">
<DelegateBoard rows={data.delegates} explorer={data.explorer} chain={chain} />
<VoterBoard rows={data.voters} explorer={data.explorer} chain={chain} />
</div>
</div>
);
}

function DelegateBoard({ rows, explorer, chain }: { rows: DelegateRow[]; explorer: string; chain: DaoChain }) {
if (rows.length === 0) {
return (
<div className="rounded-2xl border border-bdr-soft bg-card/60 p-4 text-xs text-content-soft">
No delegate weight events on this chain (native voting reports power without a delegation log).
</div>
);
}
return (
<div className="rounded-2xl border border-bdr-soft bg-card/60 p-4 backdrop-blur-sm">
<p className="mb-2.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-content-soft">
<Crown className="size-3.5 text-primary" /> Top delegates by voting power
</p>
<div className="space-y-1.5">
{rows.map((d, i) => (
<a
key={d.delegate}
href={`${explorer}/address/${d.delegate}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-surface-base-faint/50"
>
<span className="w-4 shrink-0 text-right tabular-nums text-content-soft">{i + 1}</span>
<span className="font-mono text-content-primary">{short(d.delegate)}</span>
<span className="ml-auto tabular-nums text-content-default">{formatLcaiWei(BigInt(d.weightWei), 0)}</span>
<span className="text-[10px] text-content-soft">{SYMBOL[chain]}</span>
<ExternalLink className="size-3 shrink-0 text-content-soft" />
</a>
))}
</div>
</div>
);
}

function VoterBoard({ rows, explorer, chain }: { rows: VoterRow[]; explorer: string; chain: DaoChain }) {
return (
<div className="rounded-2xl border border-bdr-soft bg-card/60 p-4 backdrop-blur-sm">
<p className="mb-2.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-content-soft">
<Users className="size-3.5 text-primary" /> Most active voters
</p>
<div className="space-y-1.5">
{rows.map((v, i) => (
<a
key={v.voter}
href={`${explorer}/address/${v.voter}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-surface-base-faint/50"
>
<span className="w-4 shrink-0 text-right tabular-nums text-content-soft">{i + 1}</span>
<span className="font-mono text-content-primary">{short(v.voter)}</span>
<span className="ml-auto tabular-nums text-content-default">{v.votes} votes</span>
<span className="hidden text-[10px] text-content-soft sm:inline">
<span className="text-success">{v.forVotes}F</span> / <span className="text-destructive">{v.against}A</span> / {v.abstain}Ab
</span>
</a>
))}
</div>
</div>
);
}
16 changes: 11 additions & 5 deletions lib/dao-governor-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -53,10 +53,11 @@ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
}

/**
* 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 {
Expand All @@ -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 };
})(),
Expand All @@ -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);
}
68 changes: 68 additions & 0 deletions lib/dao-votes.ts
Original file line number Diff line number Diff line change
@@ -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<string, VoterRow>();
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<string, DelegateRow>();
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);
}
Loading
Loading