Gate forced payment detection behind an activation slot - #254
Merged
Conversation
Detecting forced payments changes how a block is classified, so applying it retroactively would rewrite already published history. A resync from the pool deployment could allocate rewards the onchain roots never included, and since claims settle as accumulated minus claimed, a recomputed total below what an address already claimed would brick its future claims. Apply the detection only from slot 14950448 on mainnet. Before it, forced payments stay handled by the exception table, exactly as the oracle handled them when those roots were produced, so a resync reproduces the same state and the same roots by construction rather than by argument. This does not narrow the repair: 14950448 is the checkpoint the oracle resumes from, so every payment in the range that has to be replayed is still detected. Exceptions 1 to 3 predate the activation and stay on the exception path; exceptions 4 and 5 fall after it and short circuit anyway, since MevRewardInWei returns the pool as recipient for them and the trigger requires a recipient other than the pool. Chains with no recorded activation slot have no published history to preserve, so detection applies from genesis there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gating detection on isFromSubscriber made a forced payment from a validator the pool does not know yet, or one that is banned, invisible: no reward allocated, no subscription created, and the ETH left sitting in the pool with no liability against it. The pool auto subscribes any validator whose reward reaches it, in handleCorrectBlockProposal. A direct payment to the pool from an unknown validator therefore subscribes and pays it, while a forced payment from the same validator did nothing at all. That asymmetry broke reconciliation by exactly the amount of those payments. Observed on mainnet: a dry run over slots 14950448 to 14986272 recovered 15 forced payments but still ended 96156146415005252 wei short. Five of the misses are blocks 25739733, 25740093, 25740863, 25740995 and 25742610, every one a textbook Titan forced payment with the last tx value equal to the pool balance delta, skipped only because the proposer was not subscribed at the time. The cost is two eth_getBalance calls on any MEV block whose payment recipient is not the pool. For unrelated blocks the delta does not match the expected reward and the verdict is false, so only the call count grows. Also fetch header and receipts when a forced payment is delivered and they were not already fetched, since blocks from validators the pool does not know skip that step and the evidence would otherwise carry a zero tx hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MevRewardInWei only recognises a MEV payment when the last tx was sent by the block fee recipient or a whitelisted builder. Builders route payouts through other addresses, so that heuristic reports no MEV at all and any detection gated on it is blind. Observed on mainnet: a dry run over slots 14950448 to 14986272 recovered 15 forced payments but ended 96156146415005252 wei short. The gap is exactly six blocks, 25739733, 25740093, 25740995, 25742610, 25746053 and 25748681, each with the last tx sent by 0x9fc3da866e7df3a1c57ade1a97c9f00a70f010c8 while the fee recipient was Titan at 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97. Every one has a last tx value exactly equal to the pool balance delta. The six sum to the discrepancy to the wei, so nothing else is unaccounted. Take the candidate from the last tx of the block whatever its sender, and let the balance delta be the only thing that decides whether the pool was paid. Unrelated blocks fail the exact match and cost two eth_getBalance calls. Whitelisting the new sender was the alternative, but the payout address has now rotated twice in the observed data, and a missing entry fails by silently losing money rather than by erroring. MevRewardInWei returns a proven forced payment before running the sender heuristic, so the rest of the oracle sees it as an ordinary MEV reward to the pool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The candidate is now the last tx of any block, so the check runs on nearly every block and the undelivered branch fired once per slot. On a 36000 slot replay that is 36000 warnings about blocks that have nothing to do with the pool, which buries the real signal and bloats the log file. Only the delivered case is worth reporting. Also drops the mention of wrong fee policy from the message, which was misleading: most of these blocks were never pool related to begin with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Follow-up to #253.
Why
Forced payment detection changes how a block is classified. Applying it retroactively would rewrite already published history: a resync from the pool deployment could allocate rewards that the roots onchain never included.
That is not cosmetic. Claims settle as
accumulated - claimed, withclaimedBalancetracked onchain, so a recomputed total below what an address already claimed would make its future claims revert.What was actually at risk
In practice, little.
[Reconciliation] Success!on mainnet as recently as 2026-08-11 11:58 proves assets equalled liabilities exactly, so every historical forced payment is already covered by the exception table and the trigger would never have fired before the current window.But that is a property of this chain's history, not something the code enforced. The gap it left open: onchain reconciliation only runs when the oracle is caught up and on a 3-hour timer, while the checkpoint gate uses
RunOffchainReconciliation, which compares liabilities against modelled assets and is blind to this error class. A root published between a forced payment and the next onchain reconciliation would have diverged on resync.The change
IsForcedPaymentDetectionActive()gates the trigger inFetchFullBlock. Chains with no entry have no published history to preserve, so detection applies from genesis there.It does not narrow the repair
14950448 is the checkpoint the mainnet oracle resumes from, so every payment in the range being replayed is still detected.
MevRewardInWeireturns the pool as recipient for exception slots, and the trigger requires a recipient other than the poolTests
Test_ForcedMevPayment_ActivationSlot— boundary at activation−1 / activation / activation+1Test_ForcedMevPayment_ActivationUnknownChain— unlisted chains active from genesisTest_ForcedMevPayment_ActivationCoversKnownExceptions— asserts the activation slot sits between exception 3 and exception 4, so a future edit cannot move it past the range needing repairFull suite green.
🤖 Generated with Claude Code