From ef060f4ad3ff87c84f2464f22ac3f1f538d93cef Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Tue, 11 Aug 2026 15:52:54 -0400 Subject: [PATCH] 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;