Skip to content

Gate forced payment detection behind an activation slot - #254

Merged
Marketen merged 4 commits into
mainfrom
marc/selfdestruct-active-slot
Aug 14, 2026
Merged

Gate forced payment detection behind an activation slot#254
Marketen merged 4 commits into
mainfrom
marc/selfdestruct-active-slot

Conversation

@Marketen

Copy link
Copy Markdown
Contributor

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, with claimedBalance tracked 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

var ForcedPaymentActivationSlot = map[uint64]uint64{
    MainnetChainId: 14950448,
}

IsForcedPaymentDetectionActive() gates the trigger in FetchFullBlock. 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.

  • Exceptions 1-3 predate the activation and stay on the exception path
  • Exceptions 4-5 fall after it and short-circuit regardless: MevRewardInWei returns the pool as recipient for exception slots, and the trigger requires a recipient other than the pool

Tests

  • Test_ForcedMevPayment_ActivationSlot — boundary at activation−1 / activation / activation+1
  • Test_ForcedMevPayment_ActivationUnknownChain — unlisted chains active from genesis
  • Test_ForcedMevPayment_ActivationCoversKnownExceptions — asserts the activation slot sits between exception 3 and exception 4, so a future edit cannot move it past the range needing repair

Full suite green.

🤖 Generated with Claude Code

Marketen and others added 4 commits August 13, 2026 16:43
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>
@Marketen
Marketen merged commit c1a019f into main Aug 14, 2026
7 checks passed
@Marketen
Marketen deleted the marc/selfdestruct-active-slot branch August 14, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant