fix: use head state's justified checkpoint as attestation source#506
Merged
tcoratger merged 4 commits intoleanEthereum:mainfrom Apr 1, 2026
Conversation
produce_attestation_data was using store.latest_justified as the attestation source, while build_block filters attestations against head_state.latest_justified. When store.latest_justified diverges from head_state.latest_justified (caused by processing a non-head-chain block that crosses the 2/3 supermajority threshold), all attestations get filtered out during block building, producing blocks with 0 attestations. Align with the 3sf-mini reference: Staker.vote() uses post_states[head].latest_justified_hash, not a global max. The store-wide latest_justified is only used for fork choice (recompute_head, compute_safe_target), never for voting. Includes genesis root correction matching build_block's existing pattern: the stored genesis state has latest_justified.root = Bytes32.zero(), but attestation validation requires roots that exist in store.blocks.
There was a problem hiding this comment.
Pull request overview
Fixes an attestation-source mismatch that could lead to proposers building blocks with zero attestations when store.latest_justified advances on a non-head fork ahead of head_state.latest_justified.
Changes:
- Switch
produce_attestation_datato use the head state’slatest_justifiedcheckpoint as the attestationsource(aligning voting with block-building filters). - Add a genesis-specific root correction so the produced
source.rootexists instore.blocks(required for attestation validation). - Update the method docstring to document the intended fork-choice vs voting checkpoint usage.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Verifies produce_attestation_data uses head_state.latest_justified (not store.latest_justified) when the two diverge. This scenario happens when a non-head fork advances store.latest_justified, which would cause build_block to filter out all attestations.
Rewrite produce_attestation_data docstring and latest_justified field docs to teach readers the invariant directly rather than referencing past bugs or external implementations. Add line-by-line comments to the unit test explaining each step of the divergence scenario. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fork choice test that creates a justified divergence between the store-wide global max and the head state. A gossip attestation is produced via the store's attestation production path and validated. Fails without the fix: source.slot (1) > target.slot (0) violates the monotonicity invariant. Passes with the fix: source comes from the head state (slot 0) instead of the global max. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tcoratger
approved these changes
Mar 31, 2026
Collaborator
tcoratger
left a comment
There was a problem hiding this comment.
Thanks a lot for noticing the bug here @GrapeBaBa!
I've just pushed an additional commit on top with a dedicated test vector that fails on main branch and that passes here so that client teams can also be sure that this behaviour is fine on their side.
zclawz
pushed a commit
to blockblaz/zeam
that referenced
this pull request
Apr 1, 2026
constructAttestationData was using fcStore.latest_justified (the store-wide global max) as the attestation source. Block building filters attestations against head_state.latest_justified. When a non-head fork crosses the 2/3 supermajority threshold, fcStore.latest_justified can advance past the head chain's view, causing every attestation to fail the source filter and producing 0-attestation blocks. Fix: derive the attestation source from the head state's latest_justified, matching the 3sf-mini reference where Staker.vote() uses post_states[head].latest_justified_hash. Genesis correction: at genesis the head state holds a zero-hash for latest_justified.root (no real justified block yet). Since attestation validation requires the source root to exist in the block registry, substitute the actual genesis block root — the same correction already applied in block building. Also update getProposalAttestationsForHead in forkchoice to filter attestations using the head state's justified checkpoint so that produced attestations are actually included in blocks. Ref: leanEthereum/leanSpec#506
unnawut
reviewed
Apr 1, 2026
4 tasks
pablodeymo
added a commit
to lambdaclass/ethlambda
that referenced
this pull request
Apr 1, 2026
## Summary - Use `head_state.latest_justified` instead of `store.latest_justified()` as attestation source in `produce_attestation_data`, aligning voting with the block builder's filter - Handle genesis edge case (zero-hash root substitution) matching existing `build_block` logic - Add Rust unit test ported from leanSpec PR #506 ## Root Cause `produce_attestation_data` used `store.latest_justified` (the global max across all forks) as the attestation source. `build_block` filters attestations against `head_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](leanEthereum/leanSpec#506), [3sf-mini reference](https://github.com/ethereum/research/blob/master/3sf-mini/p2p.py) (`Staker.vote()` uses `self.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 - [x] Unit test `produce_attestation_data_uses_head_state_justified` passes - [x] `cargo fmt --all -- --check` clean - [x] `cargo clippy -p ethlambda-blockchain -- -D warnings` clean - [ ] Full `make test` with existing fixtures (running)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
produce_attestation_datawas usingstore.latest_justifiedas the attestation source, whilebuild_blockfilters attestations againsthead_state.latest_justified. Whenstore.latest_justifieddiverges fromhead_state.latest_justified(caused by processing a non-head-chain block that crosses the 2/3 supermajority threshold), all attestations get filtered out during block building, producing blocks with 0 attestations.Root Cause
store.latest_justifiedcan advance pasthead_state.latest_justifiedwhen a non-head-chain block's state transition crosses the 2/3 threshold. This creates a mismatch:produce_attestation_datausedstore.latest_justified→ higher checkpointbuild_blockfilters withhead_state.latest_justified→ lower checkpointFix
Align with the 3sf-mini reference design: the attestation source comes from
head_state.latest_justified, not from the store-wide max.Ref: ethereum/research 3sf-mini/p2p.py —
Staker.vote()usesself.post_states[self.head].latest_justified_hashThe store-wide
latest_justified(global max across all processed blocks) is intentionally used only for fork choice (recompute_head,compute_safe_target), never for voting — matchingget_latest_justified_hash(post_states)in the reference.