Skip to content

fix(stf): reject blocks justifying a target whose source is below finalized#402

Closed
MegaRedHand wants to merge 2 commits into
mainfrom
fix/justifiable-slot-source-below-finalized
Closed

fix(stf): reject blocks justifying a target whose source is below finalized#402
MegaRedHand wants to merge 2 commits into
mainfrom
fix/justifiable-slot-source-below-finalized

Conversation

@MegaRedHand
Copy link
Copy Markdown
Collaborator

Problem

leanSpec's Slot.is_justifiable_after (pinned f12000bd) asserts self >= finalized_slot. ethlambda's slot_is_justifiable_after instead returned false on the checked_sub underflow — silently lenient.

As a result ethlambda silently accepts a block that justifies a target while its finalization scan would inspect slots below the finalized slot. The spec and other clients reject such a block (zeam errors with InvalidJustifiableSlot). Accepting it is a consensus-split risk: ethlambda could follow a chain its peers reject.

This was observed on a mixed ethlambda/zeam devnet, where it stalled cross-client finality:

  • block slot 21 packed an attestation {source.slot=2, target.slot=9} (a valid, not-yet-justified, justifiable target);
  • it reached supermajority while finalized=8, justifying target 9;
  • the finalization scan then ran over slots 3..9, hitting slot 3 < finalized 8.

ethlambda swallowed it; zeam (and the spec) rejected the block, so zeam halted at slot ~17 and finality froze.

Change

  • slot_is_justifiable_after now returns Result<bool, Error> and yields Error::SlotBeforeFinalized when slot < finalized_slot — the missing assert. It propagates through is_valid_voteprocess_attestations and try_finalize, so the offending block is rejected (matching the spec and zeam).
  • Proposer guard: entry_passes_filters now drops candidate attestations whose source.slot < finalized_slot, so ethlambda never builds a block its own STF would reject.
  • Call sites where the slot is provably >= finalized (fork-choice target walk-back in store.rs, the target check in the builder) use .expect() with a documented invariant.

Note: this is a consensus-safety fix (don't accept/produce spec-invalid blocks), not a liveness fix for the specific stalled chain. A complementary spec/protocol clarification — whether attestations with source < finalized should be filtered earlier across clients — is tracked separately.

Tests

  • block_rejected_when_source_below_finalized — reproduces the block-21 scenario; asserts process_attestations returns Err(SlotBeforeFinalized).
  • slot_is_justifiable_after_errors_below_finalized — error on slot < finalized; boundary/window/square/pronic cases otherwise.
  • Existing state_transition lib + spec suite (51) and blockchain lib (21) pass; clippy -D warnings clean. The 24 forkchoice_spectests failures are pre-existing fixture drift (missing field proofData) on main, unrelated to this change.

…alized

leanSpec's `Slot.is_justifiable_after` asserts `self >= finalized_slot`. ethlambda's `slot_is_justifiable_after` instead returned `false` on the underflow, silently accepting blocks that justify a target while the finalization scan would inspect pre-finalization slots. The spec and other clients reject such blocks (zeam errors with InvalidJustifiableSlot), so accepting them risks a consensus split: ethlambda could follow a chain its peers reject.

Surface the missing assert as `Error::SlotBeforeFinalized`, propagated through `is_valid_vote`/`try_finalize` so the block is rejected. Also guard the proposer (`entry_passes_filters`) to drop candidate attestations whose source is below the finalized slot, so we never build a block our own state transition would reject.

Observed on a mixed ethlambda/zeam devnet: block slot 21 packed an attestation {source=2, target=9} that reached supermajority while finalized=8; justifying 9 then scanned slots 3..9 (slot 3 < finalized 8).

Adds regression tests covering the rejection path and the boundary behavior of slot_is_justifiable_after.
@MegaRedHand MegaRedHand marked this pull request as ready for review May 29, 2026 18:24
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

This PR fixes a critical consensus bug where slot_is_justifiable_after silently returned false for slots before the finalized slot (via underflow), while the spec requires this to be an assertion error/rejection. This caused a consensus split with other clients (zeam) when attestations justified targets with sources below the finalized checkpoint.

Overall Assessment: Correct and necessary fix. Good test coverage. The use of .expect() in non- consensus-critical paths (block building) with documented invariants is acceptable.

Detailed Feedback

1. Critical Fix Validation (state_transition/src/lib.rs)

The change from returning false to returning Err(Error::SlotBeforeFinalized) is correct. The prior behavior allowed blocks that should be invalid per the leanSpec is_justifiable_after assertion.

  • Line 526-532: Proper use of checked_sub to detect underflow and return the new error variant.

  • Lines 400-411: The conversion from any() to an explicit for loop with early return Ok(()) correctly handles the Result return type while maintaining the short-circuit logic. Using any with Result would require careful handling of Err propagation; the explicit loop is clearer and safer.

2. Error Handling in Block Building (block_builder.rs)

The .expect() calls are justified by preconditions established earlier in the functions:

  • Line 331: The comment correctly notes that target_already_justified rejects target.slot <= finalized_slot. Since the function returns early for target.slot <= finalized, the subsequent call to slot_is_justifiable_after with target.slot is guaranteed safe.

  • Line 383: The comment correctly references the new check at line 305 (source.slot < projected_finalized_slot) ensuring all slots in the range source.slot+1..target.slot are >= finalized.

Minor suggestion: Consider adding debug_assert! macros before the .expect() calls to document the invariants at the code level, not just in comments:

debug_assert!(att_data.source.slot >= projected_finalized_slot);
!slot_is_justifiable_after(s, projected_finalized_slot)
    .expect("source slot >= finalized: enforced by entry_passes_filters")

3. State Store Safety (store.rs)

  • Line 591-593: The .expect() is safe because the while loop condition target_header.slot > finalized_slot short-circuits before the && evaluates slot_is_justifiable_after. Well-commented.

4. State Transition Logic (state_transition/src/lib.rs)

  • Line 277: The change from if !is_valid_vote(...) { continue; } to if !is_valid_vote(...)? { continue; } correctly distinguishes between:

    • Validation failures (return Ok(false) → skip attestation)
    • Invariant violations (return Err(...) → reject block)

    This is the correct semantic: a SlotBeforeFinalized error indicates a malformed block that violates consensus rules, not just an invalid attestation.

5. Test Coverage

The new tests comprehensively cover the bug scenario:

  • block_rejected_when_source_below_finalized (Line 700): Excellent integration test recreating the exact devnet failure scenario (slot 21, source=2, target=9, finalized=8).

  • slot_is_justifiable_after_errors_below_finalized (Line 790): Good unit tests for the helper function, covering the boundary conditions (slot < finalized, slot == finalized, various delta values).

6. Code Style

  • Line 550: The overflow-safe pronic number check using checked_mul and checked_add is good defensive programming.

  • Line 37-40: The new error variant includes relevant context (slot, finalized_slot) for debugging.

7. Potential Concern: Partial State Mutation

In process_attestations, if an error occurs during try_finalize (e.g., line 330), the state may have been partially modified by previous attestations in the loop. This is standard behavior for state transition functions (atomic all-or-nothing), but ensure callers discard the state on Err. The PR doesn't change this behavior (it just adds a new error path), so no action needed.

Summary

Approve. The PR correctly fixes a consensus-split vulnerability by enforcing the spec's invariant that justifiable slot checks must not scan pre-finalization slots. Error handling is proper, comments justify the .expect() usage, and tests verify the fix.


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

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Here is the code review for PR #402:


PR #402fix(stf): reject blocks justifying a target whose source is below finalized

Overview

This is a consensus-safety fix that surfaces the missing assert self >= finalized_slot from leanSpec's Slot.is_justifiable_after. The old code silently swallowed the underflow in slot_is_justifiable_after (returning false), allowing ethlambda to accept blocks that other clients (zeam, spec) correctly reject — a consensus-split risk confirmed on a mixed devnet. The fix is well-scoped: it changes slot_is_justifiable_after to return Result<bool, Error>, propagates the error through is_valid_voteprocess_attestationstry_finalize, and adds a symmetric proposer-side guard so ethlambda never builds a block its own STF would reject.


Correctness

Core fix is sound.

The error path mirrors the spec precisely: SlotBeforeFinalized fires during the finalization scan in try_finalize, which is exactly where the leanSpec assertion fires. The is_valid_vote function itself cannot error in practice (the target_already_justified guard at line ~363 early-returns on target.slot <= finalized_slot before slot_is_justifiable_after is reached), so the ? at the is_valid_vote call site is correct but defensive.

The try_finalize refactor from .any() to a for loop is the right approach — closures can't propagate ? from the outer function, so the loop is not only idiomatic but required.

All three .expect() invariants are sound:

  • store.rs — the loop guard target_header.slot > finalized_slot ensures no underflow before the call.
  • block_builder.rs (filters) — target_already_justified ensures target.slot > finalized_slot before the call.
  • block_builder.rs (score_entry) — entry_passes_filters ensures source.slot >= projected_finalized_slot; the scan range starts at source.slot + 1, so every scanned s > projected_finalized_slot. No underflow is possible.

metrics::inc_finalizations("error") is correctly NOT triggered on SlotBeforeFinalized. The early ? propagates the error before reaching the metrics call, which is the right semantic distinction: "finalization blocked by unjustifiable gap" is a different situation from "block is spec-invalid."


Potential Issues

is_valid_vote has no explicit source.slot >= finalized_slot guard.

Currently is_valid_vote relies on source_not_known to implicitly filter out below-finalized sources (since the justified_slots window is anchored above finalized_slot). The error is correctly raised later in try_finalize, mirroring the spec path. However, this is an implicit dependency: if source_not_known's window semantics ever change, a source with slot < finalized_slot could silently pass is_valid_vote without the code being obviously wrong. An explicit early-return Ok(false) guard for source.slot < finalized_slot in is_valid_vote would make the invariant self-documenting and defense-in-depth. This is a low-risk follow-up, not a blocker for this PR.

The block_builder.rs proposer filter has no direct test.

The entry_passes_filters change (source_before_finalized early-return) is exercised indirectly by the existing builder tests, but there's no regression test that explicitly asserts a block is not built when candidate attestations have source.slot < finalized_slot. Given that this is a block-production invariant (not just a validation invariant), a dedicated test would strengthen confidence.


Style Notes

The multi-line invariant comments at .expect() sites technically exceed the project's "one short line max" comment guideline (CLAUDE.md). However, these justify non-obvious safety invariants in consensus-critical code, so the extra verbosity is defensible. If the project wants to be strict about this, they can be trimmed:

// before:
.expect("target_header.slot > finalized_slot guaranteed by the loop guard")

// shorter but still sufficient:
.expect("loop guard ensures slot > finalized_slot")

Test Coverage

  • block_rejected_when_source_below_finalized — correctly reproduces the slot-21 devnet scenario; good use of a concrete realistic fixture.
  • slot_is_justifiable_after_errors_below_finalized — good boundary coverage (error case, Δ=0, window edge, square, pronic, neither).
  • Missing: direct test for the proposer-side filter in entry_passes_filters.

Summary

The fix is correct, well-motivated, and matches the spec's intended semantics. The invariant reasoning at .expect() sites is sound. The dual fix (STF rejection + proposer guard) is the right pattern for consensus correctness. Only minor gaps:

  1. No direct test for the source_before_finalized proposer filter.
  2. is_valid_vote could add an explicit source.slot >= finalized_slot guard for defense-in-depth (low priority follow-up).
  3. .expect() comment verbosity is slightly above project style guidelines, but justified here.

Verdict: Approve with optional follow-ups. The consensus-safety gap is real, the fix is correct, and the regression tests cover the reported scenario.


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

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. source < finalized is still admitted into the STF until it happens to be the threshold-crossing attestation, which makes block validity depend on attestation order and leaves the proposer/STF semantics mismatched. Votes are still bucketed only by target.root in lib.rs, is_valid_vote() still accepts source.slot < latest_finalized.slot implicitly because finalized-and-earlier slots count as justified in lib.rs, and the new rejection only happens later when try_finalize() runs from the attestation that crosses 2/3 in lib.rs / lib.rs. That means two blocks containing the same mixed-source votes for one target can now differ only by body order, with one accepted and the other rejected. At the same time, the builder now drops all such votes up front in block_builder.rs, so local packing no longer matches imported-block validation. The safer fix is to reject source < finalized directly in is_valid_vote() before recording any votes.

Otherwise, the stricter SlotBeforeFinalized handling looks reasonable, and the added store/builder guards match the intended invariant.

I could not run cargo validation in this environment: dependency/toolchain access is blocked by read-only ~/.cargo/~/.rustup paths and no network access for fetching missing deps.


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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR fixes a consensus-safety bug where ethlambda silently accepted blocks that leanSpec and zeam reject: attestations whose source lies far enough below the finalized slot that the finalization scan (try_finalize) walks into pre-finalization slots, triggering an assertion in slot_is_justifiable_after. The fix changes slot_is_justifiable_after to return Result<bool, Error> and propagates Err(SlotBeforeFinalized) through is_valid_vote and try_finalize, so such blocks are rejected. The block builder gains a matching source_before_finalized filter so ethlambda never builds blocks its own STF would reject.

  • slot_is_justifiable_after now returns Err(SlotBeforeFinalized) instead of Ok(false) on underflow, which propagates through is_valid_vote → process_attestations → try_finalize to reject offending blocks at the STF boundary.
  • entry_passes_filters in the block builder rejects candidate attestations with source.slot < projected_finalized_slot before scoring, preventing ethlambda from proposing spec-invalid blocks.
  • Call sites where the slot is provably ≥ finalized (fork-choice walk-back, target check in the builder) use .expect() with documented invariants; two regression tests are added covering the devnet scenario and boundary cases.

Confidence Score: 4/5

Safe to merge; the fix correctly tightens the STF to match leanSpec/zeam semantics and is covered by well-constructed regression tests.

The core logic is sound: slot_is_justifiable_after now propagates Err on underflow, all three .expect() call sites have provably correct invariants, and the block builder's incremental projected.finalized_slot update ensures the new source_before_finalized filter is applied with up-to-date state each round. The one inconsistency — the missing is_genesis_self_vote exemption on the new entry_passes_filters guard — has no functional impact because the STF silently drops genesis self-votes regardless, but it is a noticeable departure from the guard pattern used by the three checks directly below it.

crates/blockchain/src/block_builder.rs — the new source_before_finalized guard does not carry the is_genesis_self_vote exemption that the surrounding validity checks share.

Important Files Changed

Filename Overview
crates/blockchain/state_transition/src/lib.rs Core fix: slot_is_justifiable_after returns Err on underflow; is_valid_vote and try_finalize propagate it correctly. Two new tests cover the devnet scenario and boundary values. The .expect() in is_valid_vote is safe because target_already_justified rejects target.slot ≤ finalized before the call.
crates/blockchain/src/block_builder.rs Adds source_before_finalized guard to entry_passes_filters; updates .expect() at the target justifiability check. The score_entry .expect() is safe because entry_passes_filters guarantees source.slot ≥ projected_finalized_slot. Minor: the new guard does not carry the is_genesis_self_vote exemption that the other three validity checks share.
crates/blockchain/src/store.rs Only adds .expect() at the fork-choice target walk-back loop; the target_header.slot > finalized_slot loop guard short-circuits before the call, making the unwrap trivially safe.

Sequence Diagram

sequenceDiagram
    participant Peer as Peer Block
    participant STF as process_attestations
    participant IVV as is_valid_vote
    participant TF as try_finalize
    participant SIJA as slot_is_justifiable_after

    Peer->>STF: "block with attestation {source.slot=2, target.slot=9, finalized=8}"
    STF->>IVV: is_valid_vote(state, att_data)
    IVV->>IVV: source justified? (slot 2 ≤ finalized 8 → implicitly true)
    IVV->>IVV: "target already justified? (slot 9 > 8 → false)"
    IVV->>SIJA: "slot_is_justifiable_after(target=9, finalized=8)"
    SIJA-->>IVV: "Ok(true) — delta=1 ≤ 5"
    IVV-->>STF: Ok(true) — valid vote, count supermajority
    STF->>TF: "try_finalize(src=2, tgt=9)"
    loop scan slots 3..9
        TF->>SIJA: "slot_is_justifiable_after(slot=3, finalized=8)"
        SIJA-->>TF: "Err(SlotBeforeFinalized{slot:3, finalized_slot:8})"
    end
    TF-->>STF: Err(SlotBeforeFinalized)
    STF-->>Peer: block REJECTED
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/block_builder.rs:305-313
The `source_before_finalized` guard is placed before `is_genesis_self_vote` is computed, so it applies unconditionally — including to genesis self-votes (source = target = slot 0). When `projected_finalized_slot > 0`, any genesis self-vote in the attestation cache would hit this check and be silently dropped as `"source_before_finalized"`. The three other validity checks below (`target_not_after_source`, `target_already_justified`, `target_not_justifiable`) all carry an `is_genesis_self_vote` exemption. The STF drops genesis self-votes silently anyway, so this doesn't affect block validity, but the inconsistency could be surprising.

```suggestion
    let is_genesis_self_vote = is_genesis_self_vote(att_data);
    // The source must not be below the finalized slot. Such a vote, once it
    // justifies its target, drives the finalization scan over pre-finalization
    // slots, which the STF rejects (leanSpec `is_justifiable_after`
    // assert). Exclude these candidates so we never build a block our own
    // state transition would reject.
    if !is_genesis_self_vote && att_data.source.slot < projected_finalized_slot {
        return Err("source_before_finalized");
    }
    if !attestation_data_matches_chain(extended_historical_block_hashes, att_data) {
```

Reviews (1): Last reviewed commit: "docs: improve comments" | Re-trigger Greptile

Comment on lines +305 to 313
// The source must not be below the finalized slot. Such a vote, once it
// justifies its target, drives the finalization scan over pre-finalization
// slots, which the STF rejects (leanSpec `is_justifiable_after`
// assert). Exclude these candidates so we never build a block our own
// state transition would reject.
if att_data.source.slot < projected_finalized_slot {
return Err("source_before_finalized");
}
if !attestation_data_matches_chain(extended_historical_block_hashes, att_data) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The source_before_finalized guard is placed before is_genesis_self_vote is computed, so it applies unconditionally — including to genesis self-votes (source = target = slot 0). When projected_finalized_slot > 0, any genesis self-vote in the attestation cache would hit this check and be silently dropped as "source_before_finalized". The three other validity checks below (target_not_after_source, target_already_justified, target_not_justifiable) all carry an is_genesis_self_vote exemption. The STF drops genesis self-votes silently anyway, so this doesn't affect block validity, but the inconsistency could be surprising.

Suggested change
// The source must not be below the finalized slot. Such a vote, once it
// justifies its target, drives the finalization scan over pre-finalization
// slots, which the STF rejects (leanSpec `is_justifiable_after`
// assert). Exclude these candidates so we never build a block our own
// state transition would reject.
if att_data.source.slot < projected_finalized_slot {
return Err("source_before_finalized");
}
if !attestation_data_matches_chain(extended_historical_block_hashes, att_data) {
let is_genesis_self_vote = is_genesis_self_vote(att_data);
// The source must not be below the finalized slot. Such a vote, once it
// justifies its target, drives the finalization scan over pre-finalization
// slots, which the STF rejects (leanSpec `is_justifiable_after`
// assert). Exclude these candidates so we never build a block our own
// state transition would reject.
if !is_genesis_self_vote && att_data.source.slot < projected_finalized_slot {
return Err("source_before_finalized");
}
if !attestation_data_matches_chain(extended_historical_block_hashes, att_data) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/block_builder.rs
Line: 305-313

Comment:
The `source_before_finalized` guard is placed before `is_genesis_self_vote` is computed, so it applies unconditionally — including to genesis self-votes (source = target = slot 0). When `projected_finalized_slot > 0`, any genesis self-vote in the attestation cache would hit this check and be silently dropped as `"source_before_finalized"`. The three other validity checks below (`target_not_after_source`, `target_already_justified`, `target_not_justifiable`) all carry an `is_genesis_self_vote` exemption. The STF drops genesis self-votes silently anyway, so this doesn't affect block validity, but the inconsistency could be surprising.

```suggestion
    let is_genesis_self_vote = is_genesis_self_vote(att_data);
    // The source must not be below the finalized slot. Such a vote, once it
    // justifies its target, drives the finalization scan over pre-finalization
    // slots, which the STF rejects (leanSpec `is_justifiable_after`
    // assert). Exclude these candidates so we never build a block our own
    // state transition would reject.
    if !is_genesis_self_vote && att_data.source.slot < projected_finalized_slot {
        return Err("source_before_finalized");
    }
    if !attestation_data_matches_chain(extended_historical_block_hashes, att_data) {
```

How can I resolve this? If you propose a fix, please make it concise.

@MegaRedHand
Copy link
Copy Markdown
Collaborator Author

Superseded by #405

@MegaRedHand MegaRedHand closed this Jun 1, 2026
@MegaRedHand MegaRedHand deleted the fix/justifiable-slot-source-below-finalized branch June 1, 2026 14:58
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