Skip to content

fix(fork-choice): bound a block's slot before the state transition (leanSpec #1182)#501

Merged
MegaRedHand merged 1 commit into
mainfrom
fix/on-block-slot-bounds
Jul 6, 2026
Merged

fix(fork-choice): bound a block's slot before the state transition (leanSpec #1182)#501
MegaRedHand merged 1 commit into
mainfrom
fix/on-block-slot-bounds

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

What

Ports 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.

Port leanSpec #1182. on_block had no upper bound on a block's slot, so one
validly-signed block on a reachable parent could drive the empty-slot walk over
an unbounded range. Add two guards at the untrusted-input boundary, before the
(expensive) signature verification so a crafted block is rejected cheaply:

- parent-gap cap: reject block.slot - parent.slot > HISTORICAL_ROOTS_LIMIT,
  bounding the walk directly and independent of the clock.
- clock horizon: reject block.slot > current_slot + 1, keeping far-future blocks
  out of the store (whole-slot margin so an intended early block still imports).

ethlambda's process_slots already jumps straight to the target slot (no per-slot
loop) and process_block_header already caps the historical-roots allocation, so
this is defense-in-depth plus the cheap early rejection the spec places here.
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall this is a well-structured defense-in-depth PR that correctly bounds DoS vectors before expensive operations. The logic is sound and the tests are comprehensive.

Major concern: Constant selection

  • File: crates/blockchain/src/store.rs, line 538
  • Issue: Using HISTORICAL_ROOTS_LIMIT (typically 16,777,216 in Ethereum specs) allows slot gaps of ~6.4 years. Processing 16M empty slots is still a significant DoS vector (seconds to minutes of CPU time per malicious block).
  • Recommendation: Verify if leanSpec #1182 specifically requires this constant. If not, consider using SLOTS_PER_HISTORICAL_ROOT (8,192 slots ≈ 27 hours) or a dedicated MAX_SLOT_GAP constant (e.g., 1024 or 8192) for tighter DoS protection. If HISTORICAL_ROOTS_LIMIT is required by spec, document why this specific limit was chosen.

Minor issues and suggestions

  1. Integer overflow safety — Lines 538, 553
    Correctly uses saturating_sub to prevent underflow. The current_slot + 1 check is safe from overflow in practice (would require u64::MAX slots).

  2. Error variant ordering — Lines 977-982
    The new error variants are appropriately placed. Ensure StoreError derives Clone if needed elsewhere (not visible in diff, but worth checking).

  3. Test coverage — Lines 1534-1608
    Tests correctly verify both error paths and the ordering (gap check fires before future-slot check). Consider adding a test for the boundary condition where slot_gap == HISTORICAL_ROOTS_LIMIT (should succeed) to prevent off-by-one regressions.

  4. Documentation — Line 538 comment
    The comment references "leanSpec #1182". Ensure this specification document is accessible in the repo or linked in the PR description for future auditors.

Security assessment

  • DoS mitigation: The checks correctly bound the process_slots loop before signature verification, preventing the worst-case unbounded computation.
  • Clock skew: The + 1 slot margin for future blocks is reasonable for network latency tolerance.
  • Validation ordering: Slot gap check precedes future-horizon check, which matches the documented behavior and prevents wasting resources on future blocks with large gaps.

Verdict: Approve with the caveat that the HISTORICAL_ROOTS_LIMIT constant should be verified as the intended bound, or replaced with a tighter limit if this is generic DoS protection rather than spec-mandated behavior.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. crates/blockchain/src/store.rs:552-556 weakens the future-block gate from the existing interval-based rule to a full-slot rule. The actor currently rejects a block when slot * INTERVALS_PER_SLOT > store.time() + GOSSIP_DISPARITY_INTERVALS in lib.rs, but this PR accepts any slot <= current_slot + 1. With INTERVALS_PER_SLOT = 5, store::on_block* now admits next-slot blocks up to 4 intervals earlier than the production ingress path. Because store::on_block and on_block_without_verification are public and used directly by the spectest/RPC drivers, this creates inconsistent block-validity semantics depending on call site. I’d factor the existing interval-based predicate into a shared helper and use it here too.

  2. crates/blockchain/src/store.rs:545-549 does not actually preflight the same resource bound enforced later by the state transition. The real OOM/overflow guard is current_len + slot_gap > HISTORICAL_ROOTS_LIMIT in state_transition/src/lib.rs, but this new check only tests slot_gap > HISTORICAL_ROOTS_LIMIT. Once historical_block_hashes is near full, a crafted block with a small gap can still pass this new precheck, pay XMSS/signature verification, and then deterministically fail in process_block_header. If the goal is cheap rejection before crypto, hoist the exact current_len + slot_gap condition into on_block_core.

No other consensus-specific regressions stood out in this diff.

Testing

I could not run cargo test here because the environment blocks writes under /home/runner/.rustup and /home/runner/.cargo, so I relied on static review only.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have enough context to write the review.

Review: fix(fork-choice): bound a block's slot before the state transition (PR 501)

Summary

Solid, well-scoped defensive change. It adds two cheap, clock/parent-relative guards in on_block_core (crates/blockchain/src/store.rs:538-561) that reject a block with an excessive slot gap or an excessive future slot before XMSS signature verification and the state transition run. The rationale is sound: even though ethlambda's process_slots jumps straight to the target slot (no per-slot loop) and process_block_header already caps the historical_block_hashes allocation via SlotGapTooLarge, both of those only fire after an expensive XMSS verification would otherwise have already run. Moving a cheap check ahead of that is a legitimate DoS hardening, not a redundant no-op.

Correctness

  • The ordering is correct: parent-gap check → horizon check → attestation-dedup check → signature verification → state transition. Both new guards run for both on_block (verified) and on_block_without_verification (spec-test path), which is appropriate since process_slots/process_block_header are shared regardless of verify.
  • The whole-slot horizon margin (slot > current_slot + 1) is deliberately looser than the interval-level margin (GOSSIP_DISPARITY_INTERVALS = 1) already enforced one layer up in process_or_pend_block (crates/blockchain/src/lib.rs:761-774) for gossip blocks. I checked this is intentional and necessary: local block building publishes the next slot's block during interval 4 of the current slot (crates/blockchain/src/lib.rs ~640-670, matching the tick schedule in CLAUDE.md), so at publish time store.time() still reports the current slot while block.slot == current_slot + 1. A tighter margin here would make the node reject its own just-built block. Good catch keeping this consistent with the existing schedule.
  • The parent-gap check (slot_gap > HISTORICAL_ROOTS_LIMIT) is intentionally a looser, fixed bound than the STF-level SlotGapTooLarge (which accounts for the already-accumulated length of historical_block_hashes, not just the raw gap). That's fine — this guard's only job is to cheaply reject obviously-pathological gaps before crypto verification; the STF check remains the precise bound.
  • HISTORICAL_ROOTS_LIMIT as u64 cast is safe and consistent with the existing cast in crates/net/rpc/src/spec.rs:28.
  • No exhaustive match over StoreError exists elsewhere in the codebase, so adding the two variants doesn't risk a missed-arm compile break.

Risk / follow-up (already flagged by the author, worth reiterating as a blocking item)

  • on_block_without_verification is also used by forkchoice_spectests.rs, whose runner only ticks the store clock to a block's slot when step.tick_to_slot is true (crates/blockchain/tests/forkchoice_spectests.rs:100-108); other steps deliberately deliver a block ahead of the store clock to test early-delivery semantics. Since the author could not run these fixtures locally (leanSpec/fixtures not downloaded), there's a real chance some tick_to_slot: false fixtures assumed no upper bound and will now fail with BlockTooFarInFuture, or that the fixture's exact margin convention differs from the whole-slot margin chosen here. This should be run against fresh fixtures before merge — treat this as a blocking item, not just a nice-to-have.

Minor / non-blocking

  • StoreError::BlockSlotGapTooLarge and state_transition::Error::SlotGapTooLarge are two different error types in two different crates with very similar names and similar-but-not-identical semantics (gap means "empty slots" in the STF version vs. "slot - parent slot" here). Not a bug, but a future debugger will need to check the variant's crate/module to know which bound actually fired — consider a doc-comment cross-reference on one or both errors making the distinction explicit.
  • Test coverage exercises both rejection paths but not the accepted boundary (e.g. slot == current_slot + 1 exactly, or slot_gap == HISTORICAL_ROOTS_LIMIT exactly). Given these are > (strict) comparisons and the PR is security-sensitive, an explicit "boundary accepted" test for at least one of the two guards would make the off-by-one intent self-documenting.
  • Pre-existing (not introduced by this PR): when process_block returns Err for a block with pending children waiting on it (crates/blockchain/src/lib.rs:858-867), those children are neither discarded nor retried — they stay stuck in pending_blocks/pending_block_parents until restart. This PR adds two more legitimate ways to hit that Err branch (e.g. a benign clock-skewed peer's early block getting an honest child queued behind it), slightly widening exposure to an existing gap. Worth a separate follow-up issue, not a blocker for this PR.

Verdict

Logic and placement are correct and match the stated threat model; the one real gate before merge is running forkchoice_spectests against current fixtures to confirm no spec-test regressions from the new upper bound.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Ports leanSpec #1182 by adding two untrusted-input boundary checks in on_block_core: a parent-gap cap (slot - parent.slot > HISTORICAL_ROOTS_LIMITBlockSlotGapTooLarge) and a clock-horizon cap (slot > current_slot + 1BlockTooFarInFuture). Both guards run before signature verification so crafted blocks are rejected cheaply.

  • The gap guard is clock-independent and directly bounds the empty-slot walk; the horizon guard mirrors the attestation future-slot pattern but with a whole-slot margin. Both are correctly placed after the parent-state fetch (which provides parent_state.slot) and before the expensive attestation and signature checks.
  • Two new StoreError variants are added with clear messages, and two unit tests cover the rejection cases. As noted in the PR description, the fork-choice spec tests (forkchoice_spectests) could not be run locally — running them against fresh fixtures before merge is recommended to confirm on_block_without_verification fixtures are unaffected.

Confidence Score: 4/5

The new guards are correctly placed and logically sound; the only concern is a test that would silently pass even if the gap guard were removed.

Both checks are correctly placed after parent-state retrieval and before the expensive signature path. The logic is consistent with the existing attestation horizon pattern. The gap-check test implicitly depends on the ordering of the two new guards — at store.time() = 0, the future-horizon check fires for the same block, so the test only catches BlockSlotGapTooLarge because the gap check runs first. If the guards were swapped, the test would still pass but no longer verify the gap guard specifically.

The test section at the bottom of store.rs (around line 1574) deserves a closer look to confirm the gap test is independently exercising the gap guard.

Important Files Changed

Filename Overview
crates/blockchain/src/store.rs Adds two early-rejection guards in on_block_core (BlockSlotGapTooLarge and BlockTooFarInFuture) before signature verification, plus two corresponding error variants and tests. Guard logic is correct; one test has an implicit dependency on check ordering that could hide a regression if the guards are reordered.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[on_block_core called] --> B{Duplicate block?}
    B -- Yes --> C[return Ok early]
    B -- No --> D{Parent state available?}
    D -- No --> E[Err: MissingParentState]
    D -- Yes --> F{slot_gap > HISTORICAL_ROOTS_LIMIT?}
    F -- Yes --> G[Err: BlockSlotGapTooLarge]
    F -- No --> H{slot > current_slot + 1?}
    H -- Yes --> I[Err: BlockTooFarInFuture]
    H -- No --> J{Attestation dedup check}
    J -- Fail --> K[Err: DuplicateAttestationData / TooManyAttestationData]
    J -- Pass --> L{verify=true?}
    L -- Yes --> M[verify_block_signatures]
    M -- Fail --> N[Err: signature error]
    M -- Pass --> O[state_transition]
    L -- No --> O
    O --> P[Store state & update checkpoints]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[on_block_core called] --> B{Duplicate block?}
    B -- Yes --> C[return Ok early]
    B -- No --> D{Parent state available?}
    D -- No --> E[Err: MissingParentState]
    D -- Yes --> F{slot_gap > HISTORICAL_ROOTS_LIMIT?}
    F -- Yes --> G[Err: BlockSlotGapTooLarge]
    F -- No --> H{slot > current_slot + 1?}
    H -- Yes --> I[Err: BlockTooFarInFuture]
    H -- No --> J{Attestation dedup check}
    J -- Fail --> K[Err: DuplicateAttestationData / TooManyAttestationData]
    J -- Pass --> L{verify=true?}
    L -- Yes --> M[verify_block_signatures]
    M -- Fail --> N[Err: signature error]
    M -- Pass --> O[state_transition]
    L -- No --> O
    O --> P[Store state & update checkpoints]
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/blockchain/src/store.rs:1574-1612
**Gap test implicitly relies on check ordering**

`on_block_rejects_block_slot_gap_too_large` sets `store.time()` to `0`, so `current_slot = 0` and the horizon is slot `1`. Because `gap_slot = HISTORICAL_ROOTS_LIMIT + 1` is also beyond the horizon, *both* guards would trigger; the test receives `BlockSlotGapTooLarge` only because the gap check happens to run first in the code. If the two checks are ever reordered, the test breaks with `BlockTooFarInFuture` instead, masking the gap guard's absence. Advancing the store clock to at least `gap_slot * INTERVALS_PER_SLOT` before the call (so `current_slot >= gap_slot`, making the horizon check a no-op) would make the test solely exercise the gap guard.

Reviews (1): Last reviewed commit: "fix(fork-choice): bound a block's slot b..." | Re-trigger Greptile

Comment thread crates/blockchain/src/store.rs
@MegaRedHand MegaRedHand merged commit d96e524 into main Jul 6, 2026
7 checks passed
@MegaRedHand MegaRedHand deleted the fix/on-block-slot-bounds branch July 6, 2026 20:38
MegaRedHand added a commit to d4m014/ethlambda that referenced this pull request Jul 7, 2026
…g store accessors

The main merge brought in the fork-choice slot-bound guards (lambdaclass#501) that call
store.time() and store.head() in their pre-Result form, but this branch changed
those accessors to return Result<T, Error>. Unwrap them consistently with the
surrounding code so the crate compiles.
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.

2 participants