Skip to content

Commit 951d976

Browse files
julien51claude
andauthored
feat: add governance subgraph sources (unlock-protocol#16331)
* feat: add governance subgraph sources * fix: normalize test address constants to lowercase hex Aligns governance test fixtures with toHexString() output and existing test conventions in constants.ts to avoid comparison mismatches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rename misleading timestamp fields to block fields in Proposal entity voteStart/voteEnd from OZ Governor are timepoints (block numbers), not timestamps. Rename voteStartTimestamp/voteEndTimestamp to voteStartBlock/voteEndBlock. Rename createdAtTimestamp to createdAt (stores block.timestamp, an actual Unix timestamp). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger re-run for claude review debug * ci: add gh pr review to allowed tools for claude code review * revert: undo workflow change (needs its own PR to master) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ebd9b7 commit 951d976

File tree

8 files changed

+967
-1
lines changed

8 files changed

+967
-1
lines changed

subgraph/bin/abis.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const getVersions = (contractName) =>
1717

1818
const unlockVersions = ['v11']
1919
const publicLockVersions = getVersions('PublicLock')
20+
const governanceContracts = ['UPGovernor', 'UPToken']
2021

2122
function setupFolder() {
2223
// make sure we clean up
@@ -47,8 +48,13 @@ function parseAndCopyAbis() {
4748
setupFolder()
4849
publicLockVersions.map((version) => copyAbi('PublicLock', version))
4950
unlockVersions.map((version) => copyAbi('Unlock', version))
51+
governanceContracts.map((contractName) => {
52+
const { abi } = abis[contractName]
53+
const abiPath = path.join(abisFolderPath, `${contractName}.json`)
54+
fs.writeJSONSync(abiPath, abi, { spaces: 2 })
55+
})
5056
console.log(
51-
`Abis file saved at: ${abisFolderPath} (PublicLock : ${publicLockVersions.toString()} - Unlock: ${unlockVersions.toString()})`
57+
`Abis file saved at: ${abisFolderPath} (PublicLock : ${publicLockVersions.toString()} - Unlock: ${unlockVersions.toString()} - Governance: ${governanceContracts.toString()})`
5258
)
5359

5460
// merge

subgraph/bin/networks.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ function setupFolderConfig() {
5757

5858
function createNetworkConfig(network, chainName) {
5959
const name = networkName(chainName)
60+
const governanceToken = network.tokens?.find(({ symbol }) => symbol === 'UP')
6061
const networkFile = {
6162
network: name,
6263
deployments: [
@@ -77,6 +78,19 @@ function createNetworkConfig(network, chainName) {
7778
})
7879
}
7980

81+
if (network.dao?.governor && governanceToken?.address) {
82+
networkFile.governance = {
83+
governor: {
84+
address: network.dao.governor,
85+
startBlock: network.startBlock,
86+
},
87+
token: {
88+
address: governanceToken.address,
89+
startBlock: network.startBlock,
90+
},
91+
}
92+
}
93+
8094
const configPath = path.join(configFolderPath, `${name}.json`)
8195
fs.writeJSONSync(configPath, networkFile, { spaces: 2 })
8296
}

subgraph/schema.graphql

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,53 @@ type ReferrerFee @entity {
144144
"In the Unlock ecosystem, a “Lock” is a smart contract that creates (or “mints”) NFTs"
145145
lock: Lock!
146146
}
147+
148+
type Proposal @entity {
149+
"On-chain proposalId as a decimal string"
150+
id: ID!
151+
proposer: String!
152+
description: String!
153+
forVotes: BigInt!
154+
againstVotes: BigInt!
155+
abstainVotes: BigInt!
156+
voteStartBlock: BigInt!
157+
voteEndBlock: BigInt!
158+
createdAt: BigInt!
159+
quorum: BigInt!
160+
proposalThreshold: BigInt!
161+
targets: [String!]!
162+
values: [BigInt!]!
163+
calldatas: [Bytes!]!
164+
etaSeconds: BigInt
165+
executedAt: BigInt
166+
canceledAt: BigInt
167+
transactionHash: String!
168+
votes: [Vote!]! @derivedFrom(field: "proposal")
169+
}
170+
171+
type Vote @entity {
172+
id: ID!
173+
proposal: Proposal!
174+
voter: String!
175+
support: Int!
176+
weight: BigInt!
177+
reason: String
178+
createdAt: BigInt!
179+
transactionHash: String!
180+
}
181+
182+
type Delegate @entity {
183+
id: ID!
184+
delegatedTo: String!
185+
votingPower: BigInt!
186+
tokenBalance: BigInt!
187+
updatedAt: BigInt!
188+
}
189+
190+
type DelegateSummary @entity {
191+
id: ID!
192+
totalDelegatedPower: BigInt!
193+
delegatorCount: Int!
194+
proposalsVoted: Int!
195+
updatedAt: BigInt!
196+
}

subgraph/src/governance.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts'
2+
import {
3+
ProposalCanceled,
4+
ProposalCreated,
5+
ProposalExecuted,
6+
ProposalQueued,
7+
UPGovernor,
8+
VoteCast,
9+
VoteCastWithParams,
10+
} from '../generated/UPGovernor/UPGovernor'
11+
import {
12+
DelegateChanged,
13+
DelegateVotesChanged,
14+
Transfer,
15+
} from '../generated/UPToken/UPToken'
16+
import { Delegate, DelegateSummary, Proposal, Vote } from '../generated/schema'
17+
18+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
19+
20+
export function handleProposalCreated(event: ProposalCreated): void {
21+
const id = event.params.proposalId.toString()
22+
const proposal = new Proposal(id)
23+
const governor = UPGovernor.bind(event.address)
24+
const quorumCall = governor.try_quorum(event.params.voteStart)
25+
const proposalThresholdCall = governor.try_proposalThreshold()
26+
27+
proposal.proposer = event.params.proposer.toHexString()
28+
proposal.description = event.params.description
29+
proposal.forVotes = BigInt.zero()
30+
proposal.againstVotes = BigInt.zero()
31+
proposal.abstainVotes = BigInt.zero()
32+
proposal.voteStartBlock = event.params.voteStart
33+
proposal.voteEndBlock = event.params.voteEnd
34+
proposal.createdAt = event.block.timestamp
35+
proposal.quorum = quorumCall.reverted ? BigInt.zero() : quorumCall.value
36+
proposal.proposalThreshold = proposalThresholdCall.reverted
37+
? BigInt.zero()
38+
: proposalThresholdCall.value
39+
proposal.targets = addressArrayToStrings(event.params.targets)
40+
proposal.values = event.params.values
41+
proposal.calldatas = event.params.calldatas
42+
proposal.transactionHash = event.transaction.hash.toHexString()
43+
proposal.save()
44+
}
45+
46+
export function handleVoteCast(event: VoteCast): void {
47+
createVote(
48+
event.params.proposalId,
49+
event.params.voter,
50+
event.params.support,
51+
event.params.weight,
52+
event.params.reason,
53+
event.block.timestamp,
54+
event.transaction.hash
55+
)
56+
}
57+
58+
export function handleVoteCastWithParams(event: VoteCastWithParams): void {
59+
createVote(
60+
event.params.proposalId,
61+
event.params.voter,
62+
event.params.support,
63+
event.params.weight,
64+
event.params.reason,
65+
event.block.timestamp,
66+
event.transaction.hash
67+
)
68+
}
69+
70+
export function handleProposalQueued(event: ProposalQueued): void {
71+
const proposal = Proposal.load(event.params.proposalId.toString())
72+
if (!proposal) {
73+
return
74+
}
75+
76+
proposal.etaSeconds = event.params.etaSeconds
77+
proposal.save()
78+
}
79+
80+
export function handleProposalExecuted(event: ProposalExecuted): void {
81+
const proposal = Proposal.load(event.params.proposalId.toString())
82+
if (!proposal) {
83+
return
84+
}
85+
86+
proposal.executedAt = event.block.timestamp
87+
proposal.save()
88+
}
89+
90+
export function handleProposalCanceled(event: ProposalCanceled): void {
91+
const proposal = Proposal.load(event.params.proposalId.toString())
92+
if (!proposal) {
93+
return
94+
}
95+
96+
proposal.canceledAt = event.block.timestamp
97+
proposal.save()
98+
}
99+
100+
export function handleDelegateChanged(event: DelegateChanged): void {
101+
const delegate = loadOrCreateDelegate(
102+
event.params.delegator,
103+
event.block.timestamp
104+
)
105+
106+
delegate.delegatedTo = event.params.toDelegate.toHexString()
107+
delegate.updatedAt = event.block.timestamp
108+
delegate.save()
109+
110+
updateDelegatorCount(event.params.fromDelegate, -1, event.block.timestamp)
111+
updateDelegatorCount(event.params.toDelegate, 1, event.block.timestamp)
112+
}
113+
114+
export function handleDelegateVotesChanged(event: DelegateVotesChanged): void {
115+
const delegate = loadOrCreateDelegate(
116+
event.params.delegate,
117+
event.block.timestamp
118+
)
119+
delegate.votingPower = event.params.newVotes
120+
delegate.updatedAt = event.block.timestamp
121+
delegate.save()
122+
123+
const summary = loadOrCreateDelegateSummary(
124+
event.params.delegate,
125+
event.block.timestamp
126+
)
127+
summary.totalDelegatedPower = event.params.newVotes
128+
summary.updatedAt = event.block.timestamp
129+
summary.save()
130+
}
131+
132+
export function handleUPTokenTransfer(event: Transfer): void {
133+
if (event.params.from.toHexString() != ZERO_ADDRESS) {
134+
const fromDelegate = loadOrCreateDelegate(
135+
event.params.from,
136+
event.block.timestamp
137+
)
138+
fromDelegate.tokenBalance = fromDelegate.tokenBalance.minus(
139+
event.params.value
140+
)
141+
fromDelegate.updatedAt = event.block.timestamp
142+
fromDelegate.save()
143+
}
144+
145+
if (event.params.to.toHexString() != ZERO_ADDRESS) {
146+
const toDelegate = loadOrCreateDelegate(
147+
event.params.to,
148+
event.block.timestamp
149+
)
150+
toDelegate.tokenBalance = toDelegate.tokenBalance.plus(event.params.value)
151+
toDelegate.updatedAt = event.block.timestamp
152+
toDelegate.save()
153+
}
154+
}
155+
156+
function createVote(
157+
proposalId: BigInt,
158+
voter: Address,
159+
support: i32,
160+
weight: BigInt,
161+
reason: string,
162+
createdAt: BigInt,
163+
transactionHash: Bytes
164+
): void {
165+
const proposal = Proposal.load(proposalId.toString())
166+
if (!proposal) {
167+
return
168+
}
169+
170+
const voterId = voter.toHexString()
171+
const vote = new Vote(proposalId.toString().concat('-').concat(voterId))
172+
vote.proposal = proposal.id
173+
vote.voter = voterId
174+
vote.support = support
175+
vote.weight = weight
176+
vote.reason = reason
177+
vote.createdAt = createdAt
178+
vote.transactionHash = transactionHash.toHexString()
179+
vote.save()
180+
181+
if (support == 0) {
182+
proposal.againstVotes = proposal.againstVotes.plus(weight)
183+
} else if (support == 1) {
184+
proposal.forVotes = proposal.forVotes.plus(weight)
185+
} else if (support == 2) {
186+
proposal.abstainVotes = proposal.abstainVotes.plus(weight)
187+
}
188+
proposal.save()
189+
190+
const summary = loadOrCreateDelegateSummary(voter, createdAt)
191+
summary.proposalsVoted = summary.proposalsVoted + 1
192+
summary.updatedAt = createdAt
193+
summary.save()
194+
}
195+
196+
function loadOrCreateDelegate(address: Address, timestamp: BigInt): Delegate {
197+
const id = address.toHexString()
198+
let delegate = Delegate.load(id)
199+
200+
if (!delegate) {
201+
delegate = new Delegate(id)
202+
delegate.delegatedTo = ZERO_ADDRESS
203+
delegate.votingPower = BigInt.zero()
204+
delegate.tokenBalance = BigInt.zero()
205+
delegate.updatedAt = timestamp
206+
}
207+
208+
return delegate
209+
}
210+
211+
function loadOrCreateDelegateSummary(
212+
address: Address,
213+
timestamp: BigInt
214+
): DelegateSummary {
215+
const id = address.toHexString()
216+
let summary = DelegateSummary.load(id)
217+
218+
if (!summary) {
219+
summary = new DelegateSummary(id)
220+
summary.totalDelegatedPower = BigInt.zero()
221+
summary.delegatorCount = 0
222+
summary.proposalsVoted = 0
223+
summary.updatedAt = timestamp
224+
}
225+
226+
return summary
227+
}
228+
229+
function updateDelegatorCount(
230+
delegateAddress: Address,
231+
delta: i32,
232+
timestamp: BigInt
233+
): void {
234+
if (delegateAddress.toHexString() == ZERO_ADDRESS) {
235+
return
236+
}
237+
238+
const summary = loadOrCreateDelegateSummary(delegateAddress, timestamp)
239+
const nextCount = summary.delegatorCount + delta
240+
summary.delegatorCount = nextCount < 0 ? 0 : nextCount
241+
summary.updatedAt = timestamp
242+
summary.save()
243+
}
244+
245+
function addressArrayToStrings(addresses: Array<Address>): Array<string> {
246+
const values = new Array<string>(addresses.length)
247+
248+
for (let i = 0; i < addresses.length; i++) {
249+
values[i] = addresses[i].toHexString()
250+
}
251+
252+
return values
253+
}

0 commit comments

Comments
 (0)