From a0c4e0bcab9c6387a7bac5db2faeee3f82b06e73 Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:16:51 -0400 Subject: [PATCH 1/3] feat: add governance proposal read paths (#16320) * feat: add governance proposal read paths * chore: update yarn.lock for ethers dependency in governance-app Co-Authored-By: Claude Sonnet 4.6 * ci: remove deploy-vercel script and fix Vercel ignore command - Remove governance-app/scripts/deploy-vercel.sh (replaced by native Vercel integration) - Add ignoreCommand to watch governance-app/ and yarn.lock changes Co-Authored-By: Claude Sonnet 4.6 * ci: fix Vercel ignoreCommand to use paths relative to project root Co-Authored-By: Claude Sonnet 4.6 * fix: fetch governance proposals from subgraph instead of RPC queryFilter The eth_getLogs range from governorStartBlock (~1.75M) to latest (~29M) is too large for the default RPC provider, causing the governance app homepage to fail with an RPC error. Migrates proposal fetching to the subgraph (already fully synced), keeping only simple contract calls (proposalThreshold, votingDelay, votingPeriod) and getLatestTimestamp() on RPC. Co-Authored-By: Claude Sonnet 4.6 * fix: use BigInt arithmetic in percentage() to avoid precision loss Number(bigint) loses precision for token amounts > Number.MAX_SAFE_INTEGER. Since UP token has 18 decimals, raw wei values exceed this threshold for any real vote. Keep the division in BigInt space, only converting the small result. Co-Authored-By: Claude Sonnet 4.6 * fix: hardcode governance subgraph URL and improve error visibility - Hardcode subgraph.unlock-protocol.com/8453 as the default governance subgraph URL instead of relying on base.subgraph.endpoint (same URL but now explicit and decoupled from the networks package) - Add console.error to all error boundaries so failures are visible in Vercel logs - Update error messages to reference the subgraph instead of RPC - Remove "indexed from RPC" label from proposals count Co-Authored-By: Claude Sonnet 4.6 * fix: remove hardcoded tokenSymbol from config, already fetched via symbol() on-chain Co-Authored-By: Claude Sonnet 4.6 * fix: fetch tokenSymbol from contract instead of hardcoding it in config Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- governance-app/app/page.tsx | 105 ++++++- governance-app/app/proposals/[id]/page.tsx | 296 +++++++++++++++++- governance-app/app/proposals/page.tsx | 81 ++++- governance-app/package.json | 2 +- .../src/components/proposals/ProposalCard.tsx | 103 ++++++ .../proposals/ProposalErrorState.tsx | 21 ++ .../components/proposals/ProposalFilters.tsx | 42 +++ .../proposals/ProposalStateBadge.tsx | 25 ++ governance-app/src/config/env.ts | 2 + governance-app/src/config/governance.ts | 7 + governance-app/src/lib/governance/format.ts | 75 +++++ .../src/lib/governance/proposals.ts | 131 ++++++++ governance-app/src/lib/governance/rpc.ts | 58 ++++ governance-app/src/lib/governance/state.ts | 51 +++ governance-app/src/lib/governance/subgraph.ts | 110 +++++++ governance-app/src/lib/governance/types.ts | 56 ++++ governance-app/tsconfig.json | 4 +- governance-app/vercel.json | 3 +- yarn.lock | 1 + 19 files changed, 1148 insertions(+), 25 deletions(-) create mode 100644 governance-app/src/components/proposals/ProposalCard.tsx create mode 100644 governance-app/src/components/proposals/ProposalErrorState.tsx create mode 100644 governance-app/src/components/proposals/ProposalFilters.tsx create mode 100644 governance-app/src/components/proposals/ProposalStateBadge.tsx create mode 100644 governance-app/src/lib/governance/format.ts create mode 100644 governance-app/src/lib/governance/proposals.ts create mode 100644 governance-app/src/lib/governance/rpc.ts create mode 100644 governance-app/src/lib/governance/state.ts create mode 100644 governance-app/src/lib/governance/subgraph.ts create mode 100644 governance-app/src/lib/governance/types.ts diff --git a/governance-app/app/page.tsx b/governance-app/app/page.tsx index 33ca098b747..3a6245fec57 100644 --- a/governance-app/app/page.tsx +++ b/governance-app/app/page.tsx @@ -1,10 +1,103 @@ -import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder' +import Link from 'next/link' +import { ProposalCard } from '~/components/proposals/ProposalCard' +import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' +import { formatTokenAmount } from '~/lib/governance/format' +import { getGovernanceOverview } from '~/lib/governance/proposals' -export default function HomePage() { +export const dynamic = 'force-dynamic' + +export default async function HomePage() { + try { + const overview = await getGovernanceOverview() + const recentProposals = overview.proposals.slice(0, 3) + + return ( +
+
+
+

+ Unlock DAO +

+

+ Governance overview +

+

+ This read-only slice already loads recent governance activity from + the Base governor contract, including live quorum-aware states and + proposal vote totals. +

+
+ + Browse proposals + + + New proposal + +
+
+
+ + + +
+
+
+
+

+ Recent proposals +

+ + View all + +
+
+ {recentProposals.map((proposal) => ( + + ))} +
+
+
+ ) + } catch (error) { + console.error('[home/page] governance data load failed:', error) + return ( + + ) + } +} + +function StatCard({ label, value }: { label: string; value: string }) { return ( - +
+
+ {label} +
+
+ {value} +
+
) } diff --git a/governance-app/app/proposals/[id]/page.tsx b/governance-app/app/proposals/[id]/page.tsx index 63eb982d646..665ae583d39 100644 --- a/governance-app/app/proposals/[id]/page.tsx +++ b/governance-app/app/proposals/[id]/page.tsx @@ -1,4 +1,21 @@ -import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder' +import { notFound } from 'next/navigation' +import { ProposalStateBadge } from '~/components/proposals/ProposalStateBadge' +import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' +import { + formatDateTime, + formatRelativeTime, + formatTokenAmount, + percentage, + truncateAddress, +} from '~/lib/governance/format' +import { + decodeProposalCalldatas, + getGovernanceOverview, + getProposalById, +} from '~/lib/governance/proposals' +import { isExecutable } from '~/lib/governance/state' + +export const dynamic = 'force-dynamic' type ProposalPageProps = { params: { @@ -6,11 +23,278 @@ type ProposalPageProps = { } } -export default function ProposalDetailPage({ params }: ProposalPageProps) { +export default async function ProposalDetailPage({ + params, +}: ProposalPageProps) { + try { + const [overview, proposal] = await Promise.all([ + getGovernanceOverview(), + getProposalById(params.id), + ]) + + if (!proposal) { + notFound() + } + + const decodedCalls = decodeProposalCalldatas(proposal) + const totalVotes = + proposal.forVotes + proposal.againstVotes + proposal.abstainVotes + const quorumVotes = proposal.forVotes + proposal.abstainVotes + const executeLabel = proposal.etaSeconds + ? isExecutable(proposal, overview.latestTimestamp) + ? 'Executable now' + : `Executable ${formatRelativeTime( + proposal.etaSeconds, + overview.latestTimestamp + )}` + : 'Not queued' + + return ( +
+
+
+
+
+ + + Proposal {proposal.id} + +
+

+ {proposal.title} +

+

+ {proposal.description} +

+
+
+ Proposed by {truncateAddress(proposal.proposer)} +
+
+
+ +
+
+
+
+

+ Vote breakdown +

+
+ Quorum counts {overview.tokenSymbol} votes for + abstain +
+
+
+ + + +
+
+
+ Quorum progress +
+
+ {formatTokenAmount(quorumVotes)} /{' '} + {formatTokenAmount(proposal.quorum)} {overview.tokenSymbol} +
+
+
+ +
+

+ Lifecycle +

+
+ + + + + + +
+
+ +
+

+ Proposed calls +

+
+ {decodedCalls.map((call, index) => ( +
+
+
+ Call {index + 1} +
+
+ Value: {formatTokenAmount(call.value)} ETH +
+
+ {call.kind === 'decoded' ? ( +
+
+ {call.contractLabel}.{call.functionName}() +
+
+ {call.args.length + ? call.args.join(', ') + : 'No arguments'} +
+
+ ) : ( +
+
Target: {call.target}
+
+ {call.calldata} +
+
+ )} +
+ ))} +
+
+
+ + +
+
+ ) + } catch (error) { + console.error('[proposals/[id]/page] governance data load failed:', error) + return ( + + ) + } +} + +function VotePanel({ + label, + percentageLabel, + value, +}: { + label: string + percentageLabel: string + value: bigint +}) { + return ( +
+
+ {label} +
+
+ {formatTokenAmount(value)} +
+
{percentageLabel}
+
+ ) +} + +function TimelineRow({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} + +function DetailRow({ label, value }: { label: string; value: string }) { return ( - +
+
+ {label} +
+
{value}
+
) } diff --git a/governance-app/app/proposals/page.tsx b/governance-app/app/proposals/page.tsx index afdb756eeef..8a749d92ef9 100644 --- a/governance-app/app/proposals/page.tsx +++ b/governance-app/app/proposals/page.tsx @@ -1,10 +1,73 @@ -import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder' - -export default function ProposalsPage() { - return ( - - ) +import { ProposalCard } from '~/components/proposals/ProposalCard' +import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' +import { ProposalFilters } from '~/components/proposals/ProposalFilters' +import { + filterProposals, + getGovernanceOverview, +} from '~/lib/governance/proposals' + +export const dynamic = 'force-dynamic' + +type ProposalsPageProps = { + searchParams?: { + state?: string + } +} + +export default async function ProposalsPage({ + searchParams, +}: ProposalsPageProps) { + const activeFilter = searchParams?.state || 'All' + + try { + const overview = await getGovernanceOverview() + const proposals = filterProposals(overview.proposals, searchParams?.state) + + return ( +
+
+

+ Proposal Read Paths +

+
+
+

+ Proposals +

+

+ Live proposal history sourced from Base governor events with + quorum-aware state derivation on the server. +

+
+
+ {overview.proposals.length} proposals +
+
+
+ +
+ {proposals.length ? ( + proposals.map((proposal) => ( + + )) + ) : ( + + )} +
+
+ ) + } catch (error) { + console.error('[proposals/page] getGovernanceOverview failed:', error) + return ( + + ) + } } diff --git a/governance-app/package.json b/governance-app/package.json index 8101960f03e..12d85d2c07f 100644 --- a/governance-app/package.json +++ b/governance-app/package.json @@ -13,7 +13,7 @@ "@unlock-protocol/eslint-config": "workspace:./packages/eslint-config", "@unlock-protocol/networks": "workspace:./packages/networks", "@unlock-protocol/ui": "workspace:./packages/ui", - + "ethers": "6.15.0", "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" diff --git a/governance-app/src/components/proposals/ProposalCard.tsx b/governance-app/src/components/proposals/ProposalCard.tsx new file mode 100644 index 00000000000..e1bd5a550c1 --- /dev/null +++ b/governance-app/src/components/proposals/ProposalCard.tsx @@ -0,0 +1,103 @@ +import Link from 'next/link' +import { + formatDateTime, + formatRelativeTime, + formatTokenAmount, + truncateAddress, +} from '~/lib/governance/format' +import type { ProposalRecord } from '~/lib/governance/types' +import { ProposalStateBadge } from './ProposalStateBadge' + +type ProposalCardProps = { + now: bigint + proposal: ProposalRecord + tokenSymbol: string +} + +export function ProposalCard({ + now, + proposal, + tokenSymbol, +}: ProposalCardProps) { + const quorumProgress = `${formatTokenAmount( + proposal.forVotes + proposal.abstainVotes + )} / ${formatTokenAmount(proposal.quorum)} ${tokenSymbol}` + const deadlineLabel = + proposal.state === 'Active' + ? `Ends ${formatRelativeTime(proposal.voteEndTimestamp, now)}` + : `Ended ${formatDateTime(proposal.voteEndTimestamp)}` + + return ( + +
+
+
+ + + Proposal {proposal.id} + +
+

+ {proposal.title} +

+

+ Proposed by {truncateAddress(proposal.proposer)}. {deadlineLabel} +

+
+
+ Created {formatDateTime(proposal.createdAtTimestamp)} +
+
+
+ + + +
+
+ Quorum +
+
+ {quorumProgress} +
+
+
+ + ) +} + +function VoteMetric({ + label, + tokenSymbol, + value, +}: { + label: string + tokenSymbol: string + value: bigint +}) { + return ( +
+
+ {label} +
+
+ {formatTokenAmount(value)} +
+
{tokenSymbol}
+
+ ) +} diff --git a/governance-app/src/components/proposals/ProposalErrorState.tsx b/governance-app/src/components/proposals/ProposalErrorState.tsx new file mode 100644 index 00000000000..3b3628ce173 --- /dev/null +++ b/governance-app/src/components/proposals/ProposalErrorState.tsx @@ -0,0 +1,21 @@ +type ProposalErrorStateProps = { + description: string + title?: string +} + +export function ProposalErrorState({ + description, + title = 'Unable to load governance data', +}: ProposalErrorStateProps) { + return ( +
+

+ RPC Error +

+

{title}

+

+ {description} +

+
+ ) +} diff --git a/governance-app/src/components/proposals/ProposalFilters.tsx b/governance-app/src/components/proposals/ProposalFilters.tsx new file mode 100644 index 00000000000..3075a44780f --- /dev/null +++ b/governance-app/src/components/proposals/ProposalFilters.tsx @@ -0,0 +1,42 @@ +import Link from 'next/link' + +const proposalFilters = [ + 'All', + 'Active', + 'Pending', + 'Succeeded', + 'Queued', + 'Defeated', + 'Executed', + 'Canceled', +] as const + +type ProposalFiltersProps = { + activeFilter: string +} + +export function ProposalFilters({ activeFilter }: ProposalFiltersProps) { + return ( + + ) +} diff --git a/governance-app/src/components/proposals/ProposalStateBadge.tsx b/governance-app/src/components/proposals/ProposalStateBadge.tsx new file mode 100644 index 00000000000..9c501f2eba0 --- /dev/null +++ b/governance-app/src/components/proposals/ProposalStateBadge.tsx @@ -0,0 +1,25 @@ +import type { ProposalState } from '~/lib/governance/types' + +const stateClassNames: Record = { + Active: 'bg-emerald-100 text-emerald-700', + Canceled: 'bg-slate-200 text-slate-700', + Defeated: 'bg-rose-100 text-rose-700', + Executed: 'bg-sky-100 text-sky-700', + Pending: 'bg-amber-100 text-amber-700', + Queued: 'bg-violet-100 text-violet-700', + Succeeded: 'bg-teal-100 text-teal-700', +} + +type ProposalStateBadgeProps = { + state: ProposalState +} + +export function ProposalStateBadge({ state }: ProposalStateBadgeProps) { + return ( + + {state} + + ) +} diff --git a/governance-app/src/config/env.ts b/governance-app/src/config/env.ts index dc0ad523fa0..44c9f050d60 100644 --- a/governance-app/src/config/env.ts +++ b/governance-app/src/config/env.ts @@ -1,3 +1,5 @@ export const governanceEnv = { + baseRpcUrl: process.env.BASE_RPC_URL || '', + baseSubgraphUrl: process.env.BASE_SUBGRAPH_URL || '', privyAppId: process.env.NEXT_PUBLIC_PRIVY_APP_ID || '', } diff --git a/governance-app/src/config/governance.ts b/governance-app/src/config/governance.ts index 306b528c133..22d0af3fca6 100644 --- a/governance-app/src/config/governance.ts +++ b/governance-app/src/config/governance.ts @@ -6,12 +6,19 @@ import { Unlock, } from '@unlock-protocol/contracts' import { base } from '@unlock-protocol/networks' +import { governanceEnv } from './env' export const governanceConfig = { chainId: 8453, chainName: 'Base', governorAddress: base.dao?.governor || '0x65bA0624403Fc5Ca2b20479e9F626eD4D78E0aD9', + governorStartBlock: base.startBlock || 1750000, + proposalQuorumMode: 'for,abstain', + rpcUrl: governanceEnv.baseRpcUrl || base.provider, + subgraphUrl: + governanceEnv.baseSubgraphUrl || + 'https://subgraph.unlock-protocol.com/8453', timelockAddress: '0xB34567C4cA697b39F72e1a8478f285329A98ed1b', tokenAddress: '0xaC27fa800955849d6D17cC8952Ba9dD6EAA66187', knownContracts: [ diff --git a/governance-app/src/lib/governance/format.ts b/governance-app/src/lib/governance/format.ts new file mode 100644 index 00000000000..06b12956125 --- /dev/null +++ b/governance-app/src/lib/governance/format.ts @@ -0,0 +1,75 @@ +const integerFormatter = new Intl.NumberFormat('en-US') + +export function formatTokenAmount(value: bigint, decimals = 18) { + const negative = value < 0n + const absoluteValue = negative ? value * -1n : value + const divisor = 10n ** BigInt(decimals) + const whole = absoluteValue / divisor + const fraction = absoluteValue % divisor + const fractionText = fraction + .toString() + .padStart(decimals, '0') + .replace(/0+$/, '') + .slice(0, 2) + + const formattedWhole = integerFormatter.format(Number(whole)) + const formattedValue = fractionText + ? `${formattedWhole}.${fractionText}` + : formattedWhole + + return negative ? `-${formattedValue}` : formattedValue +} + +export function formatDateTime(timestamp: bigint | null) { + if (!timestamp) { + return 'Not available' + } + + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(Number(timestamp) * 1000) +} + +export function formatRelativeTime(timestamp: bigint, now: bigint) { + const deltaSeconds = Number(timestamp - now) + const absoluteSeconds = Math.abs(deltaSeconds) + + if (absoluteSeconds < 60) { + return deltaSeconds >= 0 ? 'in under a minute' : 'under a minute ago' + } + + const units = [ + { seconds: 86400, label: 'day' }, + { seconds: 3600, label: 'hour' }, + { seconds: 60, label: 'minute' }, + ] + + for (const unit of units) { + if (absoluteSeconds >= unit.seconds) { + const value = Math.floor(absoluteSeconds / unit.seconds) + const suffix = value === 1 ? unit.label : `${unit.label}s` + return deltaSeconds >= 0 + ? `in ${value} ${suffix}` + : `${value} ${suffix} ago` + } + } + + return 'just now' +} + +export function truncateAddress(address: string, size = 4) { + if (address.length <= size * 2 + 2) { + return address + } + + return `${address.slice(0, size + 2)}...${address.slice(-size)}` +} + +export function percentage(value: bigint, total: bigint) { + if (total === 0n) { + return '0%' + } + + return `${(Number((value * 10000n) / total) / 100).toFixed(1)}%` +} diff --git a/governance-app/src/lib/governance/proposals.ts b/governance-app/src/lib/governance/proposals.ts new file mode 100644 index 00000000000..66e01cc3ca9 --- /dev/null +++ b/governance-app/src/lib/governance/proposals.ts @@ -0,0 +1,131 @@ +// ABOUTME: Governance proposal data access layer. Fetches proposals via subgraph, +// ABOUTME: derives on-chain state using current timestamp from RPC. +import { cache } from 'react' +import { Interface, type InterfaceAbi } from 'ethers' +import { governanceConfig } from '~/config/governance' +import { deriveProposalState } from './state' +import { getGovernorContract, getLatestTimestamp, getTokenSymbol } from './rpc' +import { getProposalsFromSubgraph } from './subgraph' +import type { + DecodedCalldata, + GovernanceOverview, + ProposalRecord, +} from './types' + +export const getGovernanceOverview = cache( + async (): Promise => { + const governor = getGovernorContract() + const [ + latestTimestamp, + proposalThreshold, + votingDelay, + votingPeriod, + tokenSymbol, + ] = await Promise.all([ + getLatestTimestamp(), + governor.proposalThreshold() as Promise, + governor.votingDelay() as Promise, + governor.votingPeriod() as Promise, + getTokenSymbol(), + ]) + + const rawProposals = await getProposalsFromSubgraph(proposalThreshold) + + const proposals: ProposalRecord[] = rawProposals.map((proposal) => ({ + ...proposal, + state: deriveProposalState(proposal, latestTimestamp), + })) + + return { + latestTimestamp, + proposalThreshold, + proposals, + tokenSymbol, + votingDelay, + votingPeriod, + } + } +) + +export async function getProposalById(proposalId: string) { + const overview = await getGovernanceOverview() + + return ( + overview.proposals.find((proposal) => proposal.id === proposalId) || null + ) +} + +export function filterProposals( + proposals: ProposalRecord[], + state: string | undefined +) { + if (!state || state === 'All') { + return proposals + } + + return proposals.filter((proposal) => proposal.state === state) +} + +export function decodeProposalCalldatas( + proposal: ProposalRecord +): DecodedCalldata[] { + const candidates = governanceConfig.knownContracts.map((contract) => ({ + interface: new Interface(getContractAbi(contract.abi)), + label: contract.label, + })) + + return proposal.calldatas.map((calldata, index) => { + const target = proposal.targets[index] + const value = proposal.values[index] || 0n + + for (const candidate of candidates) { + try { + const parsed = candidate.interface.parseTransaction({ + data: calldata, + value, + }) + + if (!parsed) { + continue + } + + return { + args: parsed.args.map((arg) => stringifyArgument(arg)), + contractLabel: candidate.label, + functionName: parsed.name, + kind: 'decoded' as const, + value, + } + } catch { + continue + } + } + + return { + calldata, + kind: 'raw' as const, + target, + value, + } + }) +} + +function stringifyArgument(value: unknown): string { + if (typeof value === 'bigint') { + return value.toString() + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stringifyArgument(item)).join(', ')}]` + } + + return String(value) +} + +function getContractAbi(abi: unknown): InterfaceAbi { + if (abi && typeof abi === 'object' && 'abi' in abi) { + return (abi as { abi: InterfaceAbi }).abi + } + + return abi as InterfaceAbi +} diff --git a/governance-app/src/lib/governance/rpc.ts b/governance-app/src/lib/governance/rpc.ts new file mode 100644 index 00000000000..3ec4e209568 --- /dev/null +++ b/governance-app/src/lib/governance/rpc.ts @@ -0,0 +1,58 @@ +import { Contract, Interface, type InterfaceAbi, JsonRpcProvider } from 'ethers' +import { cache } from 'react' +import { UPGovernor } from '@unlock-protocol/contracts' +import { governanceConfig } from '~/config/governance' + +const governorAbi = getContractAbi(UPGovernor) +const erc20Abi = ['function symbol() view returns (string)'] + +export const getRpcProvider = cache( + () => new JsonRpcProvider(governanceConfig.rpcUrl, governanceConfig.chainId) +) + +export const getGovernorContract = cache( + () => + new Contract( + governanceConfig.governorAddress, + governorAbi, + getRpcProvider() + ) +) + +export const getGovernorInterface = cache(() => new Interface(governorAbi)) + +export const getTokenContract = cache( + () => new Contract(governanceConfig.tokenAddress, erc20Abi, getRpcProvider()) +) + +export const getTokenSymbol = cache(async () => { + return (await getTokenContract().symbol()) as string +}) + +export const getBlockTimestamp = cache(async (blockNumber: number) => { + const block = await getRpcProvider().getBlock(blockNumber) + + if (!block) { + throw new Error(`Missing block ${blockNumber}`) + } + + return BigInt(block.timestamp) +}) + +export const getLatestTimestamp = cache(async () => { + const block = await getRpcProvider().getBlock('latest') + + if (!block) { + throw new Error('Unable to load latest block') + } + + return BigInt(block.timestamp) +}) + +function getContractAbi(abi: unknown): InterfaceAbi { + if (abi && typeof abi === 'object' && 'abi' in abi) { + return (abi as { abi: InterfaceAbi }).abi + } + + return abi as InterfaceAbi +} diff --git a/governance-app/src/lib/governance/state.ts b/governance-app/src/lib/governance/state.ts new file mode 100644 index 00000000000..32d18df7e41 --- /dev/null +++ b/governance-app/src/lib/governance/state.ts @@ -0,0 +1,51 @@ +import type { ProposalRecord, ProposalState } from './types' + +export function deriveProposalState( + proposal: Pick< + ProposalRecord, + | 'abstainVotes' + | 'againstVotes' + | 'canceledAt' + | 'etaSeconds' + | 'executedAt' + | 'forVotes' + | 'quorum' + | 'voteEndTimestamp' + | 'voteStartTimestamp' + >, + now: bigint +): ProposalState { + if (proposal.canceledAt) { + return 'Canceled' + } + + if (proposal.executedAt) { + return 'Executed' + } + + if (proposal.etaSeconds) { + return 'Queued' + } + + if (now < proposal.voteStartTimestamp) { + return 'Pending' + } + + if (now <= proposal.voteEndTimestamp) { + return 'Active' + } + + const quorumReached = + proposal.forVotes + proposal.abstainVotes >= proposal.quorum + const voteSucceeded = proposal.forVotes > proposal.againstVotes + + return quorumReached && voteSucceeded ? 'Succeeded' : 'Defeated' +} + +export function isExecutable(proposal: ProposalRecord, now: bigint) { + return ( + proposal.state === 'Queued' && + !!proposal.etaSeconds && + now >= proposal.etaSeconds + ) +} diff --git a/governance-app/src/lib/governance/subgraph.ts b/governance-app/src/lib/governance/subgraph.ts new file mode 100644 index 00000000000..6a19f880335 --- /dev/null +++ b/governance-app/src/lib/governance/subgraph.ts @@ -0,0 +1,110 @@ +// ABOUTME: GraphQL client for fetching governance proposals from The Graph subgraph. +// ABOUTME: Replaces direct RPC eth_getLogs queries which are too large for most providers. +import { cache } from 'react' +import { keccak256, toUtf8Bytes } from 'ethers' +import { governanceConfig } from '~/config/governance' +import type { ProposalRecord } from './types' + +const PROPOSALS_QUERY = ` + query GetProposals { + proposals(first: 1000, orderBy: createdAt, orderDirection: desc) { + id + proposer + description + forVotes + againstVotes + abstainVotes + voteStartBlock + voteEndBlock + createdAt + quorum + proposalThreshold + targets + values + calldatas + etaSeconds + executedAt + canceledAt + transactionHash + } + } +` + +type SubgraphProposal = { + id: string + proposer: string + description: string + forVotes: string + againstVotes: string + abstainVotes: string + voteStartBlock: string + voteEndBlock: string + createdAt: string + quorum: string + proposalThreshold: string + targets: string[] + values: string[] + calldatas: string[] + etaSeconds: string | null + executedAt: string | null + canceledAt: string | null + transactionHash: string +} + +async function fetchSubgraph(query: string): Promise { + const response = await fetch(governanceConfig.subgraphUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + next: { revalidate: 60 }, + }) + + if (!response.ok) { + throw new Error(`Subgraph request failed: ${response.status}`) + } + + const json = await response.json() + + if (json.errors?.length) { + throw new Error(`Subgraph error: ${json.errors[0].message}`) + } + + return json.data as T +} + +function getTitle(description: string) { + return description.split('\n')[0]?.trim() || 'Untitled proposal' +} + +export const getProposalsFromSubgraph = cache( + async ( + proposalThreshold: bigint + ): Promise[]> => { + const data = await fetchSubgraph<{ proposals: SubgraphProposal[] }>( + PROPOSALS_QUERY + ) + + return data.proposals.map((p) => ({ + id: p.id, + proposer: p.proposer, + description: p.description, + title: getTitle(p.description), + descriptionHash: keccak256(toUtf8Bytes(p.description)), + forVotes: BigInt(p.forVotes), + againstVotes: BigInt(p.againstVotes), + abstainVotes: BigInt(p.abstainVotes), + voteStartTimestamp: BigInt(p.voteStartBlock), + voteEndTimestamp: BigInt(p.voteEndBlock), + createdAtTimestamp: BigInt(p.createdAt), + quorum: BigInt(p.quorum), + proposalThreshold, + targets: p.targets, + values: p.values.map((v) => BigInt(v)), + calldatas: p.calldatas, + etaSeconds: p.etaSeconds ? BigInt(p.etaSeconds) : null, + executedAt: p.executedAt ? BigInt(p.executedAt) : null, + canceledAt: p.canceledAt ? BigInt(p.canceledAt) : null, + transactionHash: p.transactionHash, + })) + } +) diff --git a/governance-app/src/lib/governance/types.ts b/governance-app/src/lib/governance/types.ts new file mode 100644 index 00000000000..26199708a23 --- /dev/null +++ b/governance-app/src/lib/governance/types.ts @@ -0,0 +1,56 @@ +export type ProposalState = + | 'Pending' + | 'Active' + | 'Succeeded' + | 'Defeated' + | 'Queued' + | 'Executed' + | 'Canceled' + +export type DecodedCalldata = + | { + kind: 'decoded' + contractLabel: string + functionName: string + args: string[] + value: bigint + } + | { + kind: 'raw' + target: string + calldata: string + value: bigint + } + +export type ProposalRecord = { + abstainVotes: bigint + againstVotes: bigint + calldatas: string[] + canceledAt: bigint | null + createdAtTimestamp: bigint + description: string + descriptionHash: string + executedAt: bigint | null + forVotes: bigint + id: string + proposalThreshold: bigint + proposer: string + quorum: bigint + state: ProposalState + targets: string[] + title: string + transactionHash: string + values: bigint[] + voteEndTimestamp: bigint + voteStartTimestamp: bigint + etaSeconds: bigint | null +} + +export type GovernanceOverview = { + latestTimestamp: bigint + proposalThreshold: bigint + proposals: ProposalRecord[] + tokenSymbol: string + votingDelay: bigint + votingPeriod: bigint +} diff --git a/governance-app/tsconfig.json b/governance-app/tsconfig.json index db812e4e5b3..67b948960c2 100644 --- a/governance-app/tsconfig.json +++ b/governance-app/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "@unlock-protocol/tsconfig", "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "module": "esnext", - "lib": ["dom", "dom.iterable", "es2017"], + "lib": ["dom", "dom.iterable", "es2020"], "allowJs": false, "skipLibCheck": true, "strict": true, diff --git a/governance-app/vercel.json b/governance-app/vercel.json index a667db8cdaa..11c47ad06f6 100644 --- a/governance-app/vercel.json +++ b/governance-app/vercel.json @@ -1,4 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "nextjs" + "framework": "nextjs", + "ignoreCommand": "git diff HEAD^ HEAD --quiet -- . ../yarn.lock" } diff --git a/yarn.lock b/yarn.lock index ffad6f72341..e642d485843 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22496,6 +22496,7 @@ __metadata: "@unlock-protocol/ui": "workspace:./packages/ui" autoprefixer: "npm:10.4.21" eslint: "npm:9.22.0" + ethers: "npm:6.15.0" next: "npm:14.2.35" postcss: "npm:8.4.49" react: "npm:18.3.1" From da5f10a2402816dcf34c11f4a347226ede246b7f Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:16:02 -0400 Subject: [PATCH 2/3] feat: add governance treasury read paths (#16321) --- governance-app/app/page.tsx | 31 ++++++- governance-app/app/treasury/page.tsx | 60 +++++++++++-- .../components/treasury/TreasuryAssetCard.tsx | 43 +++++++++ governance-app/src/lib/governance/treasury.ts | 88 +++++++++++++++++++ 4 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 governance-app/src/components/treasury/TreasuryAssetCard.tsx create mode 100644 governance-app/src/lib/governance/treasury.ts diff --git a/governance-app/app/page.tsx b/governance-app/app/page.tsx index 3a6245fec57..7b4c42e71c9 100644 --- a/governance-app/app/page.tsx +++ b/governance-app/app/page.tsx @@ -3,13 +3,20 @@ import { ProposalCard } from '~/components/proposals/ProposalCard' import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' import { formatTokenAmount } from '~/lib/governance/format' import { getGovernanceOverview } from '~/lib/governance/proposals' +import { getTreasuryOverview } from '~/lib/governance/treasury' export const dynamic = 'force-dynamic' export default async function HomePage() { try { - const overview = await getGovernanceOverview() + const [overview, treasury] = await Promise.all([ + getGovernanceOverview(), + getTreasuryOverview(), + ]) const recentProposals = overview.proposals.slice(0, 3) + const treasurySnapshot = treasury.assets + .filter((asset) => asset.symbol === 'ETH' || asset.symbol === 'UP') + .slice(0, 2) return (
@@ -56,6 +63,28 @@ export default async function HomePage() { /> +
+
+

+ Treasury snapshot +

+ + View treasury + +
+
+ {treasurySnapshot.map((asset) => ( + + ))} +
+

diff --git a/governance-app/app/treasury/page.tsx b/governance-app/app/treasury/page.tsx index 96c783edf33..aaaeac6dd2b 100644 --- a/governance-app/app/treasury/page.tsx +++ b/governance-app/app/treasury/page.tsx @@ -1,10 +1,54 @@ -import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder' +import { base } from '@unlock-protocol/networks' +import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' +import { TreasuryAssetCard } from '~/components/treasury/TreasuryAssetCard' +import { truncateAddress } from '~/lib/governance/format' +import { getTreasuryOverview } from '~/lib/governance/treasury' -export default function TreasuryPage() { - return ( - - ) +export const dynamic = 'force-dynamic' + +export default async function TreasuryPage() { + try { + const treasury = await getTreasuryOverview() + const timelockExplorerUrl = + base.explorer?.urls.address(treasury.timelockAddress) || '#' + + return ( +
+
+

+ Treasury Read Path +

+
+
+

+ Timelock treasury +

+

+ Live balances held by the Unlock DAO timelock, fetched directly + from Base on each page load. This is a curated known-token view, + not an exhaustive inventory of arbitrary ERC20 transfers. +

+
+ + View {truncateAddress(treasury.timelockAddress, 6)} on Basescan + +
+
+
+ {treasury.assets.map((asset) => ( + + ))} +
+
+ ) + } catch (error) { + return ( + + ) + } } diff --git a/governance-app/src/components/treasury/TreasuryAssetCard.tsx b/governance-app/src/components/treasury/TreasuryAssetCard.tsx new file mode 100644 index 00000000000..440d08e8f32 --- /dev/null +++ b/governance-app/src/components/treasury/TreasuryAssetCard.tsx @@ -0,0 +1,43 @@ +import { base } from '@unlock-protocol/networks' +import { formatTokenAmount, truncateAddress } from '~/lib/governance/format' +import type { TreasuryAsset } from '~/lib/governance/treasury' + +type TreasuryAssetCardProps = { + asset: TreasuryAsset +} + +export function TreasuryAssetCard({ asset }: TreasuryAssetCardProps) { + const explorerHref = asset.isNative + ? base.explorer?.urls.address(asset.address) + : base.explorer?.urls.token(asset.address, asset.address) || '#' + + return ( +
+
+
+

+ {asset.isNative ? 'Native asset' : 'ERC20 token'} +

+

+ {asset.symbol} +

+

{asset.name}

+
+ + Explorer + +
+
+ {formatTokenAmount(asset.balance, asset.decimals)} {asset.symbol} +
+
+ {truncateAddress(asset.address, 6)} +
+
+ ) +} diff --git a/governance-app/src/lib/governance/treasury.ts b/governance-app/src/lib/governance/treasury.ts new file mode 100644 index 00000000000..b1c559bbbc2 --- /dev/null +++ b/governance-app/src/lib/governance/treasury.ts @@ -0,0 +1,88 @@ +import { Contract, type InterfaceAbi } from 'ethers' +import { cache } from 'react' +import { base } from '@unlock-protocol/networks' +import { governanceConfig } from '~/config/governance' +import { getRpcProvider } from './rpc' + +const erc20Abi = [ + { + constant: true, + inputs: [ + { + name: 'account', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, +] as const satisfies InterfaceAbi + +export type TreasuryAsset = { + address: string + balance: bigint + decimals: number + isNative: boolean + name: string + symbol: string +} + +export type TreasuryOverview = { + assets: TreasuryAsset[] + timelockAddress: string +} + +export const getTreasuryOverview = cache( + async (): Promise => { + const provider = getRpcProvider() + const tokenAssets = (base.tokens || []).filter( + (token) => !!token.address && typeof token.decimals === 'number' + ) + + const [ethBalance, tokenBalances] = await Promise.all([ + provider.getBalance(governanceConfig.timelockAddress), + Promise.all( + tokenAssets.map(async (token) => { + const contract = new Contract(token.address, erc20Abi, provider) + const balance = (await contract.balanceOf( + governanceConfig.timelockAddress + )) as bigint + + return { + address: token.address, + balance, + decimals: token.decimals, + isNative: false, + name: token.name, + symbol: token.symbol, + } satisfies TreasuryAsset + }) + ), + ]) + + const assets = [ + { + address: governanceConfig.timelockAddress, + balance: ethBalance, + decimals: base.nativeCurrency.decimals, + isNative: true, + name: base.nativeCurrency.name, + symbol: base.nativeCurrency.symbol, + } satisfies TreasuryAsset, + ...tokenBalances.filter((asset) => asset.balance > 0n), + ] + + return { + assets, + timelockAddress: governanceConfig.timelockAddress, + } + } +) From 07aae83c0eaed0793d0145a952d26311420c27e6 Mon Sep 17 00:00:00 2001 From: Julien Genestoux <17735+julien51@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:03:40 -0400 Subject: [PATCH 3/3] feat: add governance delegates read paths (#16322) --- governance-app/app/delegate/page.tsx | 27 +++++- governance-app/app/delegates/page.tsx | 49 ++++++++-- governance-app/app/page.tsx | 27 +++++- .../delegates/DelegateLeaderboardRow.tsx | 55 +++++++++++ .../src/lib/governance/delegates.ts | 97 +++++++++++++++++++ governance-app/src/lib/governance/rpc.ts | 6 +- 6 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 governance-app/src/components/delegates/DelegateLeaderboardRow.tsx create mode 100644 governance-app/src/lib/governance/delegates.ts diff --git a/governance-app/app/delegate/page.tsx b/governance-app/app/delegate/page.tsx index d6e9acf9584..ac162719a18 100644 --- a/governance-app/app/delegate/page.tsx +++ b/governance-app/app/delegate/page.tsx @@ -1,10 +1,27 @@ -import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder' +import Link from 'next/link' export default function DelegatePage() { return ( - +
+

+ Personal Delegation +

+

+ Wallet-specific delegation comes next +

+

+ This slice adds the public delegation leaderboard first. The next + transaction-focused slice will wire connected-wallet delegation status, + self-delegation messaging, and `UPToken.delegate()` writes. +

+
+ + View delegate leaderboard + +
+
) } diff --git a/governance-app/app/delegates/page.tsx b/governance-app/app/delegates/page.tsx index 643c8e1e3d1..410c48f2d4c 100644 --- a/governance-app/app/delegates/page.tsx +++ b/governance-app/app/delegates/page.tsx @@ -1,10 +1,43 @@ -import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder' +import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' +import { DelegateLeaderboardRow } from '~/components/delegates/DelegateLeaderboardRow' +import { getDelegateOverview } from '~/lib/governance/delegates' -export default function DelegatesPage() { - return ( - - ) +export const dynamic = 'force-dynamic' + +export default async function DelegatesPage() { + try { + const overview = await getDelegateOverview() + + return ( +
+
+

+ Delegation Read Path +

+

+ Delegate leaderboard +

+

+ Current delegation relationships reconstructed from on-chain + `DelegateChanged` events and hydrated with live voting power from + the UP token contract. +

+
+
+ {overview.delegates.map((delegate, index) => ( + + ))} +
+
+ ) + } catch (error) { + return ( + + ) + } } diff --git a/governance-app/app/page.tsx b/governance-app/app/page.tsx index 7b4c42e71c9..3e4078e4a03 100644 --- a/governance-app/app/page.tsx +++ b/governance-app/app/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { ProposalCard } from '~/components/proposals/ProposalCard' import { ProposalErrorState } from '~/components/proposals/ProposalErrorState' import { formatTokenAmount } from '~/lib/governance/format' +import { getDelegateOverview } from '~/lib/governance/delegates' import { getGovernanceOverview } from '~/lib/governance/proposals' import { getTreasuryOverview } from '~/lib/governance/treasury' @@ -9,11 +10,13 @@ export const dynamic = 'force-dynamic' export default async function HomePage() { try { - const [overview, treasury] = await Promise.all([ + const [overview, treasury, delegates] = await Promise.all([ getGovernanceOverview(), getTreasuryOverview(), + getDelegateOverview(), ]) const recentProposals = overview.proposals.slice(0, 3) + const topDelegates = delegates.delegates.slice(0, 3) const treasurySnapshot = treasury.assets .filter((asset) => asset.symbol === 'ETH' || asset.symbol === 'UP') .slice(0, 2) @@ -63,6 +66,28 @@ export default async function HomePage() { />

+
+
+

+ Delegates snapshot +

+ + View delegates + +
+
+ {topDelegates.map((delegate) => ( + + ))} +
+

diff --git a/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx b/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx new file mode 100644 index 00000000000..9464fc87ce2 --- /dev/null +++ b/governance-app/src/components/delegates/DelegateLeaderboardRow.tsx @@ -0,0 +1,55 @@ +import { formatTokenAmount, truncateAddress } from '~/lib/governance/format' +import { + formatDelegatedShare, + type DelegateRecord, +} from '~/lib/governance/delegates' + +type DelegateLeaderboardRowProps = { + delegate: DelegateRecord + rank: number + totalSupply: bigint +} + +export function DelegateLeaderboardRow({ + delegate, + rank, + totalSupply, +}: DelegateLeaderboardRowProps) { + return ( +
+
+ #{rank} +
+
+
+ {truncateAddress(delegate.address, 6)} +
+
+ Token balance: {formatTokenAmount(delegate.tokenBalance)} UP +
+
+ + + +
+ ) +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} diff --git a/governance-app/src/lib/governance/delegates.ts b/governance-app/src/lib/governance/delegates.ts new file mode 100644 index 00000000000..638fb487bd0 --- /dev/null +++ b/governance-app/src/lib/governance/delegates.ts @@ -0,0 +1,97 @@ +import { cache } from 'react' +import { formatEther, getAddress } from 'ethers' +import { getTokenContract } from './rpc' + +const zeroAddress = '0x0000000000000000000000000000000000000000' + +type DelegateEvent = { + args?: Record & unknown[] +} + +export type DelegateRecord = { + address: string + delegatorCount: number + tokenBalance: bigint + votingPower: bigint +} + +export type DelegateOverview = { + delegates: DelegateRecord[] + totalSupply: bigint +} + +function getEventArg(event: DelegateEvent, key: string) { + return event.args?.[key] as T +} + +export const getDelegateOverview = cache( + async (): Promise => { + const token = getTokenContract() + const delegateChangedEvents = await token.queryFilter('DelegateChanged') + const currentDelegates = new Map() + + for (const event of delegateChangedEvents as DelegateEvent[]) { + const delegator = getAddress(getEventArg(event, 'delegator')) + const delegate = getAddress(getEventArg(event, 'toDelegate')) + currentDelegates.set(delegator, delegate) + } + + const delegateToDelegators = new Map>() + + for (const [delegator, delegate] of currentDelegates.entries()) { + if (delegate === zeroAddress) { + continue + } + + const delegators = delegateToDelegators.get(delegate) || new Set() + delegators.add(delegator) + delegateToDelegators.set(delegate, delegators) + } + + const [totalSupply, delegates] = await Promise.all([ + token.totalSupply() as Promise, + Promise.all( + Array.from(delegateToDelegators.entries()).map( + async ([address, delegators]) => { + const [votingPower, tokenBalance] = await Promise.all([ + token.getVotes(address) as Promise, + token.balanceOf(address) as Promise, + ]) + + return { + address, + delegatorCount: delegators.size, + tokenBalance, + votingPower, + } satisfies DelegateRecord + } + ) + ), + ]) + + delegates.sort((left, right) => { + const votingPowerDelta = Number(right.votingPower - left.votingPower) + + if (votingPowerDelta !== 0) { + return votingPowerDelta + } + + return right.delegatorCount - left.delegatorCount + }) + + return { + delegates: delegates.filter( + (delegate) => delegate.votingPower > 0n || delegate.delegatorCount > 0 + ), + totalSupply, + } + } +) + +export function formatDelegatedShare(votingPower: bigint, totalSupply: bigint) { + if (totalSupply === 0n) { + return '0.0%' + } + + return `${((Number(formatEther(votingPower)) / Number(formatEther(totalSupply))) * 100).toFixed(1)}%` +} diff --git a/governance-app/src/lib/governance/rpc.ts b/governance-app/src/lib/governance/rpc.ts index 3ec4e209568..38d739e1b23 100644 --- a/governance-app/src/lib/governance/rpc.ts +++ b/governance-app/src/lib/governance/rpc.ts @@ -1,10 +1,10 @@ import { Contract, Interface, type InterfaceAbi, JsonRpcProvider } from 'ethers' import { cache } from 'react' -import { UPGovernor } from '@unlock-protocol/contracts' +import { UPGovernor, UPToken } from '@unlock-protocol/contracts' import { governanceConfig } from '~/config/governance' const governorAbi = getContractAbi(UPGovernor) -const erc20Abi = ['function symbol() view returns (string)'] +const tokenAbi = getContractAbi(UPToken) export const getRpcProvider = cache( () => new JsonRpcProvider(governanceConfig.rpcUrl, governanceConfig.chainId) @@ -22,7 +22,7 @@ export const getGovernorContract = cache( export const getGovernorInterface = cache(() => new Interface(governorAbi)) export const getTokenContract = cache( - () => new Contract(governanceConfig.tokenAddress, erc20Abi, getRpcProvider()) + () => new Contract(governanceConfig.tokenAddress, tokenAbi, getRpcProvider()) ) export const getTokenSymbol = cache(async () => {