Skip to content

fix(fork-choice): bound a block's slot before the empty-slot loop runs#1182

Merged
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:fix/block-future-slot-horizon
Jul 6, 2026
Merged

fix(fork-choice): bound a block's slot before the empty-slot loop runs#1182
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:fix/block-future-slot-horizon

Conversation

@tcoratger

Copy link
Copy Markdown
Collaborator

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.slot one slot at a time up to block.slot, so its length is exactly block.slot − parent.slot. on_block ran no upper-bound check on block.slot before the transition. The proposer of a slot is slot % num_validators, so one key is the valid proposer for infinitely many slots and can sign a block at slot 2**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**63 iterations.

The fix — two guards in on_block, before the transition

The consensus decision here matters: the loop length is block.slot − parent.slot, not block.slot − current_slot. A clock horizon alone bounds block.slot but not the loop — a block on the genesis parent at slot ≈ current_slot passes a clock check yet still runs ~current_slot iterations, a residual that grows with node uptime. So the fix uses both:

  • Parent-gap cap (primary): reject 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_header grows the historical_block_hashes list (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.
  • Clock horizon (secondary): reject 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 through build_block/process_slots directly, so it is unaffected; the guard deliberately does not go in process_slots.

This matches consensus-specs, which also places the future-slot bound in on_block before the transition — but with a whole-slot margin rather than an exact current_slot >= block.slot assert, because this spec deliberately imports self-authenticating early blocks (see test_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 with BLOCK_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 existing early-arrival vectors still import unchanged (a slot-1 block at interval 0 is within the one-slot margin).

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 check is 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

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>
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.
@tcoratger tcoratger merged commit 4ca7d27 into leanEthereum:main Jul 6, 2026
14 checks passed
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.

Block acceptance has no future-slot horizon

1 participant