Skip to content

Commit 07aae83

Browse files
authored
feat: add governance delegates read paths (unlock-protocol#16322)
1 parent da5f10a commit 07aae83

6 files changed

Lines changed: 244 additions & 17 deletions

File tree

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1-
import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder'
1+
import Link from 'next/link'
22

33
export default function DelegatePage() {
44
return (
5-
<RoutePlaceholder
6-
title="Personal Delegation"
7-
description="Delegation route shell for wallet status, self-delegation messaging, and delegate updates."
8-
/>
5+
<section className="rounded-[2rem] border border-brand-ui-primary/10 bg-white p-8 shadow-sm">
6+
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-ui-primary/55">
7+
Personal Delegation
8+
</p>
9+
<h2 className="mt-4 text-4xl font-semibold text-brand-ui-primary">
10+
Wallet-specific delegation comes next
11+
</h2>
12+
<p className="mt-4 max-w-3xl text-base leading-7 text-brand-ui-primary/72">
13+
This slice adds the public delegation leaderboard first. The next
14+
transaction-focused slice will wire connected-wallet delegation status,
15+
self-delegation messaging, and `UPToken.delegate()` writes.
16+
</p>
17+
<div className="mt-8">
18+
<Link
19+
className="rounded-full bg-brand-ui-primary px-5 py-3 text-sm font-semibold text-white"
20+
href="/delegates"
21+
>
22+
View delegate leaderboard
23+
</Link>
24+
</div>
25+
</section>
926
)
1027
}
Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,43 @@
1-
import { RoutePlaceholder } from '~/components/layout/RoutePlaceholder'
1+
import { ProposalErrorState } from '~/components/proposals/ProposalErrorState'
2+
import { DelegateLeaderboardRow } from '~/components/delegates/DelegateLeaderboardRow'
3+
import { getDelegateOverview } from '~/lib/governance/delegates'
24

3-
export default function DelegatesPage() {
4-
return (
5-
<RoutePlaceholder
6-
title="Delegates"
7-
description="Delegates leaderboard shell. Subgraph-backed rankings and participation metrics will land in a dedicated follow-up slice."
8-
/>
9-
)
5+
export const dynamic = 'force-dynamic'
6+
7+
export default async function DelegatesPage() {
8+
try {
9+
const overview = await getDelegateOverview()
10+
11+
return (
12+
<section className="space-y-8">
13+
<div className="rounded-[2rem] border border-brand-ui-primary/10 bg-white p-8 shadow-sm">
14+
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-ui-primary/55">
15+
Delegation Read Path
16+
</p>
17+
<h2 className="mt-4 text-4xl font-semibold text-brand-ui-primary">
18+
Delegate leaderboard
19+
</h2>
20+
<p className="mt-4 max-w-3xl text-base leading-7 text-brand-ui-primary/72">
21+
Current delegation relationships reconstructed from on-chain
22+
`DelegateChanged` events and hydrated with live voting power from
23+
the UP token contract.
24+
</p>
25+
</div>
26+
<div className="grid gap-4">
27+
{overview.delegates.map((delegate, index) => (
28+
<DelegateLeaderboardRow
29+
key={delegate.address}
30+
delegate={delegate}
31+
rank={index + 1}
32+
totalSupply={overview.totalSupply}
33+
/>
34+
))}
35+
</div>
36+
</section>
37+
)
38+
} catch (error) {
39+
return (
40+
<ProposalErrorState description="The delegates page could not load delegation data from Base. Check RPC connectivity or try again shortly." />
41+
)
42+
}
1043
}

governance-app/app/page.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@ import Link from 'next/link'
22
import { ProposalCard } from '~/components/proposals/ProposalCard'
33
import { ProposalErrorState } from '~/components/proposals/ProposalErrorState'
44
import { formatTokenAmount } from '~/lib/governance/format'
5+
import { getDelegateOverview } from '~/lib/governance/delegates'
56
import { getGovernanceOverview } from '~/lib/governance/proposals'
67
import { getTreasuryOverview } from '~/lib/governance/treasury'
78

89
export const dynamic = 'force-dynamic'
910

1011
export default async function HomePage() {
1112
try {
12-
const [overview, treasury] = await Promise.all([
13+
const [overview, treasury, delegates] = await Promise.all([
1314
getGovernanceOverview(),
1415
getTreasuryOverview(),
16+
getDelegateOverview(),
1517
])
1618
const recentProposals = overview.proposals.slice(0, 3)
19+
const topDelegates = delegates.delegates.slice(0, 3)
1720
const treasurySnapshot = treasury.assets
1821
.filter((asset) => asset.symbol === 'ETH' || asset.symbol === 'UP')
1922
.slice(0, 2)
@@ -63,6 +66,28 @@ export default async function HomePage() {
6366
/>
6467
</div>
6568
</div>
69+
<div className="space-y-4">
70+
<div className="flex items-center justify-between gap-4">
71+
<h3 className="text-2xl font-semibold text-brand-ui-primary">
72+
Delegates snapshot
73+
</h3>
74+
<Link
75+
className="text-sm font-semibold text-brand-ui-primary"
76+
href="/delegates"
77+
>
78+
View delegates
79+
</Link>
80+
</div>
81+
<div className="grid gap-4 md:grid-cols-3">
82+
{topDelegates.map((delegate) => (
83+
<StatCard
84+
key={delegate.address}
85+
label={delegate.address}
86+
value={`${formatTokenAmount(delegate.votingPower)} UP`}
87+
/>
88+
))}
89+
</div>
90+
</div>
6691
<div className="space-y-4">
6792
<div className="flex items-center justify-between gap-4">
6893
<h3 className="text-2xl font-semibold text-brand-ui-primary">
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { formatTokenAmount, truncateAddress } from '~/lib/governance/format'
2+
import {
3+
formatDelegatedShare,
4+
type DelegateRecord,
5+
} from '~/lib/governance/delegates'
6+
7+
type DelegateLeaderboardRowProps = {
8+
delegate: DelegateRecord
9+
rank: number
10+
totalSupply: bigint
11+
}
12+
13+
export function DelegateLeaderboardRow({
14+
delegate,
15+
rank,
16+
totalSupply,
17+
}: DelegateLeaderboardRowProps) {
18+
return (
19+
<div className="grid gap-4 rounded-[2rem] border border-brand-ui-primary/10 bg-white p-6 shadow-sm md:grid-cols-[80px_minmax(0,2fr)_1fr_1fr_1fr] md:items-center">
20+
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-ui-primary/45">
21+
#{rank}
22+
</div>
23+
<div>
24+
<div className="text-lg font-semibold text-brand-ui-primary">
25+
{truncateAddress(delegate.address, 6)}
26+
</div>
27+
<div className="text-sm text-brand-ui-primary/60">
28+
Token balance: {formatTokenAmount(delegate.tokenBalance)} UP
29+
</div>
30+
</div>
31+
<Metric
32+
label="Voting power"
33+
value={`${formatTokenAmount(delegate.votingPower)} UP`}
34+
/>
35+
<Metric
36+
label="% delegated"
37+
value={formatDelegatedShare(delegate.votingPower, totalSupply)}
38+
/>
39+
<Metric label="Delegators" value={delegate.delegatorCount.toString()} />
40+
</div>
41+
)
42+
}
43+
44+
function Metric({ label, value }: { label: string; value: string }) {
45+
return (
46+
<div>
47+
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-ui-primary/45">
48+
{label}
49+
</div>
50+
<div className="mt-1 text-base font-semibold text-brand-ui-primary">
51+
{value}
52+
</div>
53+
</div>
54+
)
55+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { cache } from 'react'
2+
import { formatEther, getAddress } from 'ethers'
3+
import { getTokenContract } from './rpc'
4+
5+
const zeroAddress = '0x0000000000000000000000000000000000000000'
6+
7+
type DelegateEvent = {
8+
args?: Record<string, unknown> & unknown[]
9+
}
10+
11+
export type DelegateRecord = {
12+
address: string
13+
delegatorCount: number
14+
tokenBalance: bigint
15+
votingPower: bigint
16+
}
17+
18+
export type DelegateOverview = {
19+
delegates: DelegateRecord[]
20+
totalSupply: bigint
21+
}
22+
23+
function getEventArg<T>(event: DelegateEvent, key: string) {
24+
return event.args?.[key] as T
25+
}
26+
27+
export const getDelegateOverview = cache(
28+
async (): Promise<DelegateOverview> => {
29+
const token = getTokenContract()
30+
const delegateChangedEvents = await token.queryFilter('DelegateChanged')
31+
const currentDelegates = new Map<string, string>()
32+
33+
for (const event of delegateChangedEvents as DelegateEvent[]) {
34+
const delegator = getAddress(getEventArg<string>(event, 'delegator'))
35+
const delegate = getAddress(getEventArg<string>(event, 'toDelegate'))
36+
currentDelegates.set(delegator, delegate)
37+
}
38+
39+
const delegateToDelegators = new Map<string, Set<string>>()
40+
41+
for (const [delegator, delegate] of currentDelegates.entries()) {
42+
if (delegate === zeroAddress) {
43+
continue
44+
}
45+
46+
const delegators = delegateToDelegators.get(delegate) || new Set<string>()
47+
delegators.add(delegator)
48+
delegateToDelegators.set(delegate, delegators)
49+
}
50+
51+
const [totalSupply, delegates] = await Promise.all([
52+
token.totalSupply() as Promise<bigint>,
53+
Promise.all(
54+
Array.from(delegateToDelegators.entries()).map(
55+
async ([address, delegators]) => {
56+
const [votingPower, tokenBalance] = await Promise.all([
57+
token.getVotes(address) as Promise<bigint>,
58+
token.balanceOf(address) as Promise<bigint>,
59+
])
60+
61+
return {
62+
address,
63+
delegatorCount: delegators.size,
64+
tokenBalance,
65+
votingPower,
66+
} satisfies DelegateRecord
67+
}
68+
)
69+
),
70+
])
71+
72+
delegates.sort((left, right) => {
73+
const votingPowerDelta = Number(right.votingPower - left.votingPower)
74+
75+
if (votingPowerDelta !== 0) {
76+
return votingPowerDelta
77+
}
78+
79+
return right.delegatorCount - left.delegatorCount
80+
})
81+
82+
return {
83+
delegates: delegates.filter(
84+
(delegate) => delegate.votingPower > 0n || delegate.delegatorCount > 0
85+
),
86+
totalSupply,
87+
}
88+
}
89+
)
90+
91+
export function formatDelegatedShare(votingPower: bigint, totalSupply: bigint) {
92+
if (totalSupply === 0n) {
93+
return '0.0%'
94+
}
95+
96+
return `${((Number(formatEther(votingPower)) / Number(formatEther(totalSupply))) * 100).toFixed(1)}%`
97+
}

governance-app/src/lib/governance/rpc.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Contract, Interface, type InterfaceAbi, JsonRpcProvider } from 'ethers'
22
import { cache } from 'react'
3-
import { UPGovernor } from '@unlock-protocol/contracts'
3+
import { UPGovernor, UPToken } from '@unlock-protocol/contracts'
44
import { governanceConfig } from '~/config/governance'
55

66
const governorAbi = getContractAbi(UPGovernor)
7-
const erc20Abi = ['function symbol() view returns (string)']
7+
const tokenAbi = getContractAbi(UPToken)
88

99
export const getRpcProvider = cache(
1010
() => new JsonRpcProvider(governanceConfig.rpcUrl, governanceConfig.chainId)
@@ -22,7 +22,7 @@ export const getGovernorContract = cache(
2222
export const getGovernorInterface = cache(() => new Interface(governorAbi))
2323

2424
export const getTokenContract = cache(
25-
() => new Contract(governanceConfig.tokenAddress, erc20Abi, getRpcProvider())
25+
() => new Contract(governanceConfig.tokenAddress, tokenAbi, getRpcProvider())
2626
)
2727

2828
export const getTokenSymbol = cache(async () => {

0 commit comments

Comments
 (0)