From 4f7484b33aea8507a126164fdab66a0772603535 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Sun, 8 Mar 2026 16:51:18 -0400 Subject: [PATCH 01/15] feat: add cumulative reward/fee factors and delegator shares for efficient historical stake computation Store cumulativeRewardFactor and cumulativeFeeFactor on Pool entity (matching on-chain PreciseMathUtils), propagate them forward each round, and introduce a shares field on Delegator that enables O(1) stake lookups via `stake = shares * crf[round] / 10^27`. DelegatorSnapshot entities capture state at bond/unbond/rebond events for time-series chart support. Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 26 ++++++++ src/mappings/bondingManager.ts | 109 +++++++++++++++++++++++++++++++++ src/mappings/roundsManager.ts | 2 + src/mappings/ticketBroker.ts | 34 +++++++++- utils/helpers.ts | 49 +++++++++++++++ 5 files changed, 219 insertions(+), 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index b6ac70d..04af384 100755 --- a/schema.graphql +++ b/schema.graphql @@ -135,6 +135,10 @@ type Pool @entity { rewardCut: BigInt! "Transcoder's fee share during the earnings pool's round" feeShare: BigInt! + "Cumulative reward factor for computing delegator rewards without looping (27-decimal fixed-point, matches on-chain PreciseMathUtils)" + cumulativeRewardFactor: BigInt! + "Cumulative fee factor for computing delegator fees without looping (27-decimal fixed-point, matches on-chain PreciseMathUtils)" + cumulativeFeeFactor: BigInt! } """ @@ -205,10 +209,32 @@ type Delegator @entity { withdrawnFees: BigDecimal! "Amount of Livepeer Token the delegator has delegated" delegatedAmount: BigDecimal! + "Proportional claim on the orchestrator's pool (bondedAmount * 10^27 / crf[lastClaimRound]). Invariant across claims, only changes on bond/unbond." + shares: BigInt! "Unbonding locks associated with the delegator" unbondingLocks: [UnbondingLock!] @derivedFrom(field: "delegator") } +""" +Snapshot of delegator state at each state-changing event, enabling historical stake and reward computation via cumulative factors +""" +type DelegatorSnapshot @entity { + "Unique identifier: delegator address + round number" + id: ID! + "The delegator this snapshot belongs to" + delegator: Delegator! + "The delegate (orchestrator) at the time of this snapshot, null if fully unbonded" + delegate: Transcoder + "Bonded amount at the time of this snapshot" + bondedAmount: BigDecimal! + "Proportional claim on the orchestrator's pool. stake = shares * crf[round] / 10^27" + shares: BigInt! + "Round when this snapshot was taken" + round: Round! + "Timestamp when this snapshot was taken" + timestamp: Int! +} + """ Abstraction for accounts/delegators bonded with the protocol """ diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 2a49346..935ba1b 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -1,5 +1,6 @@ import { store } from "@graphprotocol/graph-ts"; import { + convertFromDecimal, convertToDecimal, createOrLoadDelegator, createOrLoadPool, @@ -13,6 +14,9 @@ import { makeUnbondingLockId, MAXIMUM_VALUE_UINT256, ONE_BI, + percOf, + PRECISE_PERC_DIVISOR, + precisePercOf, ZERO_BI, } from "../../utils/helpers"; // Import event types from the registrar contract ABIs @@ -34,6 +38,7 @@ import { } from "../types/BondingManager/BondingManager"; import { BondEvent, + DelegatorSnapshot, EarningsClaimedEvent, ParameterUpdateEvent, RebondEvent, @@ -134,12 +139,39 @@ export function bond(event: Bond): void { convertToDecimal(event.params.additionalAmount) ); + // Compute shares: bondedAmount * 10^27 / crf[lastClaimRound] + // shares is invariant across claims, only changes on bond/unbond + let poolForShares = Pool.load( + makePoolId(event.params.newDelegate.toHex(), round.id) + ); + let sharesRefCRF = PRECISE_PERC_DIVISOR; + if ( + poolForShares && + !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) + ) { + sharesRefCRF = poolForShares.cumulativeRewardFactor; + } + delegator.shares = event.params.bondedAmount + .times(PRECISE_PERC_DIVISOR) + .div(sharesRefCRF); + round.save(); delegate.save(); delegator.save(); transcoder.save(); protocol.save(); + // Save delegator snapshot for historical stake/reward computation + let snapshotId = event.params.delegator.toHex() + "-" + round.id; + let snapshot = new DelegatorSnapshot(snapshotId); + snapshot.delegator = event.params.delegator.toHex(); + snapshot.delegate = event.params.newDelegate.toHex(); + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = round.id; + snapshot.timestamp = event.block.timestamp.toI32(); + snapshot.save(); + createOrLoadTransactionFromEvent(event); let bondEvent = new BondEvent( @@ -259,6 +291,25 @@ export function unbond(event: Unbond): void { convertToDecimal(event.params.amount) ); + // Compute shares from new bonded amount + if (delegatorData.value0.isZero()) { + delegator.shares = ZERO_BI; + } else { + let poolForShares = Pool.load( + makePoolId(event.params.delegate.toHex(), round.id) + ); + let sharesRefCRF = PRECISE_PERC_DIVISOR; + if ( + poolForShares && + !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) + ) { + sharesRefCRF = poolForShares.cumulativeRewardFactor; + } + delegator.shares = delegatorData.value0 + .times(PRECISE_PERC_DIVISOR) + .div(sharesRefCRF); + } + // Delegator no longer delegated to anyone if it does not have a bonded amount // so remove it from delegate if (delegatorData.value0.isZero()) { @@ -291,6 +342,17 @@ export function unbond(event: Unbond): void { protocol.save(); round.save(); + // Save delegator snapshot for historical stake/reward computation + let snapshotId = event.params.delegator.toHex() + "-" + round.id; + let snapshot = new DelegatorSnapshot(snapshotId); + snapshot.delegator = event.params.delegator.toHex(); + snapshot.delegate = delegator.delegate; + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = round.id; + snapshot.timestamp = event.block.timestamp.toI32(); + snapshot.save(); + createOrLoadTransactionFromEvent(event); let unbondEvent = new UnbondEvent( @@ -351,6 +413,21 @@ export function rebond(event: Rebond): void { delegator.bondedAmount = convertToDecimal(delegatorData.value0); delegator.fees = convertToDecimal(delegatorData.value1); + // Compute shares: bondedAmount * 10^27 / crf[lastClaimRound] + let poolForShares = Pool.load( + makePoolId(event.params.delegate.toHex(), round.id) + ); + let sharesRefCRF = PRECISE_PERC_DIVISOR; + if ( + poolForShares && + !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) + ) { + sharesRefCRF = poolForShares.cumulativeRewardFactor; + } + delegator.shares = delegatorData.value0 + .times(PRECISE_PERC_DIVISOR) + .div(sharesRefCRF); + // If the sender field for the lock is equal to the delegator's address then // we know that this is an unbonding lock the delegator created by calling // unbond() and if it is not then we know that this is an unbonding lock created @@ -371,6 +448,17 @@ export function rebond(event: Rebond): void { delegator.save(); protocol.save(); + // Save delegator snapshot for historical stake/reward computation + let snapshotId = event.params.delegator.toHex() + "-" + round.id; + let snapshot = new DelegatorSnapshot(snapshotId); + snapshot.delegator = event.params.delegator.toHex(); + snapshot.delegate = event.params.delegate.toHex(); + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = round.id; + snapshot.timestamp = event.block.timestamp.toI32(); + snapshot.save(); + if (unbondingLock) { store.remove("UnbondingLock", uniqueUnbondingLockId); } @@ -494,6 +582,27 @@ export function reward(event: Reward): void { ); transcoder.lastRewardRound = round.id; + // Compute cumulative reward factor (matches on-chain PreciseMathUtils) + // The pool's CRF was propagated from the previous round during pool creation, + // so it already contains the correct previous cumulative reward factor. + let prevCRF = pool.cumulativeRewardFactor; + if (prevCRF.equals(ZERO_BI)) { + prevCRF = PRECISE_PERC_DIVISOR; // default: 10^27 = percPoints(1,1) + } + + let totalRewardTokens = event.params.amount; // raw BigInt in wei + let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); + let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); + + let totalStakeBI = convertFromDecimal(pool.totalStake); + if (totalStakeBI.gt(ZERO_BI)) { + pool.cumulativeRewardFactor = prevCRF.plus( + precisePercOf(prevCRF, delegatorsRewards, totalStakeBI) + ); + } else { + pool.cumulativeRewardFactor = prevCRF; + } + pool.rewardTokens = convertToDecimal(event.params.amount); pool.feeShare = transcoder.feeShare; pool.rewardCut = transcoder.rewardCut; diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index c113a35..42fd359 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -13,11 +13,13 @@ import { getBondingManagerAddress, getLptPriceEth, getTimestampForDaysPast, + integerFromString, makeEventId, ONE_BD, ONE_BI, PERC_DIVISOR, ZERO_BD, + ZERO_BI, } from "../../utils/helpers"; import { BondingManager } from "../types/BondingManager/BondingManager"; // Import event types from the registrar contract ABIs diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index 3aa9adf..685fc0c 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -1,5 +1,6 @@ import { Address, BigInt, dataSource, log } from "@graphprotocol/graph-ts"; import { + convertFromDecimal, convertToDecimal, createOrLoadBroadcaster, createOrLoadBroadcasterDay, @@ -12,11 +13,19 @@ import { createOrLoadTranscoderDay, getBlockNum, getEthPriceUsd, + integerFromString, makeEventId, + makePoolId, + ONE_BI, + percOf, + PRECISE_PERC_DIVISOR, + precisePercOf, ZERO_BD, + ZERO_BI, } from "../../utils/helpers"; import { DepositFundedEvent, + Pool, ReserveClaimedEvent, ReserveFundedEvent, WinningTicketRedeemedEvent, @@ -118,8 +127,31 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { protocol.winningTicketCount = protocol.winningTicketCount + 1; protocol.save(); - // update the transcoder pool fees + // update the transcoder pool fees and cumulative fee factor let pool = createOrLoadPool(round.id, event.params.recipient.toHex()); + + // Compute cumulative fee factor (matches on-chain PreciseMathUtils) + // Use previous round's CRF, matching contract's latestCumulativeFactorsPool(_round - 1) + let prevRoundNum = integerFromString(round.id).minus(ONE_BI); + let prevPoolForFees = Pool.load( + makePoolId(event.params.recipient.toHex(), prevRoundNum.toString()) + ); + let prevCRF = PRECISE_PERC_DIVISOR; // default: 10^27 + if ( + prevPoolForFees && + !prevPoolForFees.cumulativeRewardFactor.equals(ZERO_BI) + ) { + prevCRF = prevPoolForFees.cumulativeRewardFactor; + } + + let delegatorsFees = percOf(event.params.faceValue, pool.feeShare); + let totalStakeBI = convertFromDecimal(pool.totalStake); + if (totalStakeBI.gt(ZERO_BI)) { + pool.cumulativeFeeFactor = pool.cumulativeFeeFactor.plus( + precisePercOf(prevCRF, delegatorsFees, totalStakeBI) + ); + } + pool.fees = pool.fees.plus(faceValue); pool.save(); diff --git a/utils/helpers.ts b/utils/helpers.ts index 556744d..97eb37a 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -61,6 +61,22 @@ export function createOrLoadPool(roundId: string, transcoderAddress: string): Po pool.round = roundId; pool.delegate = transcoderAddress; pool.fees = ZERO_BD; + + // Propagate cumulative factors from the previous round's pool so every + // pool has valid factors even if the transcoder misses reward() or has + // no fees in a round. This mirrors the contract's latestCumulativeFactorsPool. + let prevRoundNum = integerFromString(roundId).minus(ONE_BI); + let prevPool = Pool.load( + makePoolId(transcoderAddress, prevRoundNum.toString()) + ); + if (prevPool) { + pool.cumulativeRewardFactor = prevPool.cumulativeRewardFactor; + pool.cumulativeFeeFactor = prevPool.cumulativeFeeFactor; + } else { + pool.cumulativeRewardFactor = ZERO_BI; + pool.cumulativeFeeFactor = ZERO_BI; + } + let transcoder = Transcoder.load(transcoderAddress); if (transcoder) { @@ -124,6 +140,38 @@ export function percPoints(_fracNum: BigInt, _fracDenom: BigInt): BigInt { return _fracNum.times(BigInt.fromI32(PERC_DIVISOR)).div(_fracDenom); } +// PreciseMathUtils equivalents (matches Solidity's 27-decimal fixed-point arithmetic) +export let PRECISE_PERC_DIVISOR = BigInt.fromString( + "1000000000000000000000000000" +); // 10^27 + +export function precisePercPoints( + _fracNum: BigInt, + _fracDenom: BigInt +): BigInt { + return _fracNum.times(PRECISE_PERC_DIVISOR).div(_fracDenom); +} + +export function precisePercOf( + _baseAmount: BigInt, + _fracNum: BigInt, + _fracDenom: BigInt +): BigInt { + return _baseAmount + .times(precisePercPoints(_fracNum, _fracDenom)) + .div(PRECISE_PERC_DIVISOR); +} + +// Convert BigDecimal (in token units) back to raw BigInt (in wei) +export function convertFromDecimal(amount: BigDecimal): BigInt { + let str = amount.times(exponentToBigDecimal(BI_18)).toString(); + let dotIndex = str.indexOf("."); + if (dotIndex >= 0) { + str = str.substring(0, dotIndex); + } + return BigInt.fromString(str); +} + export function exponentToBigDecimal(decimals: BigInt): BigDecimal { let bd = BigDecimal.fromString("1"); for (let i = ZERO_BI; i.lt(decimals); i = i.plus(ONE_BI)) { @@ -287,6 +335,7 @@ export function createOrLoadDelegator(id: string, timestamp: i32): Delegator { delegator.fees = ZERO_BD; delegator.withdrawnFees = ZERO_BD; delegator.delegatedAmount = ZERO_BD; + delegator.shares = ZERO_BI; delegator.save(); } From f1dd7924b9cce1aad6a37dd87545e61136ae59eb Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Mon, 9 Mar 2026 12:04:23 -0400 Subject: [PATCH 02/15] feat: add cumulativeRewards on Transcoder for lifetime orchestrator commission Tracks the orchestrator's total rewardCut commission across all rounds, incremented each time reward() is called. Never resets, so clients can read lifetime earnings in a single field. Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 2 ++ src/mappings/bondingManager.ts | 5 +++++ utils/helpers.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/schema.graphql b/schema.graphql index 04af384..6a3f608 100755 --- a/schema.graphql +++ b/schema.graphql @@ -108,6 +108,8 @@ type Transcoder @entity { serviceURI: String "Days which the transcoder earned fees" transcoderDays: [TranscoderDay!]! + "Lifetime cumulative rewards (rewardCut commission) earned by this orchestrator in wei" + cumulativeRewards: BigInt! } enum TranscoderStatus @entity { diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 935ba1b..92e958e 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -11,6 +11,7 @@ import { EMPTY_ADDRESS, getBlockNum, makeEventId, + makePoolId, makeUnbondingLockId, MAXIMUM_VALUE_UINT256, ONE_BI, @@ -41,6 +42,7 @@ import { DelegatorSnapshot, EarningsClaimedEvent, ParameterUpdateEvent, + Pool, RebondEvent, RewardEvent, TranscoderActivatedEvent, @@ -594,6 +596,9 @@ export function reward(event: Reward): void { let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); + // Accumulate lifetime orchestrator commission + transcoder.cumulativeRewards = transcoder.cumulativeRewards.plus(transcoderCommission); + let totalStakeBI = convertFromDecimal(pool.totalStake); if (totalStakeBI.gt(ZERO_BI)) { pool.cumulativeRewardFactor = prevCRF.plus( diff --git a/utils/helpers.ts b/utils/helpers.ts index 97eb37a..043c78a 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -314,6 +314,7 @@ export function createOrLoadTranscoder(id: string, timestamp: i32): Transcoder { transcoder.sixtyDayVolumeETH = ZERO_BD; transcoder.ninetyDayVolumeETH = ZERO_BD; transcoder.transcoderDays = []; + transcoder.cumulativeRewards = ZERO_BI; transcoder.save(); } From 064d40f852cf1c04d2859c73d61f9ceedcce18fa Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Mon, 9 Mar 2026 12:14:35 -0400 Subject: [PATCH 03/15] fix: reset cumulativeRewards on claim so it represents unclaimed commission Resets Transcoder.cumulativeRewards to zero when the orchestrator claims earnings, so the field represents pending/unclaimed commission rather than lifetime total. This lets clients compute full orchestrator pending stake as: shares * crf / 10^27 + cumulativeRewards Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 2 +- src/mappings/bondingManager.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index 6a3f608..6d9e567 100755 --- a/schema.graphql +++ b/schema.graphql @@ -108,7 +108,7 @@ type Transcoder @entity { serviceURI: String "Days which the transcoder earned fees" transcoderDays: [TranscoderDay!]! - "Lifetime cumulative rewards (rewardCut commission) earned by this orchestrator in wei" + "Unclaimed orchestrator commission (rewardCut portion) in wei. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + cumulativeRewards" cumulativeRewards: BigInt! } diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 92e958e..7a50cd2 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -785,6 +785,16 @@ export function earningsClaimed(event: EarningsClaimed): void { delegator.fees = delegator.fees.plus(convertToDecimal(event.params.fees)); delegator.save(); + // Reset orchestrator's unclaimed commission when they claim + if (event.params.delegator.toHex() == event.params.delegate.toHex()) { + let transcoder = createOrLoadTranscoder( + event.params.delegator.toHex(), + event.block.timestamp.toI32() + ); + transcoder.cumulativeRewards = ZERO_BI; + transcoder.save(); + } + createOrLoadTransactionFromEvent(event); let earningsClaimedEvent = new EarningsClaimedEvent( From af4952d87f45eb318211f619ca2fd4d899e1212c Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Mon, 9 Mar 2026 12:19:37 -0400 Subject: [PATCH 04/15] feat: add lifetimeRewards on Transcoder for total commission earned Separate never-reset counter alongside cumulativeRewards (which resets on claim). Gives clients a single field for lifetime orchestrator commission without summing historical claim events. Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 2 ++ src/mappings/bondingManager.ts | 3 ++- utils/helpers.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index 6d9e567..7b728eb 100755 --- a/schema.graphql +++ b/schema.graphql @@ -110,6 +110,8 @@ type Transcoder @entity { transcoderDays: [TranscoderDay!]! "Unclaimed orchestrator commission (rewardCut portion) in wei. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + cumulativeRewards" cumulativeRewards: BigInt! + "Lifetime total orchestrator commission earned (rewardCut portion) in wei. Never resets." + lifetimeRewards: BigInt! } enum TranscoderStatus @entity { diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 7a50cd2..95067d7 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -596,8 +596,9 @@ export function reward(event: Reward): void { let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); - // Accumulate lifetime orchestrator commission + // Accumulate orchestrator commission transcoder.cumulativeRewards = transcoder.cumulativeRewards.plus(transcoderCommission); + transcoder.lifetimeRewards = transcoder.lifetimeRewards.plus(transcoderCommission); let totalStakeBI = convertFromDecimal(pool.totalStake); if (totalStakeBI.gt(ZERO_BI)) { diff --git a/utils/helpers.ts b/utils/helpers.ts index 043c78a..ef080f4 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -315,6 +315,7 @@ export function createOrLoadTranscoder(id: string, timestamp: i32): Transcoder { transcoder.ninetyDayVolumeETH = ZERO_BD; transcoder.transcoderDays = []; transcoder.cumulativeRewards = ZERO_BI; + transcoder.lifetimeRewards = ZERO_BI; transcoder.save(); } From 3926bccb974733783902e693f351ad84a2565de8 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Mon, 9 Mar 2026 12:31:22 -0400 Subject: [PATCH 05/15] feat: rename commission fields and add fee commission tracking Rename cumulativeRewards/lifetimeRewards to pendingRewardCommission/ lifetimeRewardCommission for clarity. Add pendingFeeCommission and lifetimeFeeCommission on Transcoder, computed in WinningTicketRedeemed handler. Both pending fields reset on claim. Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 12 ++++++++---- src/mappings/bondingManager.ts | 9 +++++---- src/mappings/ticketBroker.ts | 6 ++++++ utils/helpers.ts | 6 ++++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/schema.graphql b/schema.graphql index 7b728eb..02b05af 100755 --- a/schema.graphql +++ b/schema.graphql @@ -108,10 +108,14 @@ type Transcoder @entity { serviceURI: String "Days which the transcoder earned fees" transcoderDays: [TranscoderDay!]! - "Unclaimed orchestrator commission (rewardCut portion) in wei. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + cumulativeRewards" - cumulativeRewards: BigInt! - "Lifetime total orchestrator commission earned (rewardCut portion) in wei. Never resets." - lifetimeRewards: BigInt! + "Unclaimed orchestrator reward commission (rewardCut portion) in wei. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + pendingRewardCommission" + pendingRewardCommission: BigInt! + "Lifetime total orchestrator reward commission earned (rewardCut portion) in wei. Never resets." + lifetimeRewardCommission: BigInt! + "Unclaimed orchestrator fee commission in wei. Resets to zero on claim." + pendingFeeCommission: BigInt! + "Lifetime total orchestrator fee commission earned in wei. Never resets." + lifetimeFeeCommission: BigInt! } enum TranscoderStatus @entity { diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 95067d7..18e7918 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -596,9 +596,9 @@ export function reward(event: Reward): void { let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); - // Accumulate orchestrator commission - transcoder.cumulativeRewards = transcoder.cumulativeRewards.plus(transcoderCommission); - transcoder.lifetimeRewards = transcoder.lifetimeRewards.plus(transcoderCommission); + // Accumulate orchestrator reward commission + transcoder.pendingRewardCommission = transcoder.pendingRewardCommission.plus(transcoderCommission); + transcoder.lifetimeRewardCommission = transcoder.lifetimeRewardCommission.plus(transcoderCommission); let totalStakeBI = convertFromDecimal(pool.totalStake); if (totalStakeBI.gt(ZERO_BI)) { @@ -792,7 +792,8 @@ export function earningsClaimed(event: EarningsClaimed): void { event.params.delegator.toHex(), event.block.timestamp.toI32() ); - transcoder.cumulativeRewards = ZERO_BI; + transcoder.pendingRewardCommission = ZERO_BI; + transcoder.pendingFeeCommission = ZERO_BI; transcoder.save(); } diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index 685fc0c..710d15b 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -145,6 +145,12 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { } let delegatorsFees = percOf(event.params.faceValue, pool.feeShare); + let transcoderFeeCommission = event.params.faceValue.minus(delegatorsFees); + + // Accumulate orchestrator fee commission + transcoder.pendingFeeCommission = transcoder.pendingFeeCommission.plus(transcoderFeeCommission); + transcoder.lifetimeFeeCommission = transcoder.lifetimeFeeCommission.plus(transcoderFeeCommission); + let totalStakeBI = convertFromDecimal(pool.totalStake); if (totalStakeBI.gt(ZERO_BI)) { pool.cumulativeFeeFactor = pool.cumulativeFeeFactor.plus( diff --git a/utils/helpers.ts b/utils/helpers.ts index ef080f4..c1e10ae 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -314,8 +314,10 @@ export function createOrLoadTranscoder(id: string, timestamp: i32): Transcoder { transcoder.sixtyDayVolumeETH = ZERO_BD; transcoder.ninetyDayVolumeETH = ZERO_BD; transcoder.transcoderDays = []; - transcoder.cumulativeRewards = ZERO_BI; - transcoder.lifetimeRewards = ZERO_BI; + transcoder.pendingRewardCommission = ZERO_BI; + transcoder.lifetimeRewardCommission = ZERO_BI; + transcoder.pendingFeeCommission = ZERO_BI; + transcoder.lifetimeFeeCommission = ZERO_BI; transcoder.save(); } From dcc2755e404fe3aba787bead8e3256c5218718d4 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Tue, 10 Mar 2026 14:23:39 -0400 Subject: [PATCH 06/15] fix: match Solidity's PreciseMathUtils.percOf with single division The previous implementation did two divisions (num * 10^27 / denom, then base * result / 10^27), causing intermediate truncation that compounded each round in the CRF calculation. Solidity's version does one division (base * num / denom). While the CRF ratio error cancelled out for delegator stake, pendingRewardCommission accumulated ~0.23 LPT/round drift. This fix ensures exact match with the contract. Co-Authored-By: Claude Opus 4.6 --- utils/helpers.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/utils/helpers.ts b/utils/helpers.ts index c1e10ae..29e54ec 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -157,9 +157,7 @@ export function precisePercOf( _fracNum: BigInt, _fracDenom: BigInt ): BigInt { - return _baseAmount - .times(precisePercPoints(_fracNum, _fracDenom)) - .div(PRECISE_PERC_DIVISOR); + return _baseAmount.times(_fracNum).div(_fracDenom); } // Convert BigDecimal (in token units) back to raw BigInt (in wei) From 7b101df313a6d72bfc6a90ae4c94b2d60648e640 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Tue, 10 Mar 2026 14:45:05 -0400 Subject: [PATCH 07/15] fix: include transcoderRewardStakeRewards in pendingRewardCommission The contract's cumulativeRewards includes two components: the rewardCut commission plus rewards earned by the transcoder's own staked commission (activeCumulativeRewards). The subgraph was only tracking the first. Add activeCumulativeRewards field on Transcoder, snapshotted from pendingRewardCommission at the start of each round in newRound. The reward handler now computes both components matching the contract's updateTranscoderWithRewards logic. Reset on claim. Co-Authored-By: Claude Opus 4.6 --- schema.graphql | 6 ++++-- src/mappings/bondingManager.ts | 22 ++++++++++++++++++---- src/mappings/roundsManager.ts | 7 +++++++ utils/helpers.ts | 1 + 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/schema.graphql b/schema.graphql index 02b05af..54c9baf 100755 --- a/schema.graphql +++ b/schema.graphql @@ -108,10 +108,12 @@ type Transcoder @entity { serviceURI: String "Days which the transcoder earned fees" transcoderDays: [TranscoderDay!]! - "Unclaimed orchestrator reward commission (rewardCut portion) in wei. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + pendingRewardCommission" + "Unclaimed orchestrator reward commission in wei. Includes both rewardCut commission and rewards earned on staked commission. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + pendingRewardCommission" pendingRewardCommission: BigInt! - "Lifetime total orchestrator reward commission earned (rewardCut portion) in wei. Never resets." + "Lifetime total orchestrator reward commission earned in wei. Never resets." lifetimeRewardCommission: BigInt! + "Snapshot of pendingRewardCommission at the start of the current round. Used to compute the transcoder's share of delegator rewards earned by its own staked commission. Resets to zero on claim." + activeCumulativeRewards: BigInt! "Unclaimed orchestrator fee commission in wei. Resets to zero on claim." pendingFeeCommission: BigInt! "Lifetime total orchestrator fee commission earned in wei. Never resets." diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 18e7918..2769645 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -596,11 +596,24 @@ export function reward(event: Reward): void { let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); - // Accumulate orchestrator reward commission - transcoder.pendingRewardCommission = transcoder.pendingRewardCommission.plus(transcoderCommission); - transcoder.lifetimeRewardCommission = transcoder.lifetimeRewardCommission.plus(transcoderCommission); - + // Compute rewards earned by the transcoder's own staked commission let totalStakeBI = convertFromDecimal(pool.totalStake); + let transcoderRewardStakeRewards = ZERO_BI; + if (totalStakeBI.gt(ZERO_BI)) { + transcoderRewardStakeRewards = precisePercOf( + delegatorsRewards, + transcoder.activeCumulativeRewards, + totalStakeBI + ); + } + + // Accumulate orchestrator reward commission (rewardCut + rewards on staked commission) + transcoder.pendingRewardCommission = transcoder.pendingRewardCommission + .plus(transcoderCommission) + .plus(transcoderRewardStakeRewards); + transcoder.lifetimeRewardCommission = transcoder.lifetimeRewardCommission + .plus(transcoderCommission) + .plus(transcoderRewardStakeRewards); if (totalStakeBI.gt(ZERO_BI)) { pool.cumulativeRewardFactor = prevCRF.plus( precisePercOf(prevCRF, delegatorsRewards, totalStakeBI) @@ -794,6 +807,7 @@ export function earningsClaimed(event: EarningsClaimed): void { ); transcoder.pendingRewardCommission = ZERO_BI; transcoder.pendingFeeCommission = ZERO_BI; + transcoder.activeCumulativeRewards = ZERO_BI; transcoder.save(); } diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index 42fd359..e6982a7 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -128,6 +128,13 @@ export function newRound(event: NewRound): void { // given transcoder and round then we know the transcoder failed to call reward() createOrLoadPool(round.id, currentTranscoder.toHex()); + if (transcoder) { + // Snapshot pendingRewardCommission as activeCumulativeRewards for this round, + // mirroring the contract's setCurrentRoundTotalActiveStake snapshot + transcoder.activeCumulativeRewards = transcoder.pendingRewardCommission; + transcoder.save(); + } + currentTranscoder = bondingManager.getNextTranscoderInPool(currentTranscoder); diff --git a/utils/helpers.ts b/utils/helpers.ts index 29e54ec..b070012 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -316,6 +316,7 @@ export function createOrLoadTranscoder(id: string, timestamp: i32): Transcoder { transcoder.lifetimeRewardCommission = ZERO_BI; transcoder.pendingFeeCommission = ZERO_BI; transcoder.lifetimeFeeCommission = ZERO_BI; + transcoder.activeCumulativeRewards = ZERO_BI; transcoder.save(); } From f3774ac52b08347d6ef96567ac7a97cc1503d652 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Tue, 11 Aug 2026 15:52:54 -0400 Subject: [PATCH 08/15] fix: carry cumulative factors across round gaps in createOrLoadPool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-demand Pool creation added in #249 never seeded cumulativeRewardFactor /cumulativeFeeFactor, so a newly created Pool defaulted them to 0. When a Pool was created for a round whose previous round had no Pool (transcoder inactive, or newRound's non-deterministic enumeration skipped it), reward() saw prevCRF == 0, fell back to the base 10^27, and RESET the cumulative reward factor mid-history. That reset corrupts stake for any delegator whose lastClaimRound predates the gap: pendingStake = bonded * CRF[now] / CRF[lastClaim] straddles the break and under-reports. Observed on Arbitrum staging: an orchestrator's factor dropped to base at rounds 3178/3192/3215… (multi-round gaps where even the previous round's Pool was missing), under-reporting one delegator by ~36.6k LPT versus on-chain BondingManager.pendingStake. Fix: seed the new Pool's factors from the most recent EXISTING pool, walking back from round-1 to lastRewardRound (guaranteed to have a Pool with valid factors) — mirroring the contract's latestCumulativeFactorsPool. The prior one-round lookback was insufficient because the gaps span multiple rounds. Co-Authored-By: Claude Opus 4.8 --- utils/helpers.ts | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/utils/helpers.ts b/utils/helpers.ts index b070012..10df8ca 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -62,23 +62,44 @@ export function createOrLoadPool(roundId: string, transcoderAddress: string): Po pool.delegate = transcoderAddress; pool.fees = ZERO_BD; - // Propagate cumulative factors from the previous round's pool so every - // pool has valid factors even if the transcoder misses reward() or has - // no fees in a round. This mirrors the contract's latestCumulativeFactorsPool. - let prevRoundNum = integerFromString(roundId).minus(ONE_BI); - let prevPool = Pool.load( - makePoolId(transcoderAddress, prevRoundNum.toString()) - ); - if (prevPool) { - pool.cumulativeRewardFactor = prevPool.cumulativeRewardFactor; - pool.cumulativeFeeFactor = prevPool.cumulativeFeeFactor; + let transcoder = Transcoder.load(transcoderAddress); + + // Propagate cumulative factors from the most recent EXISTING pool so they + // never reset to base across a round gap. Normally that is the previous + // round's pool, but when the transcoder was inactive — or newRound's + // non-deterministic enumeration skipped it (see #249) — the previous + // round's pool can be missing for several rounds. Walk back to the most + // recent pool that exists, bounded below by lastRewardRound (which is + // guaranteed to have a pool with valid factors). This mirrors the + // contract's latestCumulativeFactorsPool. + // + // Only checking round-1 (the previous behavior) left the factors at 0 + // whenever that single pool was missing, and reward() then reset the + // cumulative reward factor to base (10^27) — corrupting the stake of every + // delegator whose last claim predated the gap. + let sourcePool: Pool | null = null; + if (transcoder != null && transcoder.lastRewardRound != null) { + let cursor = integerFromString(roundId).minus(ONE_BI); + let floor = integerFromString(transcoder.lastRewardRound!); + while (cursor.ge(floor)) { + let candidate = Pool.load( + makePoolId(transcoderAddress, cursor.toString()) + ); + if (candidate != null) { + sourcePool = candidate; + break; + } + cursor = cursor.minus(ONE_BI); + } + } + if (sourcePool != null) { + pool.cumulativeRewardFactor = sourcePool.cumulativeRewardFactor; + pool.cumulativeFeeFactor = sourcePool.cumulativeFeeFactor; } else { pool.cumulativeRewardFactor = ZERO_BI; pool.cumulativeFeeFactor = ZERO_BI; } - let transcoder = Transcoder.load(transcoderAddress); - if (transcoder) { pool.totalStake = transcoder.totalStake; pool.rewardCut = transcoder.rewardCut; From 5042005b8f52f472602a413a7557dfd615547c29 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 25 Sep 2026 19:59:52 -0400 Subject: [PATCH 09/15] fix: don't overwrite pool head's active flag in newRound The activeCumulativeRewards snapshot reused the pool-head Transcoder loaded before the pending (de)activation loops, so saving it restored the stale active flag. Load a fresh copy instead. Co-Authored-By: Claude Opus 5.5 --- src/mappings/roundsManager.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index e6982a7..40b9602 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -128,11 +128,16 @@ export function newRound(event: NewRound): void { // given transcoder and round then we know the transcoder failed to call reward() createOrLoadPool(round.id, currentTranscoder.toHex()); - if (transcoder) { - // Snapshot pendingRewardCommission as activeCumulativeRewards for this round, - // mirroring the contract's setCurrentRoundTotalActiveStake snapshot - transcoder.activeCumulativeRewards = transcoder.pendingRewardCommission; - transcoder.save(); + // Snapshot pendingRewardCommission as activeCumulativeRewards for this round, + // mirroring the contract's setCurrentRoundTotalActiveStake snapshot. + // Load a fresh copy rather than reusing `transcoder`: for the first + // transcoder in the pool that variable was loaded before the pending + // (de)activation loops above, so saving it would overwrite `active`. + let poolTranscoder = Transcoder.load(currentTranscoder.toHex()); + if (poolTranscoder) { + poolTranscoder.activeCumulativeRewards = + poolTranscoder.pendingRewardCommission; + poolTranscoder.save(); } currentTranscoder = From 2f98626cfd9c4c22ddbd820585d9bfd873df8572 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 25 Sep 2026 20:20:34 -0400 Subject: [PATCH 10/15] fix: share Delegator object on self-bond so delegatedAmount isn't overwritten In bond, unbond and rebond, a self-delegating orchestrator's Delegator was loaded twice (as delegate and delegator). delegate got the new delegatedAmount and was saved, then the stale delegator copy was saved over it. Reuse one object when the addresses match, including bond's oldDelegate when moving stake off a self-delegation. Co-Authored-By: Claude Opus 5.5 --- src/mappings/bondingManager.ts | 52 +++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index 2769645..c3e3a32 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -69,10 +69,15 @@ export function bond(event: Bond): void { event.params.newDelegate.toHex(), event.block.timestamp.toI32() ); - let delegator = createOrLoadDelegator( - event.params.delegator.toHex(), - event.block.timestamp.toI32() - ); + // When self-bonding, delegate and delegator are the same entity. Share one + // object so saving delegator doesn't overwrite delegate's delegatedAmount. + let delegator = + event.params.delegator.toHex() == event.params.newDelegate.toHex() + ? delegate + : createOrLoadDelegator( + event.params.delegator.toHex(), + event.block.timestamp.toI32() + ); let protocol = createOrLoadProtocol(); // If self delegating, set status and assign reference to self @@ -92,10 +97,15 @@ export function bond(event: Bond): void { event.params.oldDelegate.toHex(), event.block.timestamp.toI32() ); - let oldDelegate = createOrLoadDelegator( - event.params.oldDelegate.toHex(), - event.block.timestamp.toI32() - ); + // Moving stake off a self-delegation: oldDelegate is the delegator itself, + // so share the object for the same reason as above. + let oldDelegate = + event.params.oldDelegate.toHex() == event.params.delegator.toHex() + ? delegator + : createOrLoadDelegator( + event.params.oldDelegate.toHex(), + event.block.timestamp.toI32() + ); let oldDelegateData = bondingManager.getDelegator(event.params.oldDelegate); // if previous delegate was itself, set status and unassign reference to self @@ -271,10 +281,15 @@ export function unbond(event: Unbond): void { event.params.delegate.toHex(), event.block.timestamp.toI32() ); - let delegate = createOrLoadDelegator( - event.params.delegate.toHex(), - event.block.timestamp.toI32() - ); + // When unbonding self-stake, delegate and delegator are the same entity. + // Share one object so saving delegator doesn't overwrite delegatedAmount. + let delegate = + event.params.delegate.toHex() == event.params.delegator.toHex() + ? delegator + : createOrLoadDelegator( + event.params.delegate.toHex(), + event.block.timestamp.toI32() + ); let unbondingLock = UnbondingLock.load(uniqueUnbondingLockId); if (unbondingLock === null) { unbondingLock = new UnbondingLock(uniqueUnbondingLockId); @@ -383,10 +398,15 @@ export function rebond(event: Rebond): void { event.params.delegate.toHex(), event.block.timestamp.toI32() ); - let delegator = createOrLoadDelegator( - event.params.delegator.toHex(), - event.block.timestamp.toI32() - ); + // When rebonding to self, delegate and delegator are the same entity. Share + // one object so saving delegator doesn't overwrite delegatedAmount. + let delegator = + event.params.delegator.toHex() == event.params.delegate.toHex() + ? delegate + : createOrLoadDelegator( + event.params.delegator.toHex(), + event.block.timestamp.toI32() + ); let delegateData = bondingManager.getDelegator(event.params.delegate); let protocol = createOrLoadProtocol(); let uniqueUnbondingLockId = makeUnbondingLockId( From 6c3c5ce838fd952f6b56c787789c9fcce69fc438 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 25 Sep 2026 20:24:21 -0400 Subject: [PATCH 11/15] fix: address Copilot review on cumulative factors and shares - Add latestCumulativeRewardFactor, mirroring the contract's latestCumulativeFactorsPool, and use it for shares in bond, unbond and rebond and for the previous-round CRF in winningTicketRedeemed, instead of requiring the exact pool to exist. - Rebase Delegator.shares on EarningsClaimed so a self-claim's bonded commission is included. - Start a pool with no reward history at the 10^27 base reward factor. - Declare DelegatorSnapshot in the BondingManager data source. Co-Authored-By: Claude Opus 5.5 --- schema.graphql | 2 +- src/mappings/bondingManager.ts | 57 +++++++++++++--------------------- src/mappings/ticketBroker.ts | 16 +++------- subgraph.template.yaml | 1 + utils/helpers.ts | 33 +++++++++++++++++++- 5 files changed, 60 insertions(+), 49 deletions(-) diff --git a/schema.graphql b/schema.graphql index 54c9baf..b615ed2 100755 --- a/schema.graphql +++ b/schema.graphql @@ -219,7 +219,7 @@ type Delegator @entity { withdrawnFees: BigDecimal! "Amount of Livepeer Token the delegator has delegated" delegatedAmount: BigDecimal! - "Proportional claim on the orchestrator's pool (bondedAmount * 10^27 / crf[lastClaimRound]). Invariant across claims, only changes on bond/unbond." + "Proportional claim on the orchestrator's pool (bondedAmount * 10^27 / crf[lastClaimRound]). Recomputed on bond, unbond, rebond and earnings claims; unchanged by an ordinary claim, but a self-claim adds the orchestrator's bonded commission." shares: BigInt! "Unbonding locks associated with the delegator" unbondingLocks: [UnbondingLock!] @derivedFrom(field: "delegator") diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index c3e3a32..fa7d8a4 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -10,8 +10,8 @@ import { createOrLoadTranscoder, EMPTY_ADDRESS, getBlockNum, + latestCumulativeRewardFactor, makeEventId, - makePoolId, makeUnbondingLockId, MAXIMUM_VALUE_UINT256, ONE_BI, @@ -42,7 +42,6 @@ import { DelegatorSnapshot, EarningsClaimedEvent, ParameterUpdateEvent, - Pool, RebondEvent, RewardEvent, TranscoderActivatedEvent, @@ -152,20 +151,11 @@ export function bond(event: Bond): void { ); // Compute shares: bondedAmount * 10^27 / crf[lastClaimRound] - // shares is invariant across claims, only changes on bond/unbond - let poolForShares = Pool.load( - makePoolId(event.params.newDelegate.toHex(), round.id) - ); - let sharesRefCRF = PRECISE_PERC_DIVISOR; - if ( - poolForShares && - !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) - ) { - sharesRefCRF = poolForShares.cumulativeRewardFactor; - } delegator.shares = event.params.bondedAmount .times(PRECISE_PERC_DIVISOR) - .div(sharesRefCRF); + .div( + latestCumulativeRewardFactor(event.params.newDelegate.toHex(), round.id) + ); round.save(); delegate.save(); @@ -312,19 +302,11 @@ export function unbond(event: Unbond): void { if (delegatorData.value0.isZero()) { delegator.shares = ZERO_BI; } else { - let poolForShares = Pool.load( - makePoolId(event.params.delegate.toHex(), round.id) - ); - let sharesRefCRF = PRECISE_PERC_DIVISOR; - if ( - poolForShares && - !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) - ) { - sharesRefCRF = poolForShares.cumulativeRewardFactor; - } delegator.shares = delegatorData.value0 .times(PRECISE_PERC_DIVISOR) - .div(sharesRefCRF); + .div( + latestCumulativeRewardFactor(event.params.delegate.toHex(), round.id) + ); } // Delegator no longer delegated to anyone if it does not have a bonded amount @@ -436,19 +418,9 @@ export function rebond(event: Rebond): void { delegator.fees = convertToDecimal(delegatorData.value1); // Compute shares: bondedAmount * 10^27 / crf[lastClaimRound] - let poolForShares = Pool.load( - makePoolId(event.params.delegate.toHex(), round.id) - ); - let sharesRefCRF = PRECISE_PERC_DIVISOR; - if ( - poolForShares && - !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) - ) { - sharesRefCRF = poolForShares.cumulativeRewardFactor; - } delegator.shares = delegatorData.value0 .times(PRECISE_PERC_DIVISOR) - .div(sharesRefCRF); + .div(latestCumulativeRewardFactor(event.params.delegate.toHex(), round.id)); // If the sender field for the lock is equal to the delegator's address then // we know that this is an unbonding lock the delegator created by calling @@ -817,6 +789,19 @@ export function earningsClaimed(event: EarningsClaimed): void { convertToDecimal(event.params.rewards) ); delegator.fees = delegator.fees.plus(convertToDecimal(event.params.fees)); + // Rebase shares on the new lastClaimRound. For an ordinary delegator this + // leaves shares unchanged (rewards grow with the CRF), but a self-claim also + // bonds the orchestrator's commission, which the old shares don't include. + if (event.params.delegate.toHex() != EMPTY_ADDRESS.toHex()) { + delegator.shares = convertFromDecimal(delegator.bondedAmount) + .times(PRECISE_PERC_DIVISOR) + .div( + latestCumulativeRewardFactor( + event.params.delegate.toHex(), + event.params.endRound.toString() + ) + ); + } delegator.save(); // Reset orchestrator's unclaimed commission when they claim diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index 710d15b..ba3176b 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -14,8 +14,8 @@ import { getBlockNum, getEthPriceUsd, integerFromString, + latestCumulativeRewardFactor, makeEventId, - makePoolId, ONE_BI, percOf, PRECISE_PERC_DIVISOR, @@ -25,7 +25,6 @@ import { } from "../../utils/helpers"; import { DepositFundedEvent, - Pool, ReserveClaimedEvent, ReserveFundedEvent, WinningTicketRedeemedEvent, @@ -132,17 +131,12 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { // Compute cumulative fee factor (matches on-chain PreciseMathUtils) // Use previous round's CRF, matching contract's latestCumulativeFactorsPool(_round - 1) + // (falls back to lastRewardRound's pool when round - 1 has none) let prevRoundNum = integerFromString(round.id).minus(ONE_BI); - let prevPoolForFees = Pool.load( - makePoolId(event.params.recipient.toHex(), prevRoundNum.toString()) + let prevCRF = latestCumulativeRewardFactor( + event.params.recipient.toHex(), + prevRoundNum.toString() ); - let prevCRF = PRECISE_PERC_DIVISOR; // default: 10^27 - if ( - prevPoolForFees && - !prevPoolForFees.cumulativeRewardFactor.equals(ZERO_BI) - ) { - prevCRF = prevPoolForFees.cumulativeRewardFactor; - } let delegatorsFees = percOf(event.params.faceValue, pool.feeShare); let transcoderFeeCommission = event.params.faceValue.minus(delegatorsFees); diff --git a/subgraph.template.yaml b/subgraph.template.yaml index 4f9163e..df8feeb 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -25,6 +25,7 @@ dataSources: - BondEvent - TransferBondEvent - Delegator + - DelegatorSnapshot - LivepeerAccount - EarningsClaimedEvent - ParameterUpdateEvent diff --git a/utils/helpers.ts b/utils/helpers.ts index 10df8ca..c42dc6a 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -96,7 +96,10 @@ export function createOrLoadPool(roundId: string, transcoderAddress: string): Po pool.cumulativeRewardFactor = sourcePool.cumulativeRewardFactor; pool.cumulativeFeeFactor = sourcePool.cumulativeFeeFactor; } else { - pool.cumulativeRewardFactor = ZERO_BI; + // No reward history: start the reward factor at the 10^27 base the + // contract implies for an uninitialized pool, so shares * crf / 10^27 + // gives the right stake. The fee factor genuinely starts at zero. + pool.cumulativeRewardFactor = PRECISE_PERC_DIVISOR; pool.cumulativeFeeFactor = ZERO_BI; } @@ -116,6 +119,34 @@ export function createOrLoadPool(roundId: string, transcoderAddress: string): Po return pool; } +// Cumulative reward factor for a transcoder as of a round, mirroring the +// contract's latestCumulativeFactorsPool: use the round's pool if it has a +// factor, otherwise fall back to the lastRewardRound pool when that is +// earlier, and default to the 10^27 base when neither exists. +export function latestCumulativeRewardFactor( + transcoderAddress: string, + roundId: string +): BigInt { + let pool = Pool.load(makePoolId(transcoderAddress, roundId)); + if (pool != null && !pool.cumulativeRewardFactor.equals(ZERO_BI)) { + return pool.cumulativeRewardFactor; + } + let transcoder = Transcoder.load(transcoderAddress); + if (transcoder != null && transcoder.lastRewardRound != null) { + let lastRewardRound = transcoder.lastRewardRound!; + if (integerFromString(lastRewardRound).lt(integerFromString(roundId))) { + let rewardPool = Pool.load(makePoolId(transcoderAddress, lastRewardRound)); + if ( + rewardPool != null && + !rewardPool.cumulativeRewardFactor.equals(ZERO_BI) + ) { + return rewardPool.cumulativeRewardFactor; + } + } + } + return PRECISE_PERC_DIVISOR; +} + // Make a derived share ID from a delegator address export function makeShareId(delegatorAddress: string, roundId: string): string { return leftPad(roundId, 10) + "-" + delegatorAddress; From 0e798d1e47bb51a8822b2316985145cfa3eb22a9 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 25 Sep 2026 20:29:38 -0400 Subject: [PATCH 12/15] fix: credit fees on staked commission and read fee factor on-chain - Add transcoderRewardStakeFees to pendingFeeCommission, mirroring the contract's updateTranscoderWithFees. - Set activeCumulativeRewards in reward() and keep it across claims, as the contract does, instead of snapshotting it in newRound and clearing it on claim. This removes the newRound Transcoder save entirely. - Read the round's earnings pool via getTranscoderEarningsPoolForRound so cumulativeFeeFactor, totalStake and feeShare match the contract exactly, including its retroactive CRF estimate; keep the local computation as a fallback if the call reverts. Co-Authored-By: Claude Opus 5.5 --- schema.graphql | 4 +- src/mappings/bondingManager.ts | 7 +++- src/mappings/roundsManager.ts | 14 ------- src/mappings/ticketBroker.ts | 68 +++++++++++++++++++++++++++------- subgraph.template.yaml | 2 + 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/schema.graphql b/schema.graphql index b615ed2..bac8829 100755 --- a/schema.graphql +++ b/schema.graphql @@ -112,9 +112,9 @@ type Transcoder @entity { pendingRewardCommission: BigInt! "Lifetime total orchestrator reward commission earned in wei. Never resets." lifetimeRewardCommission: BigInt! - "Snapshot of pendingRewardCommission at the start of the current round. Used to compute the transcoder's share of delegator rewards earned by its own staked commission. Resets to zero on claim." + "Snapshot of pendingRewardCommission taken when the transcoder calls reward(), matching the contract. Used to compute the transcoder's share of delegator rewards and fees earned by its own staked commission. Not cleared on claim; the next reward() replaces it." activeCumulativeRewards: BigInt! - "Unclaimed orchestrator fee commission in wei. Resets to zero on claim." + "Unclaimed orchestrator fee commission in wei. Includes both feeShare commission and fees earned on staked commission. Resets to zero on claim." pendingFeeCommission: BigInt! "Lifetime total orchestrator fee commission earned in wei. Never resets." lifetimeFeeCommission: BigInt! diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index fa7d8a4..ff1e48f 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -588,6 +588,10 @@ export function reward(event: Reward): void { let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); + // Snapshot the commission that is active this round, as the contract does + // at the start of updateTranscoderWithRewards + transcoder.activeCumulativeRewards = transcoder.pendingRewardCommission; + // Compute rewards earned by the transcoder's own staked commission let totalStakeBI = convertFromDecimal(pool.totalStake); let transcoderRewardStakeRewards = ZERO_BI; @@ -812,7 +816,8 @@ export function earningsClaimed(event: EarningsClaimed): void { ); transcoder.pendingRewardCommission = ZERO_BI; transcoder.pendingFeeCommission = ZERO_BI; - transcoder.activeCumulativeRewards = ZERO_BI; + // activeCumulativeRewards is not cleared, matching the contract: fees + // later this round still use it, and the next reward() resets it transcoder.save(); } diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index 40b9602..c113a35 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -13,13 +13,11 @@ import { getBondingManagerAddress, getLptPriceEth, getTimestampForDaysPast, - integerFromString, makeEventId, ONE_BD, ONE_BI, PERC_DIVISOR, ZERO_BD, - ZERO_BI, } from "../../utils/helpers"; import { BondingManager } from "../types/BondingManager/BondingManager"; // Import event types from the registrar contract ABIs @@ -128,18 +126,6 @@ export function newRound(event: NewRound): void { // given transcoder and round then we know the transcoder failed to call reward() createOrLoadPool(round.id, currentTranscoder.toHex()); - // Snapshot pendingRewardCommission as activeCumulativeRewards for this round, - // mirroring the contract's setCurrentRoundTotalActiveStake snapshot. - // Load a fresh copy rather than reusing `transcoder`: for the first - // transcoder in the pool that variable was loaded before the pending - // (de)activation loops above, so saving it would overwrite `active`. - let poolTranscoder = Transcoder.load(currentTranscoder.toHex()); - if (poolTranscoder) { - poolTranscoder.activeCumulativeRewards = - poolTranscoder.pendingRewardCommission; - poolTranscoder.save(); - } - currentTranscoder = bondingManager.getNextTranscoderInPool(currentTranscoder); diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index ba3176b..64c47d3 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -12,13 +12,13 @@ import { createOrLoadTranscoder, createOrLoadTranscoderDay, getBlockNum, + getBondingManagerAddress, getEthPriceUsd, integerFromString, latestCumulativeRewardFactor, makeEventId, ONE_BI, percOf, - PRECISE_PERC_DIVISOR, precisePercOf, ZERO_BD, ZERO_BI, @@ -37,6 +37,7 @@ import { WinningTicketRedeemed, Withdrawal, } from "../types/TicketBroker/TicketBroker"; +import { BondingManager } from "../types/TicketBroker/BondingManager"; export function winningTicketRedeemed(event: WinningTicketRedeemed): void { let round = createOrLoadRound(getBlockNum()); @@ -129,24 +130,63 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { // update the transcoder pool fees and cumulative fee factor let pool = createOrLoadPool(round.id, event.params.recipient.toHex()); - // Compute cumulative fee factor (matches on-chain PreciseMathUtils) - // Use previous round's CRF, matching contract's latestCumulativeFactorsPool(_round - 1) - // (falls back to lastRewardRound's pool when round - 1 has none) - let prevRoundNum = integerFromString(round.id).minus(ONE_BI); - let prevCRF = latestCumulativeRewardFactor( - event.params.recipient.toHex(), - prevRoundNum.toString() + // Read the round's earnings pool from the contract, which has already + // applied these fees. Its cumulativeFeeFactor is exact, including the + // contract's retroactive CRF estimate when round - 1 missed reward(). + let bondingManager = BondingManager.bind( + Address.fromString(getBondingManagerAddress()) + ); + let onChainPool = bondingManager.try_getTranscoderEarningsPoolForRound( + event.params.recipient, + integerFromString(round.id) ); - let delegatorsFees = percOf(event.params.faceValue, pool.feeShare); - let transcoderFeeCommission = event.params.faceValue.minus(delegatorsFees); + let totalStakeBI = convertFromDecimal(pool.totalStake); + let feeShare = pool.feeShare; + if (!onChainPool.reverted) { + totalStakeBI = onChainPool.value.value0; + feeShare = onChainPool.value.value2; + } - // Accumulate orchestrator fee commission - transcoder.pendingFeeCommission = transcoder.pendingFeeCommission.plus(transcoderFeeCommission); - transcoder.lifetimeFeeCommission = transcoder.lifetimeFeeCommission.plus(transcoderFeeCommission); + let delegatorsFees = percOf(event.params.faceValue, feeShare); + let transcoderFeeCommission = event.params.faceValue.minus(delegatorsFees); - let totalStakeBI = convertFromDecimal(pool.totalStake); + // Fees earned by the transcoder's own staked commission, mirroring the + // contract's transcoderRewardStakeFees. Until reward() is called this round + // the contract uses cumulativeRewards (pendingRewardCommission here). + let rewardCalledThisRound = + transcoder.lastRewardRound != null && + transcoder.lastRewardRound! == round.id; + let activeCumulativeRewards = rewardCalledThisRound + ? transcoder.activeCumulativeRewards + : transcoder.pendingRewardCommission; + let transcoderRewardStakeFees = ZERO_BI; if (totalStakeBI.gt(ZERO_BI)) { + transcoderRewardStakeFees = precisePercOf( + delegatorsFees, + activeCumulativeRewards, + totalStakeBI + ); + } + + // Accumulate orchestrator fee commission (feeShare + fees on staked commission) + transcoder.pendingFeeCommission = transcoder.pendingFeeCommission + .plus(transcoderFeeCommission) + .plus(transcoderRewardStakeFees); + transcoder.lifetimeFeeCommission = transcoder.lifetimeFeeCommission + .plus(transcoderFeeCommission) + .plus(transcoderRewardStakeFees); + + if (!onChainPool.reverted) { + pool.cumulativeFeeFactor = onChainPool.value.value4; + } else if (totalStakeBI.gt(ZERO_BI)) { + // Fallback: compute it as the contract does, from the previous round's + // CRF (latestCumulativeFactorsPool(_round - 1)). + let prevRoundNum = integerFromString(round.id).minus(ONE_BI); + let prevCRF = latestCumulativeRewardFactor( + event.params.recipient.toHex(), + prevRoundNum.toString() + ); pool.cumulativeFeeFactor = pool.cumulativeFeeFactor.plus( precisePercOf(prevCRF, delegatorsFees, totalStakeBI) ); diff --git a/subgraph.template.yaml b/subgraph.template.yaml index df8feeb..9907b1a 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -199,6 +199,8 @@ dataSources: abis: - name: TicketBroker file: ./abis/TicketBroker.json + - name: BondingManager + file: ./abis/BondingManager.json - name: UniswapV3Pool file: ./abis/UniswapV3Pool.json - name: RoundsManager From bf4e850b4a295e46c5f41e5036fa0a5ff8566124 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 25 Sep 2026 20:34:20 -0400 Subject: [PATCH 13/15] fix: rebase shares only on self-claims; declare TicketBroker entities - Recompute Delegator.shares on EarningsClaimed only for a self-claim, so integer division can't drift shares down on ordinary claims. - Declare Pool, TranscoderDay and BroadcasterDay in the TicketBroker data source, which writes them. Co-Authored-By: Claude Opus 5.5 --- src/mappings/bondingManager.ts | 9 +++++---- subgraph.template.yaml | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index ff1e48f..cadd45d 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -793,10 +793,11 @@ export function earningsClaimed(event: EarningsClaimed): void { convertToDecimal(event.params.rewards) ); delegator.fees = delegator.fees.plus(convertToDecimal(event.params.fees)); - // Rebase shares on the new lastClaimRound. For an ordinary delegator this - // leaves shares unchanged (rewards grow with the CRF), but a self-claim also - // bonds the orchestrator's commission, which the old shares don't include. - if (event.params.delegate.toHex() != EMPTY_ADDRESS.toHex()) { + // A self-claim also bonds the orchestrator's commission, which the old + // shares don't include, so rebase them on the new lastClaimRound. An + // ordinary claim leaves shares unchanged (rewards grow with the CRF), so + // skip it there rather than let integer division drift them down. + if (event.params.delegator.toHex() == event.params.delegate.toHex()) { delegator.shares = convertFromDecimal(delegator.bondedAmount) .times(PRECISE_PERC_DIVISOR) .div( diff --git a/subgraph.template.yaml b/subgraph.template.yaml index 9907b1a..bb9a78b 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -211,7 +211,10 @@ dataSources: - Protocol - Transcoder - Broadcaster + - BroadcasterDay + - Pool - Round + - TranscoderDay - WinningTicketRedeemedEvent - ReserveFundedEvent - ReserveClaimedEvent From bd43e99a63e1c340dec132687f86db63eaeb15be Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 16 Oct 2026 20:56:12 -0400 Subject: [PATCH 14/15] fix: ignore an uninitialized on-chain earnings pool in winningTicketRedeemed A ticket that transfers nothing still emits WinningTicketRedeemed but skips updateTranscoderWithFees, so getTranscoderEarningsPoolForRound can return an all-zero pool. Only use its totalStake/feeShare when stake is set, and its cumulativeFeeFactor when nonzero; otherwise keep the propagated values. Co-Authored-By: Claude Opus 5.5 --- src/mappings/ticketBroker.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index 64c47d3..3ede1fc 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -143,7 +143,11 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { let totalStakeBI = convertFromDecimal(pool.totalStake); let feeShare = pool.feeShare; - if (!onChainPool.reverted) { + // The pool is only initialized once the contract has credited fees or + // stake for this round. A ticket that transferred nothing (sender's deposit + // and reserve exhausted) still emits WinningTicketRedeemed but skips + // updateTranscoderWithFees, leaving an all-zero pool, so ignore zeros. + if (!onChainPool.reverted && onChainPool.value.value0.gt(ZERO_BI)) { totalStakeBI = onChainPool.value.value0; feeShare = onChainPool.value.value2; } @@ -177,7 +181,7 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { .plus(transcoderFeeCommission) .plus(transcoderRewardStakeFees); - if (!onChainPool.reverted) { + if (!onChainPool.reverted && onChainPool.value.value4.gt(ZERO_BI)) { pool.cumulativeFeeFactor = onChainPool.value.value4; } else if (totalStakeBI.gt(ZERO_BI)) { // Fallback: compute it as the contract does, from the previous round's From 6ef9b25ca74697e2cd08c7bdef85bc74f8313c71 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Fri, 16 Oct 2026 21:08:20 -0400 Subject: [PATCH 15/15] fix: use current rewardCut on reward and persist on-chain pool values - reward: set the pool's rewardCut/feeShare from the transcoder before computing commission, as rewardWithHint's setCommission does, so a mid-round TranscoderUpdate applies to that round's rewards. - winningTicketRedeemed: persist the initialized on-chain totalStake, rewardCut and feeShare to the Pool. Co-Authored-By: Claude Opus 5.5 --- src/mappings/bondingManager.ts | 8 ++++++-- src/mappings/ticketBroker.ts | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index cadd45d..ba6feb4 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -584,6 +584,12 @@ export function reward(event: Reward): void { prevCRF = PRECISE_PERC_DIVISOR; // default: 10^27 = percPoints(1,1) } + // The contract sets the pool's commission to the transcoder's current + // rewardCut/feeShare when reward() runs (rewardWithHint's setCommission), + // so a mid-round TranscoderUpdate applies to this round's rewards + pool.feeShare = transcoder.feeShare; + pool.rewardCut = transcoder.rewardCut; + let totalRewardTokens = event.params.amount; // raw BigInt in wei let transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); @@ -619,8 +625,6 @@ export function reward(event: Reward): void { } pool.rewardTokens = convertToDecimal(event.params.amount); - pool.feeShare = transcoder.feeShare; - pool.rewardCut = transcoder.rewardCut; transcoder.save(); delegate.save(); diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index 3ede1fc..05072d1 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -150,6 +150,10 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void { if (!onChainPool.reverted && onChainPool.value.value0.gt(ZERO_BI)) { totalStakeBI = onChainPool.value.value0; feeShare = onChainPool.value.value2; + // Persist the contract's values so later handlers (e.g. reward) see them + pool.totalStake = convertToDecimal(totalStakeBI); + pool.rewardCut = onChainPool.value.value1; + pool.feeShare = feeShare; } let delegatorsFees = percOf(event.params.faceValue, feeShare);