From 74c2821fb7d9e5947685b6af1b53f892a711ff01 Mon Sep 17 00:00:00 2001 From: Martin Marinov Date: Wed, 10 Jun 2026 15:35:14 +0300 Subject: [PATCH] refactor(web): position DAO panel as ecosystem intelligence, not a voting app LightChain has its own DAO - lightnode is not trying to replace it. The panel should surface what the official tools don't and send people to the official DAO to actually transact, rather than hosting votes itself. - Remove the in-app cast-vote and delegate-to-self write controls (and the wallet-signing plumbing they needed). Voting/delegation are not lightnode's job. - Active proposals now show "Vote on this proposal at the LightChain DAO" -> dao.lightchain.ai. The voting-power card stays as read-only "your standing" and links to ballots.lightchain.ai to manage delegation. - Add a prominent callout linking to the official DAO (dao.lightchain.ai) and reframe the panel as "Governance intelligence": decoded actions, quorum distance, treasury flow, lifecycle timing, and your on-chain standing. - Drop now-dead helpers (getSigner/useDaoWallet, pinnedFees, readHasVoted, GOVERNOR/DAO_CHAIN_ID maps). All the read/decoding intelligence stays. The SDK snippet still shows the full surface (incl. castVote/delegate) - those are primitives for builders making their own tooling, the ecosystem angle. --- app/build/dao/cast-vote.tsx | 180 ---------------------------- app/build/dao/dao-chain.ts | 60 +++------- app/build/dao/page.tsx | 32 ++++- app/build/dao/use-dao-wallet.ts | 46 ------- app/build/dao/voting-power-card.tsx | 119 ++++-------------- 5 files changed, 64 insertions(+), 373 deletions(-) delete mode 100644 app/build/dao/cast-vote.tsx delete mode 100644 app/build/dao/use-dao-wallet.ts diff --git a/app/build/dao/cast-vote.tsx b/app/build/dao/cast-vote.tsx deleted file mode 100644 index 7060b0e..0000000 --- a/app/build/dao/cast-vote.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { Loader2, ExternalLink, CheckCircle2, AlertTriangle, Wallet } from "lucide-react"; -import { GOVERNOR_ABI, VOTES_ABI } from "lightnode-sdk"; -import { humanizeError } from "@/lib/humanize-error"; -import { cn } from "@/lib/utils"; -import { - DAO_EXPLORER, - GOVERNOR, - VOTE_TOKEN, - daoPublicClient, - pinnedFees, - readHasVoted, - type DaoChain, -} from "./dao-chain"; -import { useDaoWallet } from "./use-dao-wallet"; - -type Support = 0 | 1 | 2; -type Vote = { phase: "idle" | "working" | "submitted" | "confirmed" | "error"; support?: Support; msg?: string; tx?: `0x${string}` }; - -const CHOICES: { label: string; support: Support; cls: string }[] = [ - { label: "For", support: 1, cls: "text-success border-success/40 hover:bg-success/10" }, - { label: "Against", support: 0, cls: "text-destructive border-destructive/40 hover:bg-destructive/10" }, - { label: "Abstain", support: 2, cls: "text-content-default border-bdr-soft hover:bg-surface-base-faint" }, -]; - -export function CastVote({ chain, proposalId, onVoted }: { chain: DaoChain; proposalId: string; onVoted?: () => void }) { - const { address, isConnected, open, getSigner } = useDaoWallet(chain); - const [voted, setVoted] = useState(null); - const [powerWei, setPowerWei] = useState(null); - const [vote, setVote] = useState({ phase: "idle" }); - - const check = useCallback(async () => { - if (!address) return; - const pub = daoPublicClient(chain); - try { - const [hasVoted, power] = await Promise.all([ - readHasVoted(chain, BigInt(proposalId), address), - pub.readContract({ address: VOTE_TOKEN[chain], abi: VOTES_ABI, functionName: "getVotes", args: [address] }) as Promise, - ]); - setVoted(hasVoted); - setPowerWei(power); - } catch { - setVoted(null); - setPowerWei(null); - } - }, [chain, proposalId, address]); - - useEffect(() => { - setVote({ phase: "idle" }); - setVoted(null); - setPowerWei(null); - if (isConnected && address) void check(); - }, [isConnected, address, chain, proposalId, check]); - - const castVote = async (support: Support) => { - if (!isConnected || !address) return open(); - setVote({ phase: "working", support, msg: "Confirm in your wallet (you may be asked to switch network first)..." }); - try { - const signer = await getSigner(); - const fees = chain === "lightchain" ? await pinnedFees(daoPublicClient(chain)) : undefined; - const hash = await signer.writeContract({ - address: GOVERNOR[chain], - abi: GOVERNOR_ABI, - functionName: "castVote", - args: [BigInt(proposalId), support], - ...(fees ?? {}), - }); - setVote({ phase: "submitted", support, msg: "Vote submitted - confirming...", tx: hash }); - await daoPublicClient(chain).waitForTransactionReceipt({ hash }); - setVote({ phase: "confirmed", support, msg: "Vote confirmed on-chain.", tx: hash }); - setVoted(true); - onVoted?.(); - } catch (e) { - setVote({ phase: "error", support, msg: humanizeError(e, { action: "casting your vote" }) }); - } - }; - - return ( -
-

Cast your vote

- open()} - onVote={castVote} - /> - {vote.phase !== "idle" && } -
- ); -} - -function CastBody({ - connected, - voted, - powerWei, - busy, - activeSupport, - onConnect, - onVote, -}: { - connected: boolean; - voted: boolean | null; - powerWei: bigint | null; - busy: boolean; - activeSupport?: Support; - onConnect: () => void; - onVote: (s: Support) => void; -}) { - if (!connected) { - return ( - - ); - } - if (voted) { - return ( -

- You already voted on this proposal. -

- ); - } - const noPower = powerWei === 0n; - return ( - <> -
- {CHOICES.map((c) => ( - - ))} -
- {noPower && ( -

- No voting power at this proposal's snapshot. Delegate before the next proposal opens to vote. -

- )} - - ); -} - -function VoteStatus({ chain, vote }: { chain: DaoChain; vote: Vote }) { - if (vote.phase === "working") return

{vote.msg}

; - if (vote.phase === "error") { - return ( -

- {vote.msg} -

- ); - } - return ( -
- - {vote.phase === "submitted" && } - {vote.phase === "confirmed" && } - {vote.msg} - - {vote.tx && ( - - tx - - )} -
- ); -} diff --git a/app/build/dao/dao-chain.ts b/app/build/dao/dao-chain.ts index f49ca80..3dc3d71 100644 --- a/app/build/dao/dao-chain.ts +++ b/app/build/dao/dao-chain.ts @@ -1,13 +1,15 @@ /** - * Client-side DAO chain config + on-chain reads shared by the voting-power and - * cast-vote controls. Both governance chains expose the same OZ Governor surface - * but different vote tokens: + * Client-side DAO chain config + on-chain reads for the governance-intelligence + * panel. lightnode reads + decodes governance state; the actual vote/delegate + * transactions happen on LightChain's official UIs (see DAO_VOTE_UI), so there + * are no writes here. The two chains share the OZ Governor surface but differ on + * the vote token: * - Ethereum -> LCAIB, an ERC20Votes token (balanceOf works). * - LightChain -> a native genesis predeploy (balanceOf reverts; voting weight * tracks the native LCAI balance, so we read getBalance there instead). */ import { createPublicClient, http } from "viem"; -import { DAO_ADDRESSES, GOVERNOR_ABI, VOTES_ABI } from "lightnode-sdk"; +import { DAO_ADDRESSES, VOTES_ABI } from "lightnode-sdk"; import { NETWORKS } from "@/lib/network"; export type DaoChain = "ethereum" | "lightchain"; @@ -17,11 +19,6 @@ export const DAO_RPC: Record = { lightchain: NETWORKS.mainnet.rpc, }; -export const DAO_CHAIN_ID: Record = { - ethereum: 1, - lightchain: NETWORKS.mainnet.chainId, -}; - export const DAO_EXPLORER: Record = { ethereum: "https://etherscan.io", lightchain: NETWORKS.mainnet.explorer, @@ -33,11 +30,6 @@ export const VOTE_TOKEN: Record = { lightchain: DAO_ADDRESSES.lightchain.ballots as `0x${string}`, }; -export const GOVERNOR: Record = { - ethereum: DAO_ADDRESSES.ethereum.governor, - lightchain: DAO_ADDRESSES.lightchain.governor, -}; - // Both governors count voting in BLOCKS (CLOCK_MODE=blocknumber), so block time // is needed to turn votingDelay/votingPeriod + deadline distance into real time. // Measured on mainnet: Ethereum ~12.04s (slot time), LightChain ~6.00s. @@ -46,6 +38,15 @@ export const SECONDS_PER_BLOCK: Record = { lightchain: 6, }; +// lightnode is an ecosystem intelligence layer, NOT a governance app: the actual +// vote / delegate / execute transactions happen on LightChain's own official UIs. +// We read + decode everything here and link out for the writes. +export const DAO_VOTE_UI = "https://dao.lightchain.ai"; +export const DELEGATION_UI: Record = { + ethereum: "https://ballots.lightchain.ai", // wrap LCAI -> LCAIB + delegate + lightchain: null, // native voting self-delegates; no wrapper UI +}; + export function daoPublicClient(chain: DaoChain) { return createPublicClient({ transport: http(DAO_RPC[chain]) }); } @@ -78,34 +79,3 @@ export async function loadVotingPower(chain: DaoChain, address: `0x${string}`): ]); return { votesWei, delegate, balanceWei }; } - -// LightChain's gas is tiny enough that MetaMask renders the fee red and blocks -// confirmation unless we pin chain-estimated values. Returns one fee arm or the -// other (never both) so viem's writeContract accepts it. Ethereum renders fine -// natively, so callers only pin for LightChain. -export type PinnedFees = { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | { gasPrice: bigint } | undefined; - -export async function pinnedFees(pub: ReturnType): Promise { - try { - const f = await pub.estimateFeesPerGas(); - return f?.maxFeePerGas - ? { maxFeePerGas: f.maxFeePerGas, maxPriorityFeePerGas: f.maxPriorityFeePerGas ?? f.maxFeePerGas } - : { gasPrice: await pub.getGasPrice() }; - } catch { - try { - return { gasPrice: await pub.getGasPrice() }; - } catch { - return undefined; - } - } -} - -export async function readHasVoted(chain: DaoChain, proposalId: bigint, address: `0x${string}`): Promise { - const pub = daoPublicClient(chain); - return pub.readContract({ - address: GOVERNOR[chain], - abi: GOVERNOR_ABI, - functionName: "hasVoted", - args: [proposalId, address], - }) as Promise; -} diff --git a/app/build/dao/page.tsx b/app/build/dao/page.tsx index 36eb835..e62b9ae 100644 --- a/app/build/dao/page.tsx +++ b/app/build/dao/page.tsx @@ -2,18 +2,17 @@ import { useCallback, useEffect, useState } from "react"; import Image from "next/image"; -import { Loader2, RefreshCw, ChevronDown, ExternalLink } from "lucide-react"; +import { Loader2, RefreshCw, ChevronDown, ExternalLink, Landmark, Vote } 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"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { DAO_EXPLORER, SECONDS_PER_BLOCK, type DaoChain } from "./dao-chain"; +import { DAO_EXPLORER, DAO_VOTE_UI, SECONDS_PER_BLOCK, type DaoChain } from "./dao-chain"; import { humanizeDuration } from "./dao-math"; import { TreasuryBar } from "./treasury-bar"; import { VotingPowerCard } from "./voting-power-card"; import { QuorumLine } from "./quorum-line"; -import { CastVote } from "./cast-vote"; const CHAIN_META: Record = { ethereum: { label: "Ethereum", icon: "/logos/eth.svg" }, @@ -167,8 +166,8 @@ export default function DaoPanel() {
@@ -206,6 +205,20 @@ export default function DaoPanel() { } > @@ -282,7 +295,14 @@ export default function DaoPanel() { {p.stateLabel.toLowerCase() === "active" && ( - void load(chain, limit)} /> + + Vote on this proposal at the LightChain DAO + )} {(() => { diff --git a/app/build/dao/use-dao-wallet.ts b/app/build/dao/use-dao-wallet.ts deleted file mode 100644 index 11da9b9..0000000 --- a/app/build/dao/use-dao-wallet.ts +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { useAccount, useSwitchChain, useChainId } from "wagmi"; -import { getWalletClient } from "@wagmi/core"; -import { useAppKit } from "@reown/appkit/react"; -import { wagmiConfig } from "@/lib/wagmi"; -import { DAO_CHAIN_ID, type DaoChain } from "./dao-chain"; - -/** - * Shared wallet wiring for the DAO write controls (delegate, cast vote). Uses - * `open()` from AppKit for connect rather than the global ConnectButton, which - * treats Ethereum as "unsupported" and would nag the user back to LightChain. - * - * `getSigner` is the load-bearing piece: the app's network toggle drives wagmi's - * tracked chain (e.g. LightChain 9200) independently of the wallet's real chain, - * so the reactive `useWalletClient()` can stay bound to the wrong chain and viem - * then rejects the write ("wallet chain ... does not match target chain ..."). We - * instead switch to the target chain and fetch a FRESH client bound to it from - * the connector's actual state. - */ -export function useDaoWallet(chain: DaoChain) { - const { address, isConnected } = useAccount(); - const { switchChainAsync } = useSwitchChain(); - const { open } = useAppKit(); - const chainId = useChainId(); - const targetChainId = DAO_CHAIN_ID[chain]; - - const getSigner = async () => { - // Switching to the chain you're already on is a no-op (no wallet prompt), - // and it realigns wagmi's tracked chain with the connector before we read it. - await switchChainAsync({ chainId: targetChainId }); - const signer = await getWalletClient(wagmiConfig, { chainId: targetChainId }); - if (!signer) throw new Error("Could not get a wallet client for this network. Reconnect and try again."); - return signer; - }; - - return { - address, - isConnected, - open, - chainId, - targetChainId, - onTargetChain: chainId === targetChainId, - getSigner, - }; -} diff --git a/app/build/dao/voting-power-card.tsx b/app/build/dao/voting-power-card.tsx index a7ce3b7..7064e11 100644 --- a/app/build/dao/voting-power-card.tsx +++ b/app/build/dao/voting-power-card.tsx @@ -1,32 +1,25 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { Loader2, ShieldCheck, AlertTriangle, ExternalLink, Wallet } from "lucide-react"; -import { VOTES_ABI } from "lightnode-sdk"; -import { humanizeError } from "@/lib/humanize-error"; +import { ShieldCheck, AlertTriangle, ExternalLink, Wallet } from "lucide-react"; +import { useAccount } from "wagmi"; +import { useAppKit } from "@reown/appkit/react"; import { short } from "@/components/build/console/panel-kit"; -import { cn } from "@/lib/utils"; -import { - DAO_EXPLORER, - VOTE_TOKEN, - daoPublicClient, - loadVotingPower, - pinnedFees, - type DaoChain, - type VotingPowerReads, -} from "./dao-chain"; +import { DAO_VOTE_UI, DELEGATION_UI, loadVotingPower, type DaoChain, type VotingPowerReads } from "./dao-chain"; import { delegationStatus, formatLcaiWei } from "./dao-math"; -import { useDaoWallet } from "./use-dao-wallet"; - -type Tx = { phase: "idle" | "working" | "submitted" | "confirmed" | "error"; msg?: string; tx?: `0x${string}` }; const SYMBOL: Record = { ethereum: "LCAIB", lightchain: "LCAI" }; +/** + * Read-only "your standing" card. lightnode surfaces voting power + delegation + * state from the on-chain registries; the actual delegate transaction happens on + * LightChain's official UI, so we link out rather than sign here. + */ export function VotingPowerCard({ chain }: { chain: DaoChain }) { - const { address, isConnected, open, getSigner } = useDaoWallet(chain); + const { address, isConnected } = useAccount(); + const { open } = useAppKit(); const [reads, setReads] = useState(null); const [loading, setLoading] = useState(false); - const [tx, setTx] = useState({ phase: "idle" }); const refresh = useCallback(async () => { if (!address) return; @@ -42,32 +35,9 @@ export function VotingPowerCard({ chain }: { chain: DaoChain }) { useEffect(() => { setReads(null); - setTx({ phase: "idle" }); if (isConnected && address) void refresh(); }, [isConnected, address, chain, refresh]); - const delegateToSelf = async () => { - if (!isConnected || !address) return open(); - setTx({ phase: "working", msg: "Confirm in your wallet (you may be asked to switch network first)..." }); - try { - const signer = await getSigner(); - const fees = chain === "lightchain" ? await pinnedFees(daoPublicClient(chain)) : undefined; - const hash = await signer.writeContract({ - address: VOTE_TOKEN[chain], - abi: VOTES_ABI, - functionName: "delegate", - args: [address], - ...(fees ?? {}), - }); - setTx({ phase: "submitted", msg: "Delegation submitted - confirming...", tx: hash }); - await daoPublicClient(chain).waitForTransactionReceipt({ hash }); - setTx({ phase: "confirmed", msg: "Voting power activated.", tx: hash }); - await refresh(); - } catch (e) { - setTx({ phase: "error", msg: humanizeError(e, { action: "delegating your votes" }) }); - } - }; - if (!isConnected || !address) { return (
); } @@ -113,19 +82,7 @@ function PowerHeader({ chain, reads, loading }: { chain: DaoChain; reads: Voting ); } -function DelegationRow({ - chain, - reads, - address, - onDelegate, - working, -}: { - chain: DaoChain; - reads: VotingPowerReads; - address: `0x${string}`; - onDelegate: () => void; - working: boolean; -}) { +function DelegationRow({ chain, reads, address }: { chain: DaoChain; reads: VotingPowerReads; address: `0x${string}` }) { const status = delegationStatus(reads.votesWei, reads.balanceWei, reads.delegate, address); if (status.kind === "self") { const gap = formatLcaiWei(status.gapWei, 2); @@ -137,8 +94,6 @@ function DelegationRow({

); } - // Nothing to delegate: holding zero balance on this chain. Don't push a - // pointless "delegate to activate" prompt. if (reads.balanceWei === 0n) { return (

@@ -148,50 +103,22 @@ function DelegationRow({ } const msg = status.kind === "undelegated" - ? `You hold ${formatLcaiWei(reads.balanceWei, 2)} ${SYMBOL[chain]} but 0 voting power. Delegate to yourself to activate it.` - : `Delegated to ${short(reads.delegate)} - they hold your voting power. Reclaim it below.`; + ? `You hold ${formatLcaiWei(reads.balanceWei, 2)} ${SYMBOL[chain]} but 0 voting power. Activate it by delegating.` + : `Delegated to ${short(reads.delegate)} - they hold your voting power.`; + const href = DELEGATION_UI[chain] ?? DAO_VOTE_UI; return (

{msg}

- -
- ); -} - -function TxLine({ chain, tx }: { chain: DaoChain; tx: Tx }) { - if (tx.phase === "error") { - return ( -

- {tx.msg} -

- ); - } - return ( -
- - {tx.phase === "submitted" && } - {tx.phase === "confirmed" && } - {tx.msg} - - {tx.tx && ( - - tx - - )} + Manage delegation +
); }