You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: cumulative factors and delegator shares for O(1) stake computation - #217
The subgraph has no way to compute a delegator's actual total stake. The bondedAmount field only reflects the value at the time of the last claim and does not include rewards earned since then. To get a delegator's current stake, clients must call the contract's pendingStake() method directly — and for historical stake at a past round, there is no solution at all.
Similarly, computing an orchestrator's full pending stake (including their unclaimed commission) or their lifetime earnings requires fetching every pool since their last claim and summing across all of them.
This also means there's no way to build time-series charts of delegator stake, show per-round reward breakdowns, or compute orchestrator yield — all of which require knowing stake values across rounds.
Summary
Store cumulativeRewardFactor and cumulativeFeeFactor on the Pool entity, matching on-chain PreciseMathUtils (27-decimal fixed-point)
Propagate cumulative factors forward each round during pool creation so every pool has valid values (no gaps for missed reward() calls)
Add shares field to Delegator (bondedAmount * 10^27 / crf[lastClaimRound]) — invariant across claims, only changes on bond/unbond/rebond
Add DelegatorSnapshot entity capturing delegator state at each bond/unbond/rebond for time-series chart support
Compute cumulative fee factor in WinningTicketRedeemed handler using previous round's CRF (matching contract behavior)
Add orchestrator commission tracking on Transcoder:
pendingRewardCommission — unclaimed reward commission (rewardCut portion), resets on claim
lifetimeRewardCommission — total reward commission ever earned, never resets
pendingFeeCommission — unclaimed fee commission, resets on claim
lifetimeFeeCommission — total fee commission ever earned, never resets
What this unlocks
Current pending stake without contract calls — A delegator's up-to-date total stake (equivalent to pendingStake() on-chain) can now be computed entirely from the subgraph: stake = shares * crf / 10^27. For orchestrators specifically, their full pending stake includes unclaimed commission: stake = shares * crf / 10^27 + pendingRewardCommission. No contract calls needed.
Historical stake at any past round — Something previously impossible. Query the delegator's shares (or the relevant DelegatorSnapshot) and the Pool.cumulativeRewardFactor for that round, then compute shares * crf[round] / 10^27.
Per-round reward and fee breakdowns — Rewards earned in a specific round can be derived by comparing cumulative factors between consecutive rounds: rewards = shares * (crf[round] - crf[round-1]) / 10^27. Same pattern applies to fees using cumulativeFeeFactor.
Time-series charts of delegator stake — The shares approach combined with DelegatorSnapshot entities makes it straightforward to build a chart showing a delegator's total stake over time. Between snapshots, shares are constant, so stake at any round is just shares * crf[round] / 10^27. When a bond/unbond/rebond occurs, a new snapshot captures the updated shares value to use going forward.
Live example:The new Livepeer Explorer (livepeer/explorer#828) is built on this data. Its portfolio charts delegated stake, rewards and network share per round across several wallets, and its orchestrator pages show realised yield, all computed from the per-round cumulativeRewardFactor values this PR adds rather than from contract calls.
Orchestrator commission tracking — pendingRewardCommission and pendingFeeCommission give the orchestrator's unclaimed commission in a single field each, without needing to sum across pools. lifetimeRewardCommission and lifetimeFeeCommission provide total lifetime earnings.
Orchestrator pool analytics — Cumulative factors stored per-pool per-round enable computing total rewards distributed, effective yield, and fee revenue for any orchestrator over any time range without replaying events.
All changes are additive — no breaking changes to existing queries.
The PR adds cumulative reward and fee factors, precise accounting helpers, delegator share tracking with historical snapshots, and transcoder commission fields. Bonding, reward, round, earnings-claim, and ticket-redemption mappings now update these values.
Changes
Accounting factors and commission tracking
Layer / File(s)
Summary
Accounting schema and precise math schema.graphql, utils/helpers.ts
Adds pool factors, delegator shares and snapshots, transcoder commission fields, precise percentage helpers, and initialization logic.
Delegator shares and snapshots src/mappings/bondingManager.ts
Bonding, unbonding, and rebonding calculate shares from pool factors and persist DelegatorSnapshot records.
…cient 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 <noreply@anthropic.com>
…ommission
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 <noreply@anthropic.com>
…ission
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Duplicated shares-from-CRF computation across bond/unbond/rebond.
The same ~10-line "load pool → fallback to PRECISE_PERC_DIVISOR → compute shares" block is repeated verbatim in bond(), unbond(), and rebond(). Extracting it into a shared helper in utils/helpers.ts (e.g. computeShares(delegateAddress, roundId, bondedAmount)) would remove the duplication and prevent the three copies from silently diverging if the fallback/edge-case logic ever needs to change (e.g. together with the CRF-continuity fix noted in utils/helpers.ts).
Also applies to: 296-313, 418-431
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/mappings/bondingManager.ts` around lines 144 - 159, Extract the repeated
pool-loading, CRF fallback, and shares calculation from bond(), unbond(), and
rebond() into a shared helper in utils/helpers.ts, such as
computeShares(delegateAddress, roundId, bondedAmount). Update all three call
sites to use the helper while preserving the existing PRECISE_PERC_DIVISOR
fallback and cumulativeRewardFactor handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@schema.graphql`:
- Around line 231-246: Update DelegatorSnapshot ID construction in the bond,
unbond, and rebond event handlers to include a unique per-event component such
as transaction hash and log index, rather than only delegator and round. Ensure
each state-changing event creates a distinct snapshot while preserving the
existing DelegatorSnapshot fields and save flow.
In `@src/mappings/bondingManager.ts`:
- Around line 166-175: The DelegatorSnapshot identifier in the snapshot creation
block collides with snapshots from unbond() and rebond() within the same round.
Update snapshotId to include a unique event-specific component while retaining
the delegator and round context, ensuring multiple same-round events produce
distinct records without overwriting earlier state.
In `@src/mappings/ticketBroker.ts`:
- Around line 135-145: Update the previous cumulative reward factor lookup in
the ticket-broker flow around prevPoolForFees so it searches backward through
earlier rounds until it finds the transcoder’s most recent Pool with a nonzero
cumulativeRewardFactor, rather than only checking round.id - 1. Preserve
PRECISE_PERC_DIVISOR as the fallback when no prior qualifying Pool exists, and
use the existing pool-loading helpers or lookup pattern from createOrLoadPool.
In `@utils/helpers.ts`:
- Around line 64-79: Update the cumulative-factor initialization in the shown
pool-loading flow to use the transcoder’s persisted last cumulative reward and
fee factors rather than resetting to ZERO_BI when Pool.load for the previous
round returns null. Store or reuse these factors on the Transcoder entity, while
preserving previous-pool values when available and ensuring the updated factors
are persisted for subsequent rounds.
---
Nitpick comments:
In `@src/mappings/bondingManager.ts`:
- Around line 144-159: Extract the repeated pool-loading, CRF fallback, and
shares calculation from bond(), unbond(), and rebond() into a shared helper in
utils/helpers.ts, such as computeShares(delegateAddress, roundId, bondedAmount).
Update all three call sites to use the helper while preserving the existing
PRECISE_PERC_DIVISOR fallback and cumulativeRewardFactor handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
Push a commit to this branch (recommended)
Create a new PR with the fixes
ℹ️ Review info⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c79b66a3-23ff-4bca-a971-3b774b9e83c2
📥 Commits
Reviewing files that changed from the base of the PR and between 69340df and 7b101df.
The on-demand Pool creation added in livepeer#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…rwritten
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
This makes pool creation scan one store key per missed round. A transcoder that has been inactive for a long interval can make newRound perform thousands of loads and delay indexing, even though the new stake calculation is intended to be O(1). Persist the last factor-bearing pool/source on the transcoder or otherwise avoid a linear round-by-round search.
- 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 <noreply@anthropic.com>
This key is not unique for the advertised “snapshot at each” behavior: a delegator can bond, unbond, or rebond more than once in the same round, so the later event overwrites the earlier DelegatorSnapshot. Use the transaction hash/log index (or another event-unique suffix) for the snapshot ID, and apply the same change in the other two handlers.
This issue also appears in the following locations of the same file:
- 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 <noreply@anthropic.com>
Write current-round snapshot after self-claim share rebasing
src/mappings/bondingManager.ts:808
This branch updates delegator.shares for a self-claim, but no DelegatorSnapshot is written afterward. If the last bond/unbond/rebond was in an earlier round, the snapshot used for historical stake queries retains the pre-claim shares and undercounts the self-delegator's stake from the claim round onward. Update/overwrite the current-round snapshot for self-claims after rebasing shares (and adjust the no-snapshot-on-claim test expectation for this exception).
A self-claim changes delegator.shares by adding the orchestrator's commission, but this path does not write a DelegatorSnapshot. Consequently, the snapshot for the prior bond/unbond/rebond remains the only historical share value, so stake charts that switch shares at snapshots undercount this delegator after the claim. Record/update the snapshot when the self-claim rebases shares (using the current event round, consistent with the other snapshot handlers).
…edeemed
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 <noreply@anthropic.com>
Self-claim omits snapshot for changed delegator shares
src/mappings/bondingManager.ts:809
This self-claim changes delegator.shares, but no DelegatorSnapshot is created or updated for the claim. A round-based query that uses the latest snapshot at or before this round will continue using the pre-claim shares, so it underestimates the orchestrator's stake after commission is bonded; either snapshot this share-changing self-claim or make the historical-query contract explicitly account for claims.
- 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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Unresolved issues affect fee-factor accuracy, self-claim snapshots, and the documented share calculation.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Do not reward successful zero-value earnings pools
src/mappings/ticketBroker.ts:190
An all-zero, non-reverted earnings-pool read is the documented no-transfer case, but this condition falls through to the local calculation below and adds faceValue to cumulativeFeeFactor anyway. That creates fee rewards for an uncredited ticket. Only use the local increment when the contract call reverted; for a successful all-zero tuple, retain the propagated factor.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The subgraph has no way to compute a delegator's actual total stake. The
bondedAmountfield only reflects the value at the time of the last claim and does not include rewards earned since then. To get a delegator's current stake, clients must call the contract'spendingStake()method directly — and for historical stake at a past round, there is no solution at all.Similarly, computing an orchestrator's full pending stake (including their unclaimed commission) or their lifetime earnings requires fetching every pool since their last claim and summing across all of them.
This also means there's no way to build time-series charts of delegator stake, show per-round reward breakdowns, or compute orchestrator yield — all of which require knowing stake values across rounds.
Summary
cumulativeRewardFactorandcumulativeFeeFactoron thePoolentity, matching on-chain PreciseMathUtils (27-decimal fixed-point)reward()calls)sharesfield toDelegator(bondedAmount * 10^27 / crf[lastClaimRound]) — invariant across claims, only changes on bond/unbond/rebondDelegatorSnapshotentity capturing delegator state at each bond/unbond/rebond for time-series chart supportWinningTicketRedeemedhandler using previous round's CRF (matching contract behavior)Transcoder:pendingRewardCommission— unclaimed reward commission (rewardCut portion), resets on claimlifetimeRewardCommission— total reward commission ever earned, never resetspendingFeeCommission— unclaimed fee commission, resets on claimlifetimeFeeCommission— total fee commission ever earned, never resetsWhat this unlocks
Current pending stake without contract calls — A delegator's up-to-date total stake (equivalent to
pendingStake()on-chain) can now be computed entirely from the subgraph:stake = shares * crf / 10^27. For orchestrators specifically, their full pending stake includes unclaimed commission:stake = shares * crf / 10^27 + pendingRewardCommission. No contract calls needed.Historical stake at any past round — Something previously impossible. Query the delegator's shares (or the relevant
DelegatorSnapshot) and thePool.cumulativeRewardFactorfor that round, then computeshares * crf[round] / 10^27.Per-round reward and fee breakdowns — Rewards earned in a specific round can be derived by comparing cumulative factors between consecutive rounds:
rewards = shares * (crf[round] - crf[round-1]) / 10^27. Same pattern applies to fees usingcumulativeFeeFactor.Time-series charts of delegator stake — The
sharesapproach combined withDelegatorSnapshotentities makes it straightforward to build a chart showing a delegator's total stake over time. Between snapshots, shares are constant, so stake at any round is justshares * crf[round] / 10^27. When a bond/unbond/rebond occurs, a new snapshot captures the updated shares value to use going forward.Orchestrator commission tracking —
pendingRewardCommissionandpendingFeeCommissiongive the orchestrator's unclaimed commission in a single field each, without needing to sum across pools.lifetimeRewardCommissionandlifetimeFeeCommissionprovide total lifetime earnings.Orchestrator pool analytics — Cumulative factors stored per-pool per-round enable computing total rewards distributed, effective yield, and fee revenue for any orchestrator over any time range without replaying events.
All changes are additive — no breaking changes to existing queries.
Test plan
npx graph codegen && npx graph build— verified locallyshares * crf[round] / 10^27matches actual bonded amountspendingStake()return valuependingRewardCommissionandpendingFeeCommissionreset to zero after orchestrator claimslifetimeRewardCommissionandlifetimeFeeCommissionnever reset🤖 Generated with Claude Code
Summary by CodeRabbit