Fix attestation source divergence (leanSpec PR #506)#256
Conversation
produce_attestation_data used store.latest_justified (global max across all forks) as the attestation source, while build_block filters by head_state.latest_justified. When a non-head fork block advances the store-wide justified past the head chain's justified, every attestation gets rejected during block building, producing blocks with 0 attestations and stalling justification/finalization indefinitely. Align with the 3sf-mini reference: derive the attestation source from the head state's justified checkpoint. Also handle the genesis edge case where the zero-hash root must be substituted with the real genesis block root, matching the existing logic in build_block. Update LEAN_SPEC_COMMIT_HASH to include the new spec test that exercises the divergence scenario.
🤖 Kimi Code ReviewGeneral Assessment Specific Items
Security Considerations
Readability
Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code ReviewFindings
No other material correctness, security, memory-safety, or performance issues stood out in this diff. The Makefile spec bump looks routine. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewNow I have enough context to write the review. Review: Fix attestation source divergence (leanSpec PR #506)SummaryThe fix correctly addresses the root cause: CorrectnessGenesis substitution alignment — The condition Post-genesis unjustified state — For slots ≥ 2 before any justification occurs, Minor IssuesRedundant storage lookup — Lines 767-770 call // Current:
let head_checkpoint = Checkpoint {
root: head_root,
slot: store
.get_block_header(&head_root)
.expect("head block exists")
.slot,
};
// Could be:
let head_checkpoint = Checkpoint {
root: head_root,
slot: head_state.latest_block_header.slot,
};Genesis substitution duplicated — The same pattern appears verbatim in
Positive Notes
Overall: The core fix is correct and the approach is sound. The redundant Automated review by Claude (Anthropic) · sonnet · custom prompt |
Greptile SummaryThis PR fixes a correctness bug where Key changes:
The one item worth tracking: Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Fixes attestation source divergence by using head-state justified checkpoint instead of store-wide maximum; genesis edge case handled consistently with build_block; minor clamping inconsistency in get_attestation_target |
| Makefile | Bumps LEAN_SPEC_COMMIT_HASH to include the new spec test for gossip attestation accepted after fork advances justified |
Sequence Diagram
sequenceDiagram
participant V as Validator
participant PA as produce_attestation_data
participant GAT as get_attestation_target
participant S as Store
participant BB as build_block
V->>PA: produce_attestation_data(slot)
PA->>S: store.head()
S-->>PA: head_root
PA->>S: store.get_state(&head_root)
S-->>PA: head_state
note over PA: Genesis edge case?<br/>head_state.latest_block_header.slot == 0<br/>→ substitute head_root for H256::ZERO
PA->>GAT: get_attestation_target(store)
GAT->>S: store.head(), walk-back logic
S-->>GAT: target_checkpoint
GAT-->>PA: target_checkpoint
PA-->>V: AttestationData { source: head_state.latest_justified, target, head }
V->>BB: build_block(head_state, attestations)
note over BB: Filter: att.source == head_state.latest_justified ✓<br/>(was store.latest_justified() before fix)
BB-->>V: Block with matched attestations
Comments Outside Diff (1)
-
crates/blockchain/src/store.rs, line 726-733 (link)Target-clamp still uses store-wide justified, inconsistent with new source
After this PR,
produce_attestation_dataderivessourcefromhead_state.latest_justified(the head chain's view), butget_attestation_target's clamping guard still returnsstore.latest_justified()— which can be a checkpoint from a different fork in the exact minority-fork scenario this PR is fixing.If the walk-back in
get_attestation_targettriggers the guard whilestore.latest_justified()is ahead ofhead_state.latest_justifiedand comes from a minority-fork block, the resultingAttestationDatacan end up with:source.root= main-chain head-state justified root (correct head-chain checkpoint)target.root= minority-fork checkpoint root (wrong chain!)
Before this PR the source and clamped target were both
store.latest_justified()(same, wrong-fork checkpoint but internally consistent). After the fix they diverge.In practice the walk-back from a reasonably long head chain is unlikely to fall below the minority-fork's justified slot, and
build_block'sprocess_blockcall would reject such an attestation anyway. However, for full consistency with the new per-chain semantics it is worth tracking whether the clamping guard should also switch tohead_state.latest_justified(while continuing to usestore.latest_justified()only for the warning log).Prompt To Fix With AI
This is a comment left during a code review. Path: crates/blockchain/src/store.rs Line: 726-733 Comment: **Target-clamp still uses store-wide justified, inconsistent with new source** After this PR, `produce_attestation_data` derives `source` from `head_state.latest_justified` (the head chain's view), but `get_attestation_target`'s clamping guard still returns `store.latest_justified()` — which can be a checkpoint from a **different fork** in the exact minority-fork scenario this PR is fixing. If the walk-back in `get_attestation_target` triggers the guard while `store.latest_justified()` is ahead of `head_state.latest_justified` and comes from a minority-fork block, the resulting `AttestationData` can end up with: - `source.root` = main-chain head-state justified root (correct head-chain checkpoint) - `target.root` = minority-fork checkpoint root (wrong chain!) Before this PR the source and clamped target were both `store.latest_justified()` (same, wrong-fork checkpoint but internally consistent). After the fix they diverge. In practice the walk-back from a reasonably long head chain is unlikely to fall below the minority-fork's justified slot, and `build_block`'s `process_block` call would reject such an attestation anyway. However, for full consistency with the new per-chain semantics it is worth tracking whether the clamping guard should also switch to `head_state.latest_justified` (while continuing to use `store.latest_justified()` only for the warning log). How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 726-733
Comment:
**Target-clamp still uses store-wide justified, inconsistent with new source**
After this PR, `produce_attestation_data` derives `source` from `head_state.latest_justified` (the head chain's view), but `get_attestation_target`'s clamping guard still returns `store.latest_justified()` — which can be a checkpoint from a **different fork** in the exact minority-fork scenario this PR is fixing.
If the walk-back in `get_attestation_target` triggers the guard while `store.latest_justified()` is ahead of `head_state.latest_justified` and comes from a minority-fork block, the resulting `AttestationData` can end up with:
- `source.root` = main-chain head-state justified root (correct head-chain checkpoint)
- `target.root` = minority-fork checkpoint root (wrong chain!)
Before this PR the source and clamped target were both `store.latest_justified()` (same, wrong-fork checkpoint but internally consistent). After the fix they diverge.
In practice the walk-back from a reasonably long head chain is unlikely to fall below the minority-fork's justified slot, and `build_block`'s `process_block` call would reject such an attestation anyway. However, for full consistency with the new per-chain semantics it is worth tracking whether the clamping guard should also switch to `head_state.latest_justified` (while continuing to use `store.latest_justified()` only for the warning log).
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Fix attestation source divergence (leanS..." | Re-trigger Greptile
Port of test_produce_attestation_data_uses_head_state_justified from leanSpec PR #506. Verifies that when store.latest_justified diverges from head_state.latest_justified (due to a non-head fork), the attestation source comes from the head state, not the store-wide max.
The leanSpec commit that includes PR #506 also introduces a validator format change (attestationPubkey + proposalPubkey) that requires a domain model migration. Revert the hash to the previous value and defer the fixture update to a separate PR.
Summary
head_state.latest_justifiedinstead ofstore.latest_justified()as attestation source inproduce_attestation_data, aligning voting with the block builder's filterbuild_blocklogicRoot Cause
produce_attestation_datausedstore.latest_justified(the global max across all forks) as the attestation source.build_blockfilters attestations againsthead_state.latest_justified(the head chain's view). When a non-head fork block advances the store-wide justified past the head chain's justified — e.g. a minority fork carrying a supermajority of attestations — every attestation is rejected during block building. This produces blocks with 0 attestations, stalling justification and finalization indefinitely.Ref: leanSpec PR #506, 3sf-mini reference (
Staker.vote()usesself.post_states[self.head].latest_justified_hash)Note on spec test fixtures
The leanSpec commit that includes PR #506 also introduces a validator format change (
pubkey→attestationPubkey+proposalPubkey) that requires a domain model migration. The spec test fixture update is deferred to a separate PR. The unit test covers the same invariant.Test plan
produce_attestation_data_uses_head_state_justifiedpassescargo fmt --all -- --checkcleancargo clippy -p ethlambda-blockchain -- -D warningscleanmake testwith existing fixtures (running)