diff --git a/app/api/dao-analytics/route.ts b/app/api/dao-analytics/route.ts index 2e2eb36..206c4ab 100644 --- a/app/api/dao-analytics/route.ts +++ b/app/api/dao-analytics/route.ts @@ -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"; @@ -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(items: T[], size: number, fn: (item: T) => Promise): Promise { - 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; diff --git a/app/api/dao-proposals/route.ts b/app/api/dao-proposals/route.ts index 2f16aa6..8c883ad 100644 --- a/app/api/dao-proposals/route.ts +++ b/app/api/dao-proposals/route.ts @@ -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"; @@ -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, + governor: `0x${string}`, + id: bigint, +): Promise { + 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. @@ -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 { diff --git a/app/build/dao/page.tsx b/app/build/dao/page.tsx index 0f7ecfb..ada73f6 100644 --- a/app/build/dao/page.tsx +++ b/app/build/dao/page.tsx @@ -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"; @@ -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" { @@ -137,16 +155,18 @@ function VoteBar({ p }: { p: Proposal }) { export default function DaoPanel() { const [chain, setChain] = useState("ethereum"); const [limit, setLimit] = useState(5); + const [stateFilter, setStateFilter] = useState(null); const [expandedId, setExpandedId] = useState(null); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const panelRef = useRef(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."); @@ -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 (
+
{ setChain(c); setLimit(5); + setStateFilter(null); }} aria-pressed={chain === c} className={cn( @@ -196,7 +226,7 @@ export default function DaoPanel() {
+
+ )} + {loading && !data && Array.from({ length: 3 }).map((_, i) => (
@@ -236,7 +280,7 @@ export default function DaoPanel() { {data?.proposals.length === 0 && (
- No proposals found on {chain} in the scanned window. + {stateFilter ? `No ${stateFilter} proposals on ${chain}.` : `No proposals found on ${chain} in the scanned window.`}
)} @@ -345,10 +389,11 @@ export default function DaoPanel() {
)} +

Outcome analytics · {chain}

- +
diff --git a/app/build/dao/proposal-analytics.tsx b/app/build/dao/proposal-analytics.tsx index 0c1c300..842a2e7 100644 --- a/app/build/dao/proposal-analytics.tsx +++ b/app/build/dao/proposal-analytics.tsx @@ -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(null); const [loading, setLoading] = useState(true); @@ -97,12 +105,25 @@ export function ProposalAnalytics({ chain }: { chain: DaoChain }) {
{Object.entries(s.byState) .sort((a, b) => b[1] - a[1]) - .map(([state, count]) => ( - - {count} - {state} - - ))} + .map(([state, count]) => { + const active = activeFilter === state; + return ( + + ); + })}
diff --git a/lib/dao-governor-scan.ts b/lib/dao-governor-scan.ts index c232029..fbe7ec3 100644 --- a/lib/dao-governor-scan.ts +++ b/lib/dao-governor-scan.ts @@ -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(items: T[], size: number, fn: (item: T, index: number) => Promise): Promise { + 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(p: Promise, ms: number, label: string): Promise { let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => {