fix(stf): invalidate blocks with out-of-range justified-slot votes (leanSpec #1023) - #549
fix(stf): invalidate blocks with out-of-range justified-slot votes (leanSpec #1023)#549MegaRedHand wants to merge 2 commits into
Conversation
…eanSpec #1023) `is_slot_justified` read a slot past the tracked justification window as "not justified" via `unwrap_or(false)`, so a vote whose source or target slot sits at or beyond the importing block's own slot was silently skipped and the block accepted. leanSpec raises `JUSTIFIED_SLOT_OUT_OF_RANGE` there and treats the block as invalid, so a crafted block would split ethlambda from spec-strict clients. Honest ethlambda proposers never emit one, since the builder mirrors `is_valid_vote`. `is_slot_justified` now returns `Result<bool, Error>` with a new `Error::JustifiedSlotOutOfRange`, which `is_valid_vote` propagates out of `process_attestations`. The block builder's `entry_passes_filters` keeps the lenient reading on purpose. Its projection is seeded from the head state, whose window stops at `head.slot - 1`, while the chain view it filters against reaches `candidate.slot - 1`; a vote targeting the head block's own slot is routinely untracked there, so propagating would have dropped the dominant attestation shape. It pre-filters gossip candidates rather than deciding validity, and the state transition stays the authority. The spec fixture `test_source_slot_beyond_tracked_range_rejects_block` already passed, but vacuously: the filler zeroes `stateRoot` on an invalid test's final block, so ethlambda rejected on `StateRootMismatch` instead of the real reason. It now rejects with the matching error.
🤖 Kimi Code ReviewOverall Assessment: Correct and well-justified consensus logic. The PR properly distinguishes between strict state transition validation (where out-of-range slots invalidate the block) and conservative gossip pre-filtering (where unknown slots default to "not justified"). Specific Comments:
Minor Suggestions:
Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code ReviewFindings
The rest of the change looks coherent: the STF-side transition from “ignore” to hard-invalid for out-of-window justification slots is internally consistent with the I could not run the Rust tests in this environment. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewGood — errors propagate transparently via I have enough to write the review now. Review: fix(stf) — invalidate blocks with out-of-range justified-slot votes (leanSpec #1023)Overall: Solid, well-scoped fix. The core bug (an out-of-range justification lookup silently read as "not justified" via Correctness
Minor nits (non-blocking)
SecurityThis is a legitimate consensus-safety fix: an attacker could previously craft a block with a vote whose source/target slot lies at or beyond the block's own slot to have it silently accepted by ethlambda while spec-strict clients reject it — a fork-inducing eclipse/split vector. The fix closes that gap without over-correcting the builder's own leniency (which would have broken block packing on the dominant attestation shape, per the PR's explanation). No other concerns — the diff is minimal, well-targeted, and consistent with the crate's existing Automated review by Claude (Anthropic) · sonnet · custom prompt |
| self.finalized_slot, | ||
| att_data.target.slot, | ||
| ) | ||
| .unwrap_or(false) |
There was a problem hiding this comment.
Out-of-range target aborts proposal
When a pooled attestation has an in-range source but targets the proposed block's slot or later, .unwrap_or(false) treats the out-of-range target as eligible. The selected attestation then raises JustifiedSlotOutOfRange in the final state transition, aborting block construction and causing the proposer to miss the slot.
Knowledge Base Used: Blockchain core: fork choice, state transition, block building, sync
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/block_builder.rs
Line: 490
Comment:
**Out-of-range target aborts proposal**
When a pooled attestation has an in-range source but targets the proposed block's slot or later, `.unwrap_or(false)` treats the out-of-range target as eligible. The selected attestation then raises `JustifiedSlotOutOfRange` in the final state transition, aborting block construction and causing the proposer to miss the slot.
**Knowledge Base Used:** [Blockchain core: fork choice, state transition, block building, sync](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethlambda/-/docs/blockchain-core.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Greptile SummaryThis PR makes out-of-range justified-slot lookups explicit and block-invalidating in the state transition while preserving lenient lookup handling in the block builder.
Confidence Score: 4/5This PR should not merge until the builder excludes votes that the changed state transition will reject, preventing malformed pooled attestations from causing missed proposals. An out-of-range target is still considered eligible during proposal selection but now raises an error during the final state transition, aborting construction of the entire block. Files Needing Attention: crates/blockchain/src/block_builder.rs
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/block_builder.rs | Preserves lenient target filtering, which can now select an attestation that aborts the changed final STF. |
| crates/blockchain/state_transition/src/justified_slots_ops.rs | Replaces the out-of-range false fallback with a domain error while retaining implicit justification for finalized slots. |
| crates/blockchain/state_transition/src/lib.rs | Propagates out-of-range source and target lookups so imported blocks containing such votes are rejected. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Pool[Pooled attestation] --> Filter[Builder target filter]
Filter -->|out of range becomes false| Selected[Candidate selected]
Selected --> STF[Final state transition]
STF -->|JustifiedSlotOutOfRange| Abort[Block build aborts]
Prompt To Fix All With AI
### Issue 1
crates/blockchain/src/block_builder.rs:490
**Out-of-range target aborts proposal**
When a pooled attestation has an in-range source but targets the proposed block's slot or later, `.unwrap_or(false)` treats the out-of-range target as eligible. The selected attestation then raises `JustifiedSlotOutOfRange` in the final state transition, aborting block construction and causing the proposer to miss the slot.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "fix(stf): invalidate blocks with out-of-..." | Re-trigger Greptile
Problem
justified_slots_ops::is_slot_justifiedread a slot past the tracked justification window as "not justified" viaunwrap_or(false). Afterprocess_block_headerthat window covers up toblock.slot - 1, so a vote whose source or target slot is at or beyond the importing block's own slot was silently skipped and the block accepted.leanSpec raises
JUSTIFIED_SLOT_OUT_OF_RANGEthere (spec/forks/lstar/containers/state.py, leanSpec #1023) and the block is invalid. A crafted block would therefore split ethlambda from spec-strict clients. Honest ethlambda proposers never emit one, since the builder mirrorsis_valid_vote.Change
state_transition/src/lib.rsError::JustifiedSlotOutOfRange { slot, finalized_slot, tracked_length };is_valid_vote→Result<bool, Error>;process_attestationspropagates with?state_transition/src/justified_slots_ops.rsis_slot_justified→Result<bool, Error>;slots.get(idx).unwrap_or(false)becomes.ok_or(Error::JustifiedSlotOutOfRange { .. })blockchain/src/block_builder.rsentry_passes_filterscall sites explicitly.unwrap_or(false), preserving current behaviorWhy the builder stays lenient
ProjectedState::from_head_statecloneshead_state.justified_slots, whose window stops athead.slot - 1, but the extended chain view it filters against covers up tocandidate.slot - 1. A vote targeting the head block's own slot is therefore routinely untracked in the builder. Propagating the error would have filtered out the dominant attestation shape and killed block packing.entry_passes_filterspre-filters gossip candidates rather than deciding validity, so "untracked ⇒ not justified yet" is the correct reading there; the state transition stays the authority.Note on the existing spec fixture
test_source_slot_beyond_tracked_range_rejects_block(leanSpec #1100) already ships in the fixture bundle and already passed before this change, but vacuously. The filler zeroesstateRooton an invalid test's final block, so ethlambda rejected onStateRootMismatchlong before reaching the real check. The runner only assertsresult.is_err(); it deserializesrejectionReasonbut never compares it.Instrumenting the runner to print actual error vs expected reason confirms the fix now rejects for the right reason:
The same instrumentation showed two other fixtures still passing vacuously through
StateRootMismatchrather than their own check:TOO_MANY_ATTESTATION_DATAandBLOCK_SLOT_MISMATCH. Not addressed here. Making the runner assert onrejectionReasonwould close the whole class and is worth a follow-up.Testing
make fmt,make lint(clippy-D warnings) clean.