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}
+
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+ Governance settings
+
+
+
+
+
+
+
+
+
+
+
+ Lifecycle action
+
+
+
+ {proposal.state === 'Succeeded'
+ ? 'Queue proposal'
+ : proposal.state === 'Queued'
+ ? 'Execute proposal'
+ : 'No action available'}
+
+
+ Write flows land in a later slice. This page already derives
+ the live action state, including queued execution readiness.
+
+
+
+
+
+
+ )
+ } 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
+
+
+
+
+ {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 (
+
+ {proposalFilters.map((filter) => {
+ const isActive = filter === activeFilter
+
+ return (
+
+ {filter}
+
+ )
+ })}
+
+ )
+}
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"