From a6a6ff32ddc0c2c9dd146c58a07d2c2da0652b13 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 26 Nov 2025 13:08:25 +0100 Subject: [PATCH 1/4] feat(treasury): index governor votes and tallies Track LivepeerGovernor votes with per-voter state and proposal-level aggregates. Co-authored-by: kyriediculous --- schema.graphql | 61 +++++++++++++++++ src/mappings/treasury.ts | 137 ++++++++++++++++++++++++++++++++++++++- subgraph.template.yaml | 8 +++ 3 files changed, 203 insertions(+), 3 deletions(-) diff --git a/schema.graphql b/schema.graphql index aa25ed2..15887e8 100755 --- a/schema.graphql +++ b/schema.graphql @@ -354,6 +354,9 @@ type TranscoderDay @entity { transcoder: Transcoder! } +""" +Stake weighted treasury proposal +""" type TreasuryProposal @entity { "Governor proposal ID formatted as a decimal number" id: ID! @@ -371,6 +374,40 @@ type TreasuryProposal @entity { voteEnd: BigInt! "Description of the proposal" description: String! + "Votes cast for this proposal" + votes: [TreasuryVote!]! @derivedFrom(field: "proposal") + "Total weight of votes in favor" + forVotes: BigDecimal! + "Total weight of votes against" + againstVotes: BigDecimal! + "Total weight of abstaining votes" + abstainVotes: BigDecimal! + "Sum of all vote weights" + totalVotes: BigDecimal! +} + +enum TreasuryVoteSupport @entity { + Against + For + Abstain +} + +""" +Stake weighted vote on a treasury proposal +""" +type TreasuryVote @entity { + "Proposal ID + voter address" + id: ID! + "The proposal that was voted on" + proposal: TreasuryProposal! + "Account that cast the vote" + voter: LivepeerAccount! + "The voter's position" + support: TreasuryVoteSupport! + "Stake-weighted voting power" + weight: BigDecimal! + "Optional reason string provided by the voter" + reason: String } ############################################################################### @@ -871,6 +908,30 @@ type ParameterUpdateEvent implements Event @entity { param: String! } +""" +TreasuryVoteEvent entities are created for every emitted VoteCast/VoteCastWithParams event. +""" +type TreasuryVoteEvent implements Event @entity { + "Ethereum transaction hash + event log index" + id: ID! + "Reference to the transaction the event was included in" + transaction: Transaction! + "Timestamp of the transaction the event was included in" + timestamp: Int! + "Reference to the round the event occured in" + round: Round! + "Account that cast the vote" + voter: LivepeerAccount! + "Proposal that the vote was cast for" + proposal: TreasuryProposal! + "The voter's position" + support: TreasuryVoteSupport! + "Stake-weighted voting power" + weight: BigDecimal! + "Optional reason string provided by the voter" + reason: String +} + """ VoteEvent entities are created for every emitted Vote event. """ diff --git a/src/mappings/treasury.ts b/src/mappings/treasury.ts index 1e787c7..1f2b693 100644 --- a/src/mappings/treasury.ts +++ b/src/mappings/treasury.ts @@ -1,16 +1,147 @@ -import { TreasuryProposal } from "../types/schema"; -import { ProposalCreated } from "../types/Treasury/LivepeerGovernor"; +import { BigDecimal, BigInt, ethereum, log } from "@graphprotocol/graph-ts"; + +import { + convertToDecimal, + createOrLoadRound, + createOrLoadTransactionFromEvent, + createOrUpdateLivepeerAccount, + getBlockNum, + makeEventId, + ZERO_BD, +} from "../../utils/helpers"; +import { + TreasuryProposal, + TreasuryVote, + TreasuryVoteEvent, +} from "../types/schema"; +import { + ProposalCreated, + VoteCast, + VoteCastWithParams, +} from "../types/Treasury/LivepeerGovernor"; export function proposalCreated(event: ProposalCreated): void { const p = event.params; + const proposer = createOrUpdateLivepeerAccount( + p.proposer.toHex(), + event.block.timestamp.toI32() + ); const proposal = new TreasuryProposal(p.proposalId.toString()); - proposal.proposer = p.proposer.toHex(); + proposal.proposer = proposer.id; proposal.targets = p.targets.map((t) => t.toHex()); proposal.values = p.values; proposal.calldatas = p.calldatas; proposal.voteStart = p.voteStart; proposal.voteEnd = p.voteEnd; proposal.description = p.description; + proposal.forVotes = ZERO_BD; + proposal.againstVotes = ZERO_BD; + proposal.abstainVotes = ZERO_BD; + proposal.totalVotes = ZERO_BD; + proposal.save(); +} + +export function voteCast(event: VoteCast): void { + handleVote( + event, + event.params.proposalId, + event.params.voter.toHex(), + event.params.support, + event.params.weight, + event.params.reason + ); +} + +export function voteCastWithParams(event: VoteCastWithParams): void { + handleVote( + event, + event.params.proposalId, + event.params.voter.toHex(), + event.params.support, + event.params.weight, + event.params.reason + ); +} + +function handleVote( + event: ethereum.Event, + proposalId: BigInt, + voter: string, + support: i32, + weightRaw: BigInt, + reason: string +): void { + const proposal = TreasuryProposal.load(proposalId.toString()); + + if (!proposal) { + log.error("Treasury vote for unknown proposal {}", [proposalId.toString()]); + return; + } + + const supportLabelValue = supportFromValue(support); + if (supportLabelValue == null) { + return; + } + const supportLabel = supportLabelValue as string; + + const account = createOrUpdateLivepeerAccount( + voter, + event.block.timestamp.toI32() + ); + const round = createOrLoadRound(getBlockNum()); + const transaction = createOrLoadTransactionFromEvent(event); + const voteId = proposal.id.concat("-").concat(voter); + let vote = TreasuryVote.load(voteId); + const weight = convertToDecimal(weightRaw); + + if (!vote) { + vote = new TreasuryVote(voteId); + vote.proposal = proposal.id; + vote.voter = account.id; + } + + vote.support = supportLabel; + vote.weight = weight; + vote.reason = reason.length > 0 ? reason : null; + vote.save(); + + increaseProposalTotals(proposal, supportLabel, weight); + proposal.save(); + + const voteEvent = new TreasuryVoteEvent( + makeEventId(event.transaction.hash, event.logIndex) + ); + voteEvent.transaction = transaction.id; + voteEvent.timestamp = event.block.timestamp.toI32(); + voteEvent.round = round.id; + voteEvent.voter = account.id; + voteEvent.proposal = proposal.id; + voteEvent.support = supportLabel; + voteEvent.weight = weight; + voteEvent.reason = reason.length > 0 ? reason : null; + voteEvent.save(); +} + +function supportFromValue(value: i32): string | null { + if (value == 0) return "Against"; + if (value == 1) return "For"; + if (value == 2) return "Abstain"; + return null; +} + +function increaseProposalTotals( + proposal: TreasuryProposal, + support: string, + weight: BigDecimal +): void { + if (support == "For") { + proposal.forVotes = proposal.forVotes.plus(weight); + } else if (support == "Against") { + proposal.againstVotes = proposal.againstVotes.plus(weight); + } else { + proposal.abstainVotes = proposal.abstainVotes.plus(weight); + } + proposal.totalVotes = proposal.totalVotes.plus(weight); } diff --git a/subgraph.template.yaml b/subgraph.template.yaml index c41d2d3..cbc0c33 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -293,9 +293,17 @@ dataSources: entities: - TreasuryProposal - LivepeerAccount + - TreasuryVote + - TreasuryVoteEvent + - Transaction + - Round eventHandlers: - event: ProposalCreated(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,string) handler: proposalCreated + - event: VoteCast(indexed address,uint256,uint8,uint256,string) + handler: voteCast + - event: VoteCastWithParams(indexed address,uint256,uint8,uint256,string,bytes) + handler: voteCastWithParams - kind: ethereum/contract name: ServiceRegistry network: {{networkName}} From 5f5dc620fa9f19686580584e7af27603deb2d087 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 23 Dec 2025 22:14:26 +0100 Subject: [PATCH 2/4] fix: add missing Treasury mappings and ABIs Ensure the schema includes all required ABIs and entities referenced throughout the treasury mapping. --- src/mappings/treasury.ts | 12 ++++++------ subgraph.template.yaml | 7 +++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/mappings/treasury.ts b/src/mappings/treasury.ts index 1f2b693..fc6a76f 100644 --- a/src/mappings/treasury.ts +++ b/src/mappings/treasury.ts @@ -1,12 +1,12 @@ import { BigDecimal, BigInt, ethereum, log } from "@graphprotocol/graph-ts"; import { - convertToDecimal, - createOrLoadRound, - createOrLoadTransactionFromEvent, - createOrUpdateLivepeerAccount, - getBlockNum, - makeEventId, + convertToDecimal, + createOrLoadRound, + createOrLoadTransactionFromEvent, + createOrUpdateLivepeerAccount, + getBlockNum, + makeEventId, ZERO_BD, } from "../../utils/helpers"; import { diff --git a/subgraph.template.yaml b/subgraph.template.yaml index cbc0c33..4f9163e 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -290,13 +290,16 @@ dataSources: abis: - name: LivepeerGovernor file: ./abis/LivepeerGovernor.json + - name: RoundsManager + file: ./abis/RoundsManager.json entities: - TreasuryProposal - - LivepeerAccount - TreasuryVote - TreasuryVoteEvent - - Transaction + - LivepeerAccount - Round + - Protocol + - Transaction eventHandlers: - event: ProposalCreated(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,string) handler: proposalCreated From 2180a42d4038b242a7bf74a410577bfcbd809a1c Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 23 Dec 2025 22:19:43 +0100 Subject: [PATCH 3/4] fix: remove entity directive from enum Remove incorrect entity directive from the TreasuryVoteSupport enum. Co-authored-by: jmulq --- schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index 15887e8..6d90e49 100755 --- a/schema.graphql +++ b/schema.graphql @@ -386,7 +386,7 @@ type TreasuryProposal @entity { totalVotes: BigDecimal! } -enum TreasuryVoteSupport @entity { +enum TreasuryVoteSupport{ Against For Abstain From d39ea1a8c8f8205a2fd53e698887f802077b0368 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 23 Dec 2025 23:37:46 +0100 Subject: [PATCH 4/4] refactor: centralize treasury vote support strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize treasury support strings in a namespace. Graph entities store enums as strings and AS/codegen don’t expose string enums right now. Co-authored-by: jmulq --- src/mappings/treasury.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/mappings/treasury.ts b/src/mappings/treasury.ts index fc6a76f..4a539e6 100644 --- a/src/mappings/treasury.ts +++ b/src/mappings/treasury.ts @@ -1,12 +1,12 @@ import { BigDecimal, BigInt, ethereum, log } from "@graphprotocol/graph-ts"; import { - convertToDecimal, - createOrLoadRound, - createOrLoadTransactionFromEvent, - createOrUpdateLivepeerAccount, - getBlockNum, - makeEventId, + convertToDecimal, + createOrLoadRound, + createOrLoadTransactionFromEvent, + createOrUpdateLivepeerAccount, + getBlockNum, + makeEventId, ZERO_BD, } from "../../utils/helpers"; import { @@ -20,6 +20,13 @@ import { VoteCastWithParams, } from "../types/Treasury/LivepeerGovernor"; +// Workaround: Graph entities store enums as strings (no string enums in AS/codegen). +namespace TreasurySupport { + export const Against = "Against"; + export const For = "For"; + export const Abstain = "Abstain"; +} + export function proposalCreated(event: ProposalCreated): void { const p = event.params; const proposer = createOrUpdateLivepeerAccount( @@ -125,9 +132,9 @@ function handleVote( } function supportFromValue(value: i32): string | null { - if (value == 0) return "Against"; - if (value == 1) return "For"; - if (value == 2) return "Abstain"; + if (value == 0) return TreasurySupport.Against; + if (value == 1) return TreasurySupport.For; + if (value == 2) return TreasurySupport.Abstain; return null; } @@ -136,9 +143,9 @@ function increaseProposalTotals( support: string, weight: BigDecimal ): void { - if (support == "For") { + if (support == TreasurySupport.For) { proposal.forVotes = proposal.forVotes.plus(weight); - } else if (support == "Against") { + } else if (support == TreasurySupport.Against) { proposal.againstVotes = proposal.againstVotes.plus(weight); } else { proposal.abstainVotes = proposal.abstainVotes.plus(weight);