fix(fork-choice): bound a block's slot before the empty-slot loop runs#1182
Merged
tcoratger merged 1 commit intoJul 6, 2026
Merged
Conversation
The empty-slot loop in the state transition runs once per slot from the parent to the block, so its length is block.slot minus parent.slot, and on_block placed no upper bound on block.slot before reaching it. Because the proposer of a slot is slot modulo the validator count, one key is the valid proposer for infinitely many slots, so a single signed block at slot 2**63 forced roughly 2**63 iterations from one message. The always present genesis parent made a far-slot block reachable, and the cost was paid before the post-state root was ever checked. Reject the block in on_block, before the transition, with two guards: - Parent-gap cap: reject when block.slot minus parent.slot exceeds the historical-roots limit. This bounds the actual loop variable directly and is clock independent. It is lossless: a block with a larger gap would overflow the capped historical-block-hashes list during header processing anyway, so this rejects only already doomed blocks, with no false rejections and no liveness cost. - Clock horizon: reject when block.slot exceeds the current slot by more than one slot. This mirrors the attestation future-slot guard and keeps far-future blocks out of the store. The margin is a whole slot, not the attestation path's one interval, so an intended early block still imports. Both guards work in Python integers so the near-2**64 wire slot never overflows, and both run before signature verification so a far-future block is rejected cheaply. The guards live only in on_block, the untrusted-input boundary; block production advances slots through a different path and is unaffected. Adds BLOCK_SLOT_GAP_TOO_LARGE and BLOCK_TOO_FAR_IN_FUTURE reasons, and consensus vectors for the clock-horizon rejection and its boundary. The parent-gap cap is verified by construction (a larger-gap block is not representable) and the early-block-arrival vectors still import. Closes leanEthereum#1171 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 tasks
MegaRedHand
added a commit
to lambdaclass/ethlambda
that referenced
this pull request
Jul 6, 2026
…eanSpec #1182) (#501) ## What Ports leanSpec [#1182](leanEthereum/leanSpec#1182): bound a block's slot in `on_block` before the state transition runs. ## Why `on_block` had a lower slot bound but no upper bound. The transition advances the state one slot at a time from the parent up to `block.slot`, and the proposer of a slot is `slot % num_validators`, so one key is a valid proposer for infinitely many slots. A single validly-signed block on a reachable parent (e.g. genesis) could therefore drive an unbounded empty-slot walk. ethlambda is not vulnerable to the unbounded *loop* (`process_slots` jumps straight to the target slot rather than iterating) and `process_block_header` already caps the `historical_block_hashes` allocation via `SlotGapTooLarge`. This PR adds the spec's two guards at the untrusted-input boundary so a crafted block is rejected *cheaply*, before signature verification. ## Changes `crates/blockchain/src/store.rs`, in `on_block_core` before signature verification: - **Parent-gap cap:** reject `block.slot - parent.slot > HISTORICAL_ROOTS_LIMIT` (`BlockSlotGapTooLarge`). Clock-independent; bounds the walk directly. - **Clock horizon:** reject `block.slot > current_slot + 1` (`BlockTooFarInFuture`). Whole-slot margin, so an intended early block still imports (mirrors the attestation future-slot guard, but with a whole-slot rather than one-interval margin). The existing STF-level `SlotGapTooLarge` in `process_block_header` is kept as defense-in-depth. ## Tests - `on_block_rejects_block_too_far_in_future` - `on_block_rejects_block_slot_gap_too_large` `cargo fmt`, `clippy -D warnings`, and the blockchain lib tests pass. >⚠️ The horizon guard also runs via `on_block_without_verification` (fork-choice spec tests). The runner ticks to each block's slot before delivery (`tick_to_slot`), so normal progression fixtures are unaffected; only deliberate early/future vectors exercise it, and #1182 keeps a 1-slot margin for backward-compat. I could not run `forkchoice_spectests` here (`leanSpec/fixtures` not downloaded) — recommend running it against fresh fixtures before merge.
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.
What
Closes #1171: block acceptance had a lower slot bound but no upper bound, so a single validly-signed block could force the state-transition's empty-slot loop to run unboundedly — a denial of service every transliterating client inherits.
The bug
The empty-slot loop advances
state.slotone slot at a time up toblock.slot, so its length is exactlyblock.slot − parent.slot.on_blockran no upper-bound check onblock.slotbefore the transition. The proposer of a slot isslot % num_validators, so one key is the valid proposer for infinitely many slots and can sign a block at slot2**63; the always-present genesis state is a reachable parent; and the post-state root is only checked after the loop. So one small message forces ~2**63iterations.The fix — two guards in
on_block, before the transitionThe consensus decision here matters: the loop length is
block.slot − parent.slot, notblock.slot − current_slot. A clock horizon alone boundsblock.slotbut not the loop — a block on the genesis parent at slot ≈current_slotpasses a clock check yet still runs ~current_slotiterations, a residual that grows with node uptime. So the fix uses both:block.slot − parent.slot > HISTORICAL_ROOTS_LIMIT(2^18). This bounds the actual loop variable directly and is clock-independent. It is lossless:process_block_headergrows thehistorical_block_hasheslist (capped at 2^18) by the gap, so any block with a larger gap is already doomed to overflow that list — but only after the loop runs. Capping the gap up front rejects exactly those already-doomed blocks, with no false rejections and no liveness cost, reusing the existing constant.block.slot > current_slot + 1. Mirrors the attestation future-slot guard and keeps far-future blocks out of the store. The margin is a whole slot, not the attestation path's one interval, so an intended early block still imports.Both work in Python integers (the near-2^64 wire slot never overflows), both run before signature verification so a far-future block is rejected cheaply, and both live only in
on_block— the untrusted-input boundary. Block production advances slots throughbuild_block/process_slotsdirectly, so it is unaffected; the guard deliberately does not go inprocess_slots.This matches consensus-specs, which also places the future-slot bound in
on_blockbefore the transition — but with a whole-slot margin rather than an exactcurrent_slot >= block.slotassert, because this spec deliberately imports self-authenticating early blocks (seetest_early_block_arrival.py).Tests
Consensus vectors (fork behavior is vector-tested):
test_block_future_horizon.py— a slot-2 block with the clock pinned at interval 0 is rejected withBLOCK_TOO_FAR_IN_FUTURE(full-message equality), the store untouched; plus a non-genesis-clock boundary (clock at slot 1: a slot-2 block imports, a slot-3 block is rejected).The parent-gap cap is verified by construction rather than by a vector: a block with a >2^18 slot gap cannot be built through the fixture (the builder runs the real transition, and the signing keys are slot-bounded) and would overflow the historical-roots list regardless. This is documented, not vectored.
just checkis clean; the new and affected vectors fill deterministically.Credit
Found via the Lean 4 formalization of this spec (NyxFoundation/formal-leanSpec): the empty-slot loop was proved to terminate for a given target, but nothing bounded the target itself — exactly the missing precondition.
Closes #1171
🤖 Generated with Claude Code