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
11 changes: 1 addition & 10 deletions app/api/dao-analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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 { findGovernorEvents, mapBatched, type GovernorChain } from "@/lib/dao-governor-scan";
import { computeOutcomeStats, type ProposalOutcome } from "@/lib/dao-analytics";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -50,15 +50,6 @@ async function readOutcome(pub: Pub, governor: `0x${string}`, id: bigint): Promi
};
}

// 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;
Expand Down
33 changes: 29 additions & 4 deletions app/api/dao-proposals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { NextResponse } from "next/server";
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";
import { findGovernorEvents, mapBatched, RPCS_BY_CHAIN } from "@/lib/dao-governor-scan";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";
Expand All @@ -37,6 +37,18 @@ function deriveTitle(description: string): string {
return cleaned.length ? cleaned.slice(0, 120) : "Untitled proposal";
}

// Read a proposal's current state as its lowercase label (for the ?state= filter).
async function readStateLabel(
pub: ReturnType<typeof createPublicClient>,
governor: `0x${string}`,
id: bigint,
): Promise<string> {
const raw = (await pub
.readContract({ address: governor, abi: GOVERNOR_ABI, functionName: "state", args: [id] })
.catch(() => -1)) as number;
return (PROPOSAL_STATE_LABEL[raw as ProposalState] ?? "unknown").toLowerCase();
}

// 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 Down Expand Up @@ -68,13 +80,26 @@ export async function GET(req: Request) {
// load cheap; max 30 protects free RPCs from getting hammered when a
// visitor clicks 'See more' a few times.
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? "12")));
// Optional state filter (e.g. ?state=executed). When set we read state for
// EVERY proposal (cheap single read each) and keep only matches, so the
// filter spans all proposals, not just the loaded page.
const stateParam = url.searchParams.get("state");
const stateFilter = stateParam ? stateParam.toLowerCase() : null;
const addresses = DAO_ADDRESSES[chain];
const abi = GOVERNOR_ABI;
// `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;

let ordered = events.slice().reverse(); // newest first
if (stateFilter) {
const labels = await mapBatched(ordered, 8, (log) =>
readStateLabel(pub, addresses.governor, (log as unknown as { args: { proposalId: bigint } }).args.proposalId),
);
ordered = ordered.filter((_, i) => labels[i] === stateFilter);
}
const total = ordered.length;
const recent = ordered.slice(0, limit);
const proposals = await Promise.all(
recent.map(async (log) => {
const args = (log as unknown as {
Expand Down
89 changes: 67 additions & 22 deletions app/build/dao/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { Loader2, RefreshCw, ChevronDown, ExternalLink, Landmark, Vote } from "lucide-react";
import { Loader2, RefreshCw, ChevronDown, ExternalLink, Landmark, Vote, X } from "lucide-react";
import { ConsolePanel } from "@/components/build/console/panel";
import { CodeTabs } from "@/components/build/console/code-tabs";
import { Notice, short } from "@/components/build/console/panel-kit";
Expand Down Expand Up @@ -73,26 +73,44 @@ function timingLabel(p: Proposal, headBlock: string | undefined, chain: DaoChain

function snippetFor(chain: DaoChain): string {
const rpcVar = chain === "ethereum" ? "ETH_RPC" : "LIGHTCHAIN_RPC";
const tokenNote =
const chainNote =
chain === "ethereum"
? `// Ethereum: LCAIB is an ERC20Votes token - delegate once to activate power.`
: `// LightChain: native voting via the genesis predeploy - stake self-delegates.`;
: `// LightChain: native voting via the genesis predeploy precompile - no wrapping.`;
return `import { createPublicClient, http } from "viem";
import { DAO } from "lightnode-sdk";
import { DAO, decodeGovernanceAction } from "lightnode-sdk";

${tokenNote}
const dao = new DAO(createPublicClient({ transport: http(${rpcVar}) }), "${chain}");
${chainNote}
// First arg is a viem PublicClient (not an RPC url); second is the chain key.
const client = createPublicClient({ transport: http(${rpcVar}) });
const dao = new DAO(client, "${chain}");

// Live reads (no wallet needed):
const p = await dao.proposal(proposalId); // state, tallies, snapshot
const quorum = await dao.quorum(p.snapshot); // wei needed to reach quorum
const power = await dao.getVotes(me, p.snapshot); // your weight at the snapshot
const voted = await dao.hasVoted(proposalId, me); // already voted?
const delegate = await dao.getDelegate(me); // who holds your voting power
// Scan recent proposals - each row carries VERIFIED, decoded calldata
// (the executing intent, not the proposer's prose) + live state:
const rows = await dao.recentProposals({ limit: 5 });
for (const p of rows) {
console.log(p.id, p.stateLabel, p.title);
for (const act of p.actions) {
// act: { label, kind, dangerous, target, valueLcai, fn } - produced by
// decodeGovernanceAction({ target, value, calldata }), callable directly.
console.log(act.dangerous ? "[privileged]" : "", act.label);
}
}

// Drill into one proposal + the governor's own voting rules:
const p = await dao.proposal(rows[0].id); // state, tallies, snapshot, eta
const cfg = await dao.config(); // delay/period blocks, threshold
const quorum = await dao.quorum(p.snapshot); // wei needed to reach quorum

// Your on-chain standing (no wallet needed):
const power = await dao.getVotes(me, p.snapshot); // weight at the snapshot
const balance = await dao.getBallotsBalance(me); // voting-token balance
const voted = await dao.hasVoted(rows[0].id, me); // already voted?
const delegate = await dao.getDelegate(me); // who holds your power

// Writes sign with your wallet: new DAO(rpc, "${chain}", walletClient)
// Writes sign with your wallet: new DAO(client, "${chain}", walletClient)
// await dao.delegate(me); // activate your voting power
// await dao.castVote(proposalId, 1); // 0 against, 1 for, 2 abstain`;
// await dao.castVote(rows[0].id, 1); // 0 against, 1 for, 2 abstain`;
}

function toneFor(label: string): "brand" | "success" | "danger" | "warning" | "muted" {
Expand Down Expand Up @@ -137,16 +155,18 @@ function VoteBar({ p }: { p: Proposal }) {
export default function DaoPanel() {
const [chain, setChain] = useState<DaoChain>("ethereum");
const [limit, setLimit] = useState(5);
const [stateFilter, setStateFilter] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [data, setData] = useState<DaoResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);

const load = useCallback(async (c: DaoChain, lim: number) => {
const load = useCallback(async (c: DaoChain, lim: number, sf: string | null) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/dao-proposals?chain=${c}&limit=${lim}`);
const res = await fetch(`/api/dao-proposals?chain=${c}&limit=${lim}${sf ? `&state=${sf}` : ""}`);
const d = (await res.json()) as DaoResponse & { error?: string };
if (!res.ok || d.error) {
setError(d.error ?? "Could not reach the governor RPC.");
Expand All @@ -161,11 +181,20 @@ export default function DaoPanel() {
}, []);

useEffect(() => {
void load(chain, limit);
}, [load, chain, limit]);
void load(chain, limit, stateFilter);
}, [load, chain, limit, stateFilter]);

// Apply a state filter from the analytics chips and bring the list into view.
const applyStateFilter = (s: string | null) => {
setStateFilter((prev) => (prev === s ? null : s));
setLimit(5);
setExpandedId(null);
panelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};

return (
<div className="space-y-10">
<div ref={panelRef}>
<ConsolePanel
kicker="Capability · DAO"
title="Governance intelligence"
Expand All @@ -180,6 +209,7 @@ export default function DaoPanel() {
onClick={() => {
setChain(c);
setLimit(5);
setStateFilter(null);
}}
aria-pressed={chain === c}
className={cn(
Expand All @@ -196,7 +226,7 @@ export default function DaoPanel() {
</div>
<button
type="button"
onClick={() => void load(chain, limit)}
onClick={() => void load(chain, limit, stateFilter)}
disabled={loading}
aria-label="Refresh"
className="grid size-9 place-items-center rounded-lg border border-bdr-soft text-content-soft transition-colors hover:text-content-primary disabled:opacity-50"
Expand Down Expand Up @@ -229,14 +259,28 @@ export default function DaoPanel() {

{!error && (
<div className="space-y-2.5">
{stateFilter && (
<div className="flex items-center gap-2 text-xs">
<span className="text-content-soft">Filtered to</span>
<button
type="button"
onClick={() => applyStateFilter(null)}
className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-2.5 py-1 font-medium capitalize text-primary transition-colors hover:bg-primary/25"
>
{stateFilter}
<X className="size-3" />
</button>
</div>
)}

{loading && !data &&
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-2xl border border-bdr-soft bg-surface-base-faint" />
))}

{data?.proposals.length === 0 && (
<div className="rounded-2xl border border-dashed border-bdr-soft px-4 py-8 text-center text-sm text-content-soft">
No proposals found on {chain} in the scanned window.
{stateFilter ? `No ${stateFilter} proposals on ${chain}.` : `No proposals found on ${chain} in the scanned window.`}
</div>
)}

Expand Down Expand Up @@ -345,10 +389,11 @@ export default function DaoPanel() {
</div>
)}
</ConsolePanel>
</div>

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

<section className="space-y-3">
Expand Down
35 changes: 28 additions & 7 deletions app/build/dao/proposal-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ function Tile({ label, value, sub }: { label: string; value: string; sub?: strin
);
}

export function ProposalAnalytics({ chain }: { chain: DaoChain }) {
export function ProposalAnalytics({
chain,
activeFilter,
onFilter,
}: {
chain: DaoChain;
activeFilter?: string | null;
onFilter?: (state: string) => void;
}) {
const [data, setData] = useState<AnalyticsResp | null>(null);
const [loading, setLoading] = useState(true);

Expand Down Expand Up @@ -97,12 +105,25 @@ export function ProposalAnalytics({ chain }: { chain: DaoChain }) {
<div className="flex flex-wrap gap-2">
{Object.entries(s.byState)
.sort((a, b) => b[1] - a[1])
.map(([state, count]) => (
<span key={state} className="inline-flex items-center gap-1.5 rounded-full border border-bdr-soft bg-card/60 px-2.5 py-1 text-[11px]">
<span className={cn("font-semibold tabular-nums", STATE_TONE[state] ?? "text-content-default")}>{count}</span>
<span className="capitalize text-content-soft">{state}</span>
</span>
))}
.map(([state, count]) => {
const active = activeFilter === state;
return (
<button
key={state}
type="button"
onClick={() => onFilter?.(state)}
aria-pressed={active}
title={`Show only ${state} proposals`}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] transition-colors",
active ? "border-primary/50 bg-primary/15" : "border-bdr-soft bg-card/60 hover:border-primary/40",
)}
>
<span className={cn("font-semibold tabular-nums", STATE_TONE[state] ?? "text-content-default")}>{count}</span>
<span className="capitalize text-content-soft">{state}</span>
</button>
);
})}
</div>

<ExecutedTimeline executed={data.executed} explorer={data.explorer} chain={chain} />
Expand Down
9 changes: 9 additions & 0 deletions lib/dao-governor-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export const PROPOSAL_CREATED = parseAbiItem(
const RPC_ATTEMPT_TIMEOUT_MS = 9000;
const CHUNK = 50_000n;

/** Run `fn` over `items` in small concurrent batches to keep free-RPC load sane. */
export async function mapBatched<T, R>(items: T[], size: number, fn: (item: T, index: number) => 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((item, j) => fn(item, i + j)))));
}
return out;
}

function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_, reject) => {
Expand Down
Loading