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 33ca098b747..3e4078e4a03 100644 --- a/governance-app/app/page.tsx +++ b/governance-app/app/page.tsx @@ -1,10 +1,157 @@ -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 { getDelegateOverview } from '~/lib/governance/delegates' +import { getGovernanceOverview } from '~/lib/governance/proposals' +import { getTreasuryOverview } from '~/lib/governance/treasury' -export default function HomePage() { +export const dynamic = 'force-dynamic' + +export default async function HomePage() { + try { + 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) + + 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 + +
+
+
+ + + +
+
+
+
+

+ Delegates snapshot +

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

+ Treasury snapshot +

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

+ 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/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/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/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/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/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/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/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/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..38d739e1b23 --- /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, UPToken } from '@unlock-protocol/contracts' +import { governanceConfig } from '~/config/governance' + +const governorAbi = getContractAbi(UPGovernor) +const tokenAbi = getContractAbi(UPToken) + +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, tokenAbi, 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/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, + } + } +) 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"