Use post-block checkpoints for proposer attestation source and target#268
Conversation
The proposer's attestation was created before the block was imported into fork choice, using stale store.latest_justified() and get_attestation_target() values. If the block's attestations advanced justification/finalization during the state transition, the proposer attested with outdated checkpoints. build_block already runs the state transition to compute state_root. Extract post_state.latest_justified and post_state.latest_finalized from that result and use them for the proposer's attestation instead of reading from the store. Same bug as found in Qlean during devnet3 testing.
🤖 Kimi Code ReviewThis PR correctly fixes a consensus-critical bug where the proposer's attestation used stale checkpoint data from the store rather than the post-state checkpoints resulting from processing the block's attestations. Consensus Correctness — Approved Code Quality
Minor Observation
Testing
No security vulnerabilities or memory safety issues introduced. The change improves consensus fidelity and the implementation is idiomatic Rust. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
Greptile SummaryThis PR fixes a finality-lagging bug where the proposer's attestation was built with stale Confidence Score: 5/5Safe to merge — the fix is minimal, correctly targeted, and all 97 tests pass. The change threads already-computed checkpoint values from No files require special attention.
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Adds PostBlockCheckpoints struct, extracts it from build_block's final post_state, and refactors get_attestation_target into a thin wrapper over the new get_attestation_target_with_checkpoints. Logic is sound and well-commented. |
| crates/blockchain/src/lib.rs | Updates propose_block to destructure post_checkpoints from produce_block_with_signatures and use post_checkpoints.justified for source and the parameterized target helper. Change is minimal and correctly scoped. |
Sequence Diagram
sequenceDiagram
participant PB as propose_block
participant S as store (produce_block_with_signatures)
participant BB as build_block
participant ST as state_transition
PB->>S: produce_block_with_signatures(slot, validator_id)
S->>BB: build_block(head_state, slot, ...)
BB->>ST: process_slots(&mut post_state, slot)
BB->>ST: process_block(&mut post_state, &final_block)
note over BB: post_state.latest_justified / .latest_finalized<br/>reflect attestations in the block
BB-->>S: (final_block, signatures, PostBlockCheckpoints{justified, finalized})
S-->>PB: (block, attestation_signatures, post_checkpoints)
PB->>PB: get_attestation_target_with_checkpoints(store, post_checkpoints.justified, post_checkpoints.finalized)
note over PB: target walk-back uses post-block finalized slot<br/>and clamps to post-block justified checkpoint
PB->>PB: Attestation { source: post_checkpoints.justified, target: ... }
note over PB: Fresh checkpoints — no finality lag
PB->>PB: process_block(signed_block) [import into store]
Reviews (1): Last reviewed commit: "Use post-block checkpoints for proposer ..." | Re-trigger Greptile
🤖 Codex Code ReviewNo findings. The patch looks correct: it now derives the proposer attestation’s Residual risk is test coverage, not code correctness: I could not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is my review of PR #268: Review: Use post-block checkpoints for proposer attestation source and targetSummaryThe fix is correct and well-motivated. The PR resolves a real consensus bug: the proposer was embedding a stale CorrectnessCore logic is sound.
Justified-slot source alignment. Previously, Issues / Observations1. Walk-back still starts from the parent block, not the new block ( let mut target_block_root = store.head(); // = parent block, new block not imported yetThe proposer attests 2. The struct is 3.
4. Potential double state transition when loop runs ( When 5. Test uses let (block, signatures, _post_checkpoints) = build_block(...)This is idiomatic and intentionally signals "unused." Fine as-is; no test coverage of the returned checkpoints is needed since the value is exercised by the VerdictApprove. The fix is correct, minimal, and well-documented. The PR description accurately describes the ordering constraint and why simple reordering isn't possible. Items 2–3 above are minor polish suggestions, not blockers. Automated review by Claude (Anthropic) · sonnet · custom prompt |
Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
…rt to test harness Resolve merge conflict in lib.rs: keep phase4's block-root signing (proposer attestation removed in devnet4), drop devnet4's post-block checkpoint attestation (PR #268) since the feature it improves no longer exists. Add attestation and gossipAggregatedAttestation step handlers to the fork choice test harness, with on_gossip_attestation_without_verification() that validates data, checks validator index, inserts into known payloads, and recalculates head. Patch leanSpec's test_lexicographic_tiebreaker to use single-depth forks (backport of upstream fix e545e14) and remove same-slot constraint from the lexicographicHeadAmong validation. Skip test_gossip_attestation_with_invalid_signature (no sig verification in tests). 7 headSlot-mismatch failures remain from fixture expectation changes between leanSpec d39d101 and 9c30436 - to be investigated separately.
Motivation
A bug was found during devnet3 testing in the Qlean client (C++ implementation) where finality was lagging more than 3 slots behind head. The root cause was identified through structured log analysis: the proposer's attestation was computed before the block was imported into fork choice, causing the attestation to carry stale
source(justified) andtargetcheckpoint values.The ordering bug across clients
When a proposer builds a block containing attestations that advance justification/finalization, the state transition updates these checkpoints. The question is: does the proposer's own attestation reflect the pre-import or post-import checkpoint values?
Qlean (buggy ordering):
Zeam (correct ordering):
ethlambda (was buggy, same as Qlean):
Why we can't just reorder
In ethlambda (and Qlean), the proposer attestation is embedded inside
SignedBlockWithAttestation→BlockWithAttestation. It's part of the block's hash tree root and required for signature verification during import. So unlike Zeam's architecture (where block and attestation are separate), we cannot import the block first and then create the attestation — it's a chicken-and-egg problem.Description
The fix exploits the fact that
build_block()already runs the full state transition to computestate_root. The resultingpost_statecontainslatest_justifiedandlatest_finalizedafter processing the block's attestations — but these values were previously discarded.Changes
PostBlockCheckpointsstruct — carries the two checkpoints (justified+finalized) frombuild_blockback to the caller.build_block/produce_block_with_signatures— returnPostBlockCheckpointsalongside the block, extracted from the post-state transition result. No extra computation — just plumbing values that were already computed.get_attestation_target_with_checkpoints— parameterized version ofget_attestation_targetthat accepts justified/finalized checkpoints instead of reading from the store. The existingget_attestation_targetbecomes a thin wrapper that delegates with store values.propose_block— usespost_checkpoints.justifiedassourceand callsget_attestation_target_with_checkpointswith post-block checkpoints fortarget.Behavior
post_state.latest_justified == head_state.latest_justified, so behavior is identical to before. No regression.How to Test
Full devnet validation is recommended to verify finality behavior under load.