fix(blockchain): reject attestation whose head is off the finalized branch (leanSpec #1179)#510
Conversation
…ranch (leanSpec #1179) leanSpec PR #1179 (commit 9033157c7) added a HEAD_NOT_DESCENDANT_OF_FINALIZED rejection rule to attestation validation. Fork choice only ever descends from the latest finalized block, so a vote whose head sits on a branch orphaned by finalization carries no fork-choice weight. Without this rule a vote that finalization pruning has already dropped can be re-admitted when the same aggregate is re-gossiped, instead of being rejected. ethlambda's shared attestation validation (validate_attestation_data, used by both on_gossip_attestation and on_gossip_aggregated_attestation_core) ran availability, topology, consistency, ancestry, and timing checks but never verified the head descends from the finalized block. Add that check mirroring the spec: after the target-ancestor-of-head check, reuse the existing checkpoint_is_ancestor helper against store.latest_finalized(). This is sound because the store re-derives the finalized checkpoint from the head on each update, so it is always an ancestor of the head. Fixes the failing fork-choice spec test test_re_gossip_of_pruned_orphaned_vote_is_rejected.
🤖 Kimi Code ReviewReview: PR #510 - Finalized-Ancestor Check for Attestation Validation Overall: Correct and well-tested. This implements a critical consensus safety check per leanSpec #1179. Consensus Correctness
Minor clarification: Ensure Code Quality
Suggestion: Consider adding the attestation's source checkpoint to the error context for debugging re-org scenarios, though the current fields are sufficient. Test Coverage
Nit: The test uses Security AssessmentThis check prevents a stale attestation replay attack where validators could re-gossip attestations for orphaned branches after finalization pruning has dropped them. Without this check, an attacker could accumulate weight on dead branches, potentially confusing the fork choice of syncing nodes. This is a necessary security boundary. PerformanceThe ancestry check walks back from head to finalized. Since Ethereum finalizes every epoch (32 slots) and the store prunes orphaned branches, the traversal depth is bounded (~32 slots typical, ~8192 slots worst-case). This is acceptable for attestation validation hot path. VerdictApprove with minor suggestion. The implementation is spec-compliant and secure. Consider documenting in the function-level docs that this check ensures // Optional documentation addition at line 227:
/// Ensures the attestation votes on a chain descending from the latest finalized
/// checkpoint, preventing stale votes from affecting fork choice after finalization.
Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
Greptile SummaryThis PR rejects attestations whose head is no longer on the finalized branch. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Adds the finalized-ancestor attestation check, the new rejection error, and focused regression coverage for orphaned-branch votes. |
Reviews (1): Last reviewed commit: "fix(blockchain): reject attestation whos..." | Re-trigger Greptile
🤖 Codex Code Review
Briefly: the new ancestor check itself looks sound, and the added regression test is useful, but it only covers I couldn’t run the Rust test target here because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview: fix(blockchain): reject attestation whose head is off the finalized branch (leanSpec #1179)SummaryThis is a correct, well-targeted fix. It adds the missing Findings1. Doc comment not updated (minor, maintainability) 2. Redundant ancestor walk (minor, performance) Things verified as correct (no action needed)
Nothing here blocks merging; both findings are polish-level. Automated review by Claude (Anthropic) · sonnet · custom prompt |
…(leanSpec #1189) (#511) ## Why The spec-test fixture types silently dropped any JSON field they did not model. When leanSpec adds a new fixture check, ethlambda kept passing while verifying nothing new. That masked drift is exactly how PR **#1189**'s `canonicalEquivocationHeadAmong` (and a dozen other already-shipped fields) landed in the 07-11 fixtures without ethlambda asserting any of them. > **Stacked on #510** (branch `fix/attestation-reject-head-off-finalized`, the `HEAD_NOT_DESCENDANT_OF_FINALIZED` fix that makes all fork-choice fixtures pass). This PR targets that branch and will be **retargeted to `main` once #510 merges**. ## What ### 1. `deny_unknown_fields` hardening (fail loudly on drift) Added `#[serde(deny_unknown_fields)]` to every fixture-facing struct and modeled every field the current (07-11) fixtures actually carry, so a future upstream schema addition fails at parse time instead of being silently ignored. Structs that got `deny_unknown_fields`: - `common.rs`: `Container<T>`, `Config`, `Checkpoint`, `BlockHeader`, `Validator`, `TestState`, `Block`, `BlockBody`, `AggregatedAttestation`, `AggregationBits`, `AttestationData`, `TestInfo`. - `fork_choice.rs`: `ForkChoiceTest`, `ForkChoiceStep`, `AttestationStepData`, `ProofStepData`, `HexByteList`, `BlockStepData`, `StoreChecks`, `AttestationCheck`, plus new `BlockAttestationCheck`, `StoreSnapshot`, `BlockWeightEntry`, `AggregatedPayloadEntry`, `AttestationSignatureEntry`. - `verify_signatures.rs`: `VerifySignaturesTest`, `TestSignedBlock`, `MergedProof`, `HexBytes`. - `state_transition/tests/types.rs`: `StateTransitionTest` (`PostState` already had it). The flatten wrapper vectors (`ForkChoiceTestVector`, etc.) are intentionally left without the attribute (serde forbids `deny_unknown_fields` + `flatten`). The Hive driver's own request wrappers (`StateTransitionRunRequest`/`InitForkChoiceRequest`/`VerifySignaturesRequest`) are also left without it, so a stray *top-level* field (e.g. `_info`) is still tolerated. Note, however, that these wrappers embed the shared fixture types (`TestState`, `Block`, `TestSignedBlock`, `ForkChoiceStep`, …), which this PR *does* harden: any unknown field nested inside `anchorState`/`anchorBlock`/`pre`/`blocks`/`signedBlock` (or a fork-choice step body) now fails deserialization at the Axum handler rather than being silently dropped. This is intentional: silently dropping a replay-relevant field would make the driver report misleading results, so failing loudly on simulator-side schema drift is the safer boundary for a conformance driver. Previously-dropped fields now modeled include: `_info.keySetDigest`, top-level `proofSetting` / `rejectionReason` / `postStateRoot`, per-step `storeSnapshot` / `rejectionReason`, `attestationChecks[].sourceRootLabel`, and the full new `StoreChecks` batch. The fixture crate is a **production dependency of the RPC Hive test-driver**, so this also hardens the binary's parsing of simulator-delivered JSON. The driver returns a store snapshot and applies no `StoreChecks` itself (the leanSpec simulator does the comparisons), so no driver logic changed. ### 2. Newly **asserted** checks in the fork-choice runner | Check | Notes | |-------|-------| | `canonicalEquivocationHeadAmong` | **leanSpec #1189.** Head must sit on the fork whose targeting attestation in the accepted (known) pool carries the largest `hash_tree_root`. Scheme-independent; mirrors `lexicographicHeadAmong`. Covers the 3 equivocation fixtures. | | `latestKnownAggregatedTargetSlots` | Sorted-unique target slots of the known aggregated pool. | | `attestationTargetRootLabel` | Attestation target root resolves to the labeled block. | | `attestationChecks[].sourceRootLabel` | Per-validator source checkpoint root by label. | | `labelsInStore` | Labeled blocks still present in the block tree. | | `reorgDepth` | `ancestors(old_head) \ ancestors(new_head)`. | | `blockAttestations` / `blockAttestationCount` | Per-aggregate participant/slot checks + count on the block imported this step. | Also seeds the block registry with the anchor under the label `"genesis"`, matching leanSpec's harness, so label-based checks resolve (true even for checkpoint-sync anchors past slot 0). ### 3. Parsed but **not** asserted (each carries a `TODO`) `latestNewAggregatedTargetSlots`, `attestationSignatureTargetSlots`, `newPoolProofParticipants`, `storeSnapshot`, `filledBlockRootLabel`. These read the pending (new) aggregated pool, the raw gossip-signature pool, or the identity of a freshly-built block. The offline runner advances ticks **without** the aggregator role and folds block-borne votes straight into the known pool, so those pools/built-block do not track leanSpec's harness. The accepted (known) pool — which the runner does populate — is asserted. `storeSnapshot` is a whole-store snapshot (692 steps) that would need substantial new Store plumbing to compare. ## Verification - `cargo test --release -p ethlambda-blockchain --test forkchoice_spectests` → **122 passed** - `cargo test --release -p ethlambda-state-transition --test stf_spectests` → **74 passed** - `cargo test --release -p ethlambda-blockchain --test signature_spectests` → **3 passed** - `make test` → fully green - `make fmt` / `make lint` (clippy `-D warnings`) clean; whole workspace (incl. Hive driver binary) builds. - Confirmed the `canonicalEquivocationHeadAmong` assertion executes and is meaningful: inverting the winner selection (max→min) fails exactly the 3 equivocation fixtures; poisoning the computed `reorgDepth`/`blockAttestationCount` actuals fails exactly the fixtures carrying those checks.
Root cause
leanSpec PR #1179 (commit
9033157c7, merged 2026-07-04) added aHEAD_NOT_DESCENDANT_OF_FINALIZEDrejection rule to attestation validation. Fork choice only ever descends from the latest finalized block, so a vote whose head sits on a branch orphaned by finalization carries no fork-choice weight. Without this rule, a vote that finalization pruning has already dropped is re-admitted when the same aggregate is re-gossiped, instead of being rejected.ethlambda's shared attestation validation (
validate_attestation_data, used by bothon_gossip_attestationandon_gossip_aggregated_attestation_core) ran availability, topology, consistency, ancestry, and timing checks but never verified that the head descends fromlatest_finalized.Fix
After the target-ancestor-of-head check, verify
store.latest_finalized()is an ancestor of the head checkpoint, reusing the existingcheckpoint_is_ancestorhelper and the already-fetched head header. Adds a newStoreError::HeadNotDescendantOfFinalizedvariant (with head root/slot and finalized root/slot for context).This mirrors leanSpec #1179's semantics and insertion point exactly. It is sound because ethlambda re-derives the store's finalized checkpoint from the head on each head update, so finalized is always an ancestor of the head.
Tests
test_re_gossip_of_pruned_orphaned_vote_is_rejected.validate_attestation_rejects_head_off_finalized_branch(admissible while finalized sits below the fork point; rejected once a sibling finalizes past it).(
make test, full workspace, release)Notes