From 2856825079b13b917cfcce2b7af4caf5f21cfafa Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Sun, 23 Aug 2026 19:57:21 +0530 Subject: [PATCH 1/2] fix: harden non-deterministic eth_call reads to prevent silent data corruption (#248) --- src/mappings/roundsManager.ts | 52 ++++++++++++++++++++++++++++++----- utils/helpers.ts | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index c113a35..32d4668 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -50,10 +50,14 @@ export function newRound(event: NewRound): void { let totalActiveStake = BigDecimal.zero(); let transcoder: Transcoder | null = null; - // will revert if there are no transcoders in pool + // getFirstTranscoderInPool reverts only if the pool is empty, which is + // impossible post-launch. A revert here is a determinism hazard (RPC + // backend returning bad data), not a legitimate control-flow path — log + // critical so it surfaces immediately rather than silently skipping the + // transcoder enumeration (see issue #248, case 1). let callResult = bondingManager.try_getFirstTranscoderInPool(); if (callResult.reverted) { - log.info("getFirstTranscoderInPool reverted", []); + log.critical("getFirstTranscoderInPool reverted", []); } else { currentTranscoder = callResult.value; transcoder = createOrLoadTranscoder( @@ -62,10 +66,12 @@ export function newRound(event: NewRound): void { ); } - // will revert if there is no LPT bonded + // getTotalBonded reverts only when no LPT is bonded, which is impossible + // post-launch. A revert here is a determinism hazard — log critical so it + // surfaces immediately (see issue #248, case 2). let getTotalBondedCallResult = bondingManager.try_getTotalBonded(); if (getTotalBondedCallResult.reverted) { - log.info("getTotalBonded reverted", []); + log.critical("getTotalBonded reverted", []); } else { totalActiveStake = convertToDecimal(getTotalBondedCallResult.value); } @@ -77,6 +83,26 @@ export function newRound(event: NewRound): void { let protocol = createOrLoadProtocol(); + // Carry forward the last known totalActiveStake if the call reverted or + // returned zero (impossible post-launch, indicating a bad RPC response). + // This prevents zeroing protocol.totalActiveStake and downstream + // participationRate / numActiveTranscoders for the entire round. + if ( + getTotalBondedCallResult.reverted || + totalActiveStake.equals(ZERO_BD) + ) { + let lastStake = protocol.totalActiveStake; + if (!lastStake.equals(ZERO_BD)) { + totalActiveStake = lastStake; + protocol.totalActiveStake = totalActiveStake; + protocol.save(); + log.warning( + "totalActiveStake reverted/zero; carrying forward last known value {}", + [lastStake.toString()] + ); + } + } + // Activate all transcoders pending activation let pendingActivation = protocol.pendingActivation; if (pendingActivation.length) { @@ -118,7 +144,10 @@ export function newRound(event: NewRound): void { 90 ); - // Iterate over all active transcoders + // Iterate over all active transcoders using the try_ variant so that a + // reverted eth_call (bad RPC backend) is visible rather than silently + // returning EMPTY_ADDRESS, which would truncate the enumeration and + // skip every transcoder after it (see issue #248, case 5). while (EMPTY_ADDRESS.toHex() != currentTranscoder.toHex()) { // create a unique "pool" for each active transcoder. If a transcoder calls // reward() for a given round, we store its reward tokens inside this Pool @@ -126,8 +155,17 @@ export function newRound(event: NewRound): void { // given transcoder and round then we know the transcoder failed to call reward() createOrLoadPool(round.id, currentTranscoder.toHex()); - currentTranscoder = - bondingManager.getNextTranscoderInPool(currentTranscoder); + let nextResult = bondingManager.try_getNextTranscoderInPool( + currentTranscoder + ); + if (nextResult.reverted) { + log.critical( + "getNextTranscoderInPool reverted for transcoder {} — enumeration truncated (POI divergence risk)", + [currentTranscoder.toHex()] + ); + break; + } + currentTranscoder = nextResult.value; transcoder = Transcoder.load(currentTranscoder.toHex()); diff --git a/utils/helpers.ts b/utils/helpers.ts index 556744d..f53fa30 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -5,6 +5,7 @@ import { Bytes, dataSource, ethereum, + log, } from "@graphprotocol/graph-ts"; import { RoundsManager } from "../src/types/RoundsManager/RoundsManager"; import { @@ -461,6 +462,18 @@ export function getLptPriceEth(): BigDecimal { return getPriceForPair(getUniswapV3LptEthPoolAddress()); } +// Last-known price per pool address; carried across handlers so a transient +// RPC revert (or a laggard backend returning a wrong-but-valid 0 price) does +// not permanently zero the USD accumulators. The zero sentinel for +// "pool not yet deployed / off-network" is indistinguishable from "RPC +// hiccup" — carrying the last known value is strictly safer than emitting a +// silent zero that corrupts downstream totals. +// +// We use individual variables because AssemblyScript's support for Map with +// BigDecimal values is unreliable across graph-ts versions. +let lastKnownDaiEthPrice: BigDecimal = ZERO_BD; +let lastKnownLptEthPrice: BigDecimal = ZERO_BD; + export function getPriceForPair(address: string): BigDecimal { let pricePair = ZERO_BD; @@ -478,6 +491,44 @@ export function getPriceForPair(address: string): BigDecimal { BigInt.fromI32(18) ); pricePair = prices[1]; + // Only cache a non-zero price so we don't persist the "pool not yet + // deployed" zero as the last-known value (see issue #248, case 3). + if (!pricePair.equals(ZERO_BD)) { + if (address == getUniswapV3DaiEthPoolAddress()) { + lastKnownDaiEthPrice = pricePair; + } else if (address == getUniswapV3LptEthPoolAddress()) { + lastKnownLptEthPrice = pricePair; + } + } + } else { + // RPC call reverted — carry forward the last successfully read price. + if (address == getUniswapV3DaiEthPoolAddress()) { + if (!lastKnownDaiEthPrice.equals(ZERO_BD)) { + log.warning( + "slot0 call reverted for DAI/ETH pool {}; carrying forward last known price {}", + [address, lastKnownDaiEthPrice.toString()] + ); + pricePair = lastKnownDaiEthPrice; + } else { + log.info( + "slot0 call reverted for DAI/ETH pool {} with no cached price", + [address] + ); + } + } else if (address == getUniswapV3LptEthPoolAddress()) { + if (!lastKnownLptEthPrice.equals(ZERO_BD)) { + log.warning( + "slot0 call reverted for LPT/ETH pool {}; carrying forward last known price {}", + [address, lastKnownLptEthPrice.toString()] + ); + pricePair = lastKnownLptEthPrice; + } else { + log.info( + "slot0 call reverted for LPT/ETH pool {} with no cached price", + [address] + ); + } + } } } From dd65b9e3569370f40f52630ffa5642032d804e5a Mon Sep 17 00:00:00 2001 From: Mohak Gupta Date: Sun, 23 Aug 2026 21:03:50 +0530 Subject: [PATCH 2/2] Fall back to durable protocol.lptPriceEth before giving up on LPT/ETH lastKnownLptEthPrice is a module level variable, reset to zero on every subgraph restart since the WASM instance is recreated. A revert on the first price fetch after a restart fell through to a silent zero, the same corruption this PR sets out to fix. protocol.lptPriceEth is an entity backed field already written once per round in roundsManager.ts, so it survives restarts and is a strictly safer fallback than the in memory cache alone. --- utils/helpers.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/utils/helpers.ts b/utils/helpers.ts index f53fa30..23a128f 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -523,10 +523,22 @@ export function getPriceForPair(address: string): BigDecimal { ); pricePair = lastKnownLptEthPrice; } else { - log.info( - "slot0 call reverted for LPT/ETH pool {} with no cached price", - [address] - ); + // lastKnownLptEthPrice resets on every subgraph restart; fall + // back to the entity-backed protocol.lptPriceEth first. + let protocol = createOrLoadProtocol(); + if (!protocol.lptPriceEth.equals(ZERO_BD)) { + log.warning( + "slot0 call reverted for LPT/ETH pool {} with no in-memory cache; " + + "carrying forward durable protocol.lptPriceEth {}", + [address, protocol.lptPriceEth.toString()] + ); + pricePair = protocol.lptPriceEth; + } else { + log.info( + "slot0 call reverted for LPT/ETH pool {} with no cached price", + [address] + ); + } } } }