Skip to content

fix(blockchain): allow older-but-justified sources in build_block#366

Merged
pablodeymo merged 9 commits into
mainfrom
fix/build-block-justification-gap
May 14, 2026
Merged

fix(blockchain): allow older-but-justified sources in build_block#366
pablodeymo merged 9 commits into
mainfrom
fix/build-block-justification-gap

Conversation

@MegaRedHand
Copy link
Copy Markdown
Collaborator

Summary

Mirrors leanSpec#716. The fixed-point attestation loop in build_block required att.source to equal current_justified exactly. When the store's justified checkpoint has advanced via a sibling fork while the canonical head's chain has only proven an earlier slot, the head chain's pool holds a gap-closing attestation whose source differs from the head's latest_justified checkpoint. The exact-match filter dropped it and block production aborted with JustifiedDivergenceNotClosed ("Fixed-point attestation loop did not converge").

Scenario (from the spec PR)

                       block_4(4) -- block_5(5)  <-- head
                      /
genesis -- 1 -- 2 -- 3
                      \
                       block_6(6)
  • block_4: 6/8 attest target=block_1 (justifies slot 1).
  • block_5: 2/8 attest target=block_4 (fork-choice weight only).
  • block_6 (sibling of block_4): 6/8 attest target=block_2 (justifies slot 2 on the store).

Fork choice picks block_5 as head; the store's latest_justified is block_2, but block_5's post-state still sits at block_1. The proposer at slot 7 builds on block_5 and must absorb block_6's attestation from the gossip pool to close the gap.

Changes

In build_block (crates/blockchain/src/store.rs):

  • Relax the source filter from att.source != current_justified to att.source.slot > current_justified.slot, so older-but-already-justified sources can flow through.
  • Build a chain view that process_block_header would produce on the candidate block (the head's historical_block_hashes plus parent_root at the parent slot plus H256::ZERO for each empty slot between parent and the candidate) and reject attestations whose source.root or target.root do not match it. Prevents pulling in attestations about other forks now that the exact source check is gone.
  • Skip attestations whose target slot is already justified on the chain, with a genesis self-vote (source.slot == 0 && target.slot == 0) exception for fork-choice bootstrapping.
  • Track current_finalized_slot and current_justified_slots alongside current_justified, refreshing them from post_state whenever the STF iteration advances either checkpoint.

In state_transition:

  • Add a public attestation_data_matches_chain(&[H256], &AttestationData) -> bool helper.
  • Make the justified_slots_ops module pub so is_slot_justified can be reused from build_block without duplicating the relative-index logic.

The existing process_attestations path already factored the source/target chain match into checkpoint_exists and is left unchanged.

Test update

build_block_caps_attestation_data_entries previously used fake target roots that don't appear in the chain. Restructured the setup to build a head state at slot 51 with populated historical_block_hashes and vary AttestationData.slot (rather than the target root) to produce distinct entries. Still verifies the MAX_ATTESTATIONS_DATA cap and gossip-size bound.

Test plan

  • cargo fmt --all
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace --release (including forkchoice_spectests, stf_spectests, signature_spectests against the pinned leanSpec fixtures)
  • Multi-client devnet run to confirm no regression in steady-state finalization

Note: the leanSpec spec test for this scenario (test_block_production_justification_gap.py) is not yet in the pinned LEAN_SPEC_COMMIT_HASH. A follow-up commit hash bump in Makefile will pick it up automatically.

Mirrors leanSpec#716. The fixed-point attestation loop in `build_block`
required `att.source == current_justified` exactly. When a sibling fork
advances the store's justified checkpoint past what the canonical head's
state has proven, gap-closing attestations in the head chain's pool were
filtered out because their source did not match the head's
`latest_justified` checkpoint, and block production aborted with
"Fixed-point attestation loop did not converge".

Relax the filter to a slot bound (`source.slot <= current_justified.slot`)
and add the now-required sanity checks:

- chain match: source.root and target.root must match the canonical chain
  at their slots, using a `process_block_header`-style extended view that
  includes parent_root and ZERO_HASH placeholders for empty slots up to
  the candidate slot
- skip attestations whose target slot is already justified on this chain
  (with a genesis self-vote exception for fork-choice bootstrapping)
- track justified_slots and finalized_slot through STF iterations so
  filters reflect the latest post-state

Adds a `attestation_data_matches_chain` helper to the state_transition
crate and re-exports `justified_slots_ops` so it can be reused by
`build_block`. Updates the cap regression test to set up valid chain
context since the new chain-match check rejects fake roots.
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment
The PR correctly implements the relaxed source checkpoint validation (leanSpec #716) and adds chain-matching checks for attestations during block construction. The logic for extending historical block hashes to validate attestation data against the prospective chain state is sound. However, there is a critical arithmetic issue that could cause panics.

Critical Issues

  1. Arithmetic underflow in empty slot calculation (crates/blockchain/src/store.rs:1057)

    let num_empty_slots = (slot - parent_slot - 1) as usize;

    If slot <= parent_slot (e.g., building a block at the same slot as the parent due to a bug or race), this will underflow in debug mode (panic) or wrap in release. Since slot is a function parameter, it should be validated.

    Fix: Use saturating arithmetic or assert preconditions:

    let num_empty_slots = slot.saturating_sub(parent_slot + 1) as usize;
    // Or: assert!(slot > parent_slot, "block slot must be after parent");

Safety & Correctness

  1. Potential truncation in slot indexing (crates/blockchain/state_transition/src/lib.rs:495-496)

    let source_slot = data.source.slot as usize;
    let target_slot = data.target.slot as usize;

    On 32-bit platforms, u64::MAX as usize truncates. While Ethereum slots won't realistically exceed usize::MAX on 32-bit systems during the lifetime of this code, this is an implicit assumption. Consider using try_from or asserting slot < usize::MAX as u64.

  2. Clone performance in justification loop (crates/blockchain/src/store.rs:1148)

    current_justified_slots = post_state.justified_slots.clone();

    This clones the entire JustifiedSlots structure every time justification advances during the iterative block building. If this structure is large (bitmaps or large vectors), this could be expensive. Consider whether a reference or Arc could be used, or if the clone is necessary for the state transition logic (which appears to require ownership).

Consensus Logic Review

  1. Relaxed source checkpoint validation (crates/blockchain/src/store.rs:1072)
    The change from exact checkpoint matching (att_data.source != current_justified) to slot-only comparison (att_data.source.slot > current_justified.slot) combined with attestation_data_matches_chain correctly implements the gap-closing attestation absorption described in the leanSpec. The subsequent chain-matching check ensures the source root is valid for our chain at that slot, preventing inclusion of attestations from conflicting forks that justified earlier slots.

  2. Already-justified slot filtering (crates/blockchain/src/store.rs:1094-1100)
    The logic to skip attestations targeting already-justified slots is a valid optimization, but ensure that justified_slots_ops::is_slot_justified correctly handles boundary conditions near the finalized slot. The genesis self-vote exception (is_genesis_self_vote) is correctly preserved for bootstrapping.

Testing & Maintainability

  1. Test improvements
    The updated test build_block_caps_attestation_data_entries properly constructs a realistic chain state with valid historical_block_hashes, allowing the new chain-matching validation to pass. This is a significant improvement over the previous genesis-only test setup.

Minor Observations

  1. Rust version compatibility
    The use of std::iter::repeat_n (line 1061) requires Rust 1.82+. Ensure the project's rust-toolchain or CI specifies a compatible version.

Summary
The PR is architecturally sound and implements the spec change correctly. Item 1 (arithmetic underflow) must be fixed before merge. Items 2 and 3 are minor but worth addressing for robustness. The consensus logic in Items 4 and 5 appears correct for the 3SF-mini specification.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

PR #366 Review: fix(blockchain): allow older-but-justified sources in build_block

Overview

This mirrors leanSpec#716, fixing a JustifiedDivergenceNotClosed failure in build_block when the store's justified checkpoint has advanced on a sibling fork while the canonical head remains at an earlier justified slot. The fix relaxes the exact source checkpoint match, adds a chain-view validation to prevent cross-fork contamination, and skips attestations whose targets are already justified.


Correctness

Chain view construction (store.rs, extended_historical_block_hashes):

The construction is correct. head_state.historical_block_hashes covers [0, parent_slot - 1], pushing parent_root places it at index parent_slot, then num_empty_slots zero-hashes pad up to slot - 1. Final length = slot, so index i maps to slot i.

Relaxed source filter:

Old: att_data.source != current_justified (exact match)
New: att_data.source.slot > current_justified.slot (slot-bound only)

Root integrity is now handled by the subsequent attestation_data_matches_chain call. Together these checks are equivalent in the normal case and strictly more permissive in the gap-closing scenario described. Correct.

Genesis self-vote exception:

is_genesis_self_vote = source.slot == 0 && target.slot == 0 bypasses the is_slot_justified skip for slot-0 attestations. When current_finalized_slot > 0 the relative-index logic in is_slot_justified may not cover slot 0, so the exception is a necessary guard. Correct.

Finalization tracking in convergence condition:

Adding || post_state.latest_finalized.slot != current_finalized_slot to the re-loop condition is important: finalization shifts the justified_slots window, which changes what is_slot_justified returns. The updated current_justified_slots read from post_state.justified_slots already has shift_window() applied by the STF. Correct.

attestation_data_matches_chain bounds check:

if source_slot >= historical_block_hashes.len() || target_slot >= historical_block_hashes.len() {
    return false;
}

Correctly rejects out-of-range checkpoints. No off-by-one since historical_block_hashes is sized exactly slot (covers [0, slot-1]) and source/target.slot must be < slot. Correct.


Issues

Potential underflow in num_empty_slots (store.rs)

let num_empty_slots = (slot - parent_slot - 1) as usize;

If, due to a caller bug, slot <= parent_slot, this wraps on u64 and produces an enormous allocation. The invariant holds by construction today, but a debug_assert!(slot > parent_slot) here would make the assumption explicit and catch regressions cheaply.

Style: multi-line comment blocks (CLAUDE.md violation)

CLAUDE.md specifies "one short line max" for comments. Several multi-line blocks were added:

  • store.rs — "Chain view that process_block_header would produce…" (4 lines)
  • store.rs — "Relaxed source check (leanSpec #716)…" (4 lines)
  • store.rs — "Skip attestations whose target slot is already justified…" (4 lines)
  • lib.rsattestation_data_matches_chain docstring (multi-paragraph)

These should be collapsed to single-line comments or removed. The WHY is captured in the PR description and the spec reference; the code is readable enough on its own.

Removal of the genesis current_justified.root = parent_root special case

The old code:

let mut current_justified = if head_state.latest_block_header.slot == 0 {
    Checkpoint { root: parent_root, slot: head_state.latest_justified.slot }
} else {
    head_state.latest_justified
};

The new code drops this and relies on attestation_data_matches_chain to validate roots. For genesis, historical_block_hashes is empty so parent_root gets pushed at index 0. An attestation with source = { root: parent_root, slot: 0 } then passes historical_block_hashes[0] == parent_root. The behavior is preserved — but this is a non-obvious interaction worth a single-line comment at the chain-view construction site confirming the genesis case is covered by the general path.


Test Update

The test restructuring is correct and necessary. The previous fake target.root = H256([target_slot as u8; 32]) entries are not in any chain so would be rejected by the new chain-match filter. Varying AttestationData.slot instead still produces 50 distinct data_root hashes (since data_root = data.hash_tree_root()), so the cap and gossip-size bound assertions remain meaningful.

One minor note: hashes[0] is used as both source.root and head.root. Since head.root is also validated against known_block_roots (and hashes[0] is inserted there), this is consistent. Correct.


pub justified_slots_ops

Making this module public is a minimal, justified API change. It avoids duplicating the relative-index logic and the module name signals its scope clearly.


Summary

The logic is correct and the spec-mirror is faithful. Two actionable items:

  1. Add debug_assert!(slot > parent_slot) before the num_empty_slots computation.
  2. Collapse the multi-line comment blocks to single lines per project style (or remove them).

Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. High: store.rs:1089 relaxes the source filter to att_data.source.slot <= current_justified.slot, but the STF still only accepts sources whose slot is actually justified in justified_slots (lib.rs:345). Because the per-block cap is applied before STF execution (store.rs:1082), a peer can fill the pool with validly signed aggregates whose source is historic-but-unjustified; build_block will spend some or all of the 16 AttestationData slots on them, and process_block will silently drop them later. That is a liveness regression in a consensus-critical path. This filter should use is_slot_justified(..., att_data.source.slot), not just <= current_justified.slot.

  2. Medium: store.rs:1062 computes slot - parent_slot - 1 and materializes a synthetic history before any monotonicity or size checks. The STF has those guards later (lib.rs:126, lib.rs:160), but this new prefilter now runs first. If produce_block_with_signatures is ever called with slot <= parent_slot or an excessively large future slot, this can underflow or attempt a huge allocation before returning an error. build_block should perform the same checked slot > parent_slot / HISTORICAL_ROOTS_LIMIT validation before constructing extended_historical_block_hashes.

I couldn’t run cargo test here because the sandbox has no network access and cargo attempted to refresh the crates.io index, so this review is based on diff and local code inspection.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR relaxes the attestation source filter in build_block from an exact checkpoint match to a slot-based comparison, then compensates with an explicit chain-view root check (attestation_data_matches_chain) and a "target already justified" skip guard, allowing gap-closing attestations from sibling forks to flow into block production without breaking cross-fork isolation.

  • store.rs: current_justified pre-computation (including its genesis edge case) is removed; the pre-filter now accepts any source.slot ≤ current_justified.slot, validates source/target roots against a locally-constructed chain view, and tracks both current_finalized_slot and current_justified_slots so the convergence loop stays accurate when finalization, not just justification, advances.
  • state_transition/src/lib.rs: Adds pub fn attestation_data_matches_chain (the shared chain-view validator) and makes justified_slots_ops pub so is_slot_justified can be called from build_block without duplicating relative-index logic.
  • Test: build_block_caps_attestation_data_entries is restructured to build a real head_state at slot 51 with populated historical_block_hashes, replacing the old approach that used fake target roots the new chain-match check would have rejected.

Confidence Score: 4/5

Safe to merge; the relaxed source filter is correctly bounded by chain-view root validation and no new cross-fork attestation vectors are opened.

The core logic change (slot-comparison + attestation_data_matches_chain) is well-reasoned and the STF remains the authoritative gatekeeper. Two minor quality gaps exist: the pre-filter admits zero-root attestations at empty-slot positions that the STF will silently discard, and the genesis self-vote exception can fire after finalization has moved past slot 0, both of which waste attestation budget slots without threatening correctness or liveness in realistic conditions.

attestation_data_matches_chain in state_transition/src/lib.rs — the zero-root guard is worth adding before this ships to a multi-client devnet.

Important Files Changed

Filename Overview
crates/blockchain/src/store.rs Core build_block logic updated: source filter relaxed from exact-match to slot-comparison, chain-view matching added, finalization tracking added, test updated to use a valid head state with real historical_block_hashes.
crates/blockchain/state_transition/src/lib.rs Adds public attestation_data_matches_chain helper and makes justified_slots_ops pub; both changes are minimal and correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[build_block called] --> B{aggregated_payloads empty?}
    B -- yes --> Z[compact & emit block]
    B -- no --> C[Build extended_historical_block_hashes\nhead.hbh ++ parent_root ++ ZERO×empty_slots]
    C --> D[Sort entries by target.slot]
    D --> E[Loop: scan sorted_entries]
    E --> F{data_root already processed?}
    F -- yes --> E
    F -- no --> G{len ≥ MAX_ATTESTATIONS_DATA?}
    G -- yes --> K[break for-loop]
    G -- no --> H{head.root in known_block_roots?}
    H -- no --> E
    H -- yes --> I{source.slot > current_justified.slot?}
    I -- yes --> E
    I -- no --> J{attestation_data_matches_chain?}
    J -- no --> E
    J -- yes --> L{target already justified?\nexcept genesis self-vote}
    L -- yes --> E
    L -- no --> M[Accept: mark processed, add proofs]
    M --> E
    K --> N{found_new?}
    N -- no --> Z
    N -- yes --> O[Run STF: process_slots + process_block]
    O --> P{justified or finalized advanced?}
    P -- yes --> Q[Update current_justified\ncurrent_finalized_slot\ncurrent_justified_slots]
    Q --> E
    P -- no --> Z
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/blockchain/state_transition/src/lib.rs:498-503
The STF's `is_valid_vote` explicitly rejects attestations with a zero root (rule 3: "Neither root is zero hash"), silently skipping them via `continue`. `attestation_data_matches_chain` accepts any attestation whose source or target slot maps to an empty (skipped) slot position, because those positions hold `H256::ZERO`. In practice no honest validator produces zero-root attestations, but if one slips into the pool the pre-filter will admit it, burn one of the 16 `MAX_ATTESTATIONS_DATA` budget slots, and the STF will then silently discard it — leaving a useless entry in the block body.

```suggestion
    if source_slot >= historical_block_hashes.len() || target_slot >= historical_block_hashes.len()
    {
        return false;
    }
    let source_root = historical_block_hashes[source_slot];
    let target_root = historical_block_hashes[target_slot];
    // Mirror the STF's "neither root is zero hash" rule so the pre-filter
    // agrees with is_valid_vote and never wastes an attestation budget slot
    // on an entry the STF will silently discard.
    if source_root == H256::ZERO || target_root == H256::ZERO {
        return false;
    }
    source_root == data.source.root && target_root == data.target.root
```

### Issue 2 of 2
crates/blockchain/src/store.rs:1105-1114
**Genesis self-vote exception persists past finalization**

`is_genesis_self_vote` is true whenever `source.slot == 0 && target.slot == 0`, regardless of the current `current_finalized_slot`. Once finalization has advanced beyond slot 0, `is_slot_justified(slots, finalized_slot > 0, 0)` returns `true` (slot 0 is implicitly finalized), and without the guard the attestation is skipped — which is correct. But if the exception fires while `finalized_slot > 0`, it lets a genesis attestation bypass the "already justified" skip, reserving one of the 16 `MAX_ATTESTATIONS_DATA` slots for an entry that cannot advance the chain. `attestation_data_matches_chain` ensures the roots are valid so there is no safety issue; this is a minor budget-waste risk during the very first epoch of a live chain.

Reviews (1): Last reviewed commit: "fix(blockchain): allow older-but-justifi..." | Re-trigger Greptile

Comment thread crates/blockchain/state_transition/src/lib.rs
Comment thread crates/blockchain/src/store.rs
…atches_chain

Greptile flagged that the helper accepts any attestation whose source or
target slot maps to a skipped-slot position in extended_historical_block_hashes
(those positions hold H256::ZERO). The STF's is_valid_vote then silently
discards the vote as a zero-hash reference, but the entry has already
burned one of the 16 MAX_ATTESTATIONS_DATA budget slots.

Mirror is_valid_vote's "neither root is zero hash" rule in the pre-filter
so process_attestations and build_block agree.
…ata_matches_chain

Mirrors leanSpec#716 follow-up. Replace the duplicated zero-hash guard +
checkpoint_exists pair in is_valid_vote with a single call to the
chain-match helper (which now rejects zero source/target roots inline,
just like the spec). Removes checkpoint_exists and the standalone
zero-hash check from is_valid_vote.

Reorders the helper to test source/target roots for zero before the
slot-bounds check, matching the spec's structure.

No semantic change: is_valid_vote rejects exactly the same attestations,
just through one helper call instead of three branches.
Copy link
Copy Markdown
Collaborator

@pablodeymo pablodeymo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough port of leanSpec #716 — the refactor is clean and the logic matches the spec. Four things I'd like addressed before merge.

Verified locally: cargo fmt --check clean, cargo clippy -D warnings clean, cargo test --workspace --release all green (forkchoice_spectests 84 / stf_spectests 49 / blockchain unit tests).


1. Underflow on num_empty_slots (store.rs:1063)

let num_empty_slots = (slot - parent_slot - 1) as usize;

If slot <= parent_slot, this wraps to a huge u64 → huge usize and repeat_n panics on allocation before the STF gets a chance to reject with Error::ParentSlotIsNewer. In practice produce_block_with_signatures is the only caller and the slot always comes from validator-duty logic so head should be at a lower slot, but the pre-PR build_block didn't compute this upfront and we should keep the same defensive posture as process_block_header's HISTORICAL_ROOTS_LIMIT guard right above it.

Add a StoreError variant:

#[error("Proposal slot {slot} must be strictly after parent slot {parent_slot}")]
InvalidProposalSlot { slot: u64, parent_slot: u64 },

and guard at the top of build_block:

let parent_slot = head_state.latest_block_header.slot;
if slot <= parent_slot {
    return Err(StoreError::InvalidProposalSlot { slot, parent_slot });
}

2. Pre-filter source check is looser than is_valid_vote

if att_data.source.slot > current_justified.slot { continue; }

is_valid_vote requires the source slot to actually be justified (is_slot_justified(state.justified_slots, finalized, source.slot)). The pre-filter accepts any source.slot <= current_justified.slot, including slots the chain passed without justifying. Those would burn a MAX_ATTESTATIONS_DATA budget slot and be dropped by the STF.

We already have current_justified_slots in scope, so:

if !justified_slots_ops::is_slot_justified(
    &current_justified_slots,
    current_finalized_slot,
    att_data.source.slot,
) {
    continue;
}

Worth raising in leanSpec#716 too so the spec eventually tightens to match — otherwise we end up subtly stricter than the reference forever.

3. No Rust test exercises the actual fix

The modified build_block_caps_attestation_data_entries only proves the 16-entry cap. The relaxed source check, the chain-match rejection, and the genesis-self-vote bypass are all untested in this branch.

Concrete sketch — Rust analogue of the spec scenario:

/// Regression test for leanSpec PR #716: build_block must absorb
/// gap-closing attestations whose source is justified on the head
/// chain but older than `latest_justified` (e.g., a sibling fork
/// advanced the store's justified past what the canonical head has
/// proven). Without the relaxed `source.slot <= current_justified.slot`
/// filter, the exact-equality check would drop the attestation and
/// justification would never converge on this chain.
#[test]
fn build_block_absorbs_older_but_justified_source() {
    use ethlambda_types::{
        block::BlockHeader,
        state::{ChainConfig, JustificationValidators, JustifiedSlots},
    };
    use libssz_types::SszList;

    const NUM_VALIDATORS: usize = 50;
    const SUPERMAJORITY: usize = 34; // ceil(2 * 50 / 3)
    const HEAD_SLOT: u64 = 5;
    const JUSTIFIED_SLOT: u64 = 1;
    const GAP_TARGET_SLOT: u64 = 2;

    let validators: Vec<_> = (0..NUM_VALIDATORS)
        .map(|i| ethlambda_types::state::Validator {
            attestation_pubkey: [i as u8; 52],
            proposal_pubkey: [i as u8; 52],
            index: i as u64,
        })
        .collect();

    let hashes: Vec<H256> =
        (0..HEAD_SLOT).map(|i| H256([(i + 1) as u8; 32])).collect();

    let mut justified_slots = JustifiedSlots::new();
    justified_slots_ops::set_justified(&mut justified_slots, 0, JUSTIFIED_SLOT);

    let head_header = BlockHeader {
        slot: HEAD_SLOT,
        proposer_index: 0,
        parent_root: H256::ZERO,
        state_root: H256::ZERO,
        body_root: BlockBody::default().hash_tree_root(),
    };

    let head_state = State {
        config: ChainConfig { genesis_time: 1000 },
        slot: HEAD_SLOT,
        latest_block_header: head_header,
        latest_justified: Checkpoint {
            root: hashes[JUSTIFIED_SLOT as usize],
            slot: JUSTIFIED_SLOT,
        },
        latest_finalized: Checkpoint::default(),
        historical_block_hashes: SszList::try_from(hashes.clone()).unwrap(),
        justified_slots,
        validators: SszList::try_from(validators).unwrap(),
        justifications_roots: Default::default(),
        justifications_validators: JustificationValidators::new(),
    };

    let mut header_for_root = head_state.latest_block_header.clone();
    header_for_root.state_root = head_state.hash_tree_root();
    let parent_root = header_for_root.hash_tree_root();

    let slot = HEAD_SLOT + 1;
    let proposer_index = slot % NUM_VALIDATORS as u64;

    // source = genesis (slot 0): older than head.latest_justified at
    // slot 1. Pre-PR exact-equality filter would drop this; post-PR
    // it's absorbed and the candidate justifies GAP_TARGET_SLOT.
    let att_data = AttestationData {
        slot,
        head: Checkpoint { root: hashes[0], slot: 0 },
        target: Checkpoint {
            root: hashes[GAP_TARGET_SLOT as usize],
            slot: GAP_TARGET_SLOT,
        },
        source: Checkpoint { root: hashes[0], slot: 0 },
    };
    let data_root = att_data.hash_tree_root();

    let mut bits = AggregationBits::with_length(NUM_VALIDATORS).unwrap();
    for i in 0..SUPERMAJORITY {
        bits.set(i, true).unwrap();
    }
    let proof =
        AggregatedSignatureProof::new(bits, SszList::try_from(vec![0xAB; 64]).unwrap());

    let mut aggregated_payloads = HashMap::new();
    aggregated_payloads.insert(data_root, (att_data.clone(), vec![proof]));

    let mut known_block_roots = HashSet::new();
    known_block_roots.insert(parent_root);
    known_block_roots.insert(hashes[0]);

    let (block, _signatures, post_checkpoints) = build_block(
        &head_state,
        slot,
        proposer_index,
        parent_root,
        &known_block_roots,
        &aggregated_payloads,
    )
    .expect("build_block should succeed");

    let targets: Vec<_> = block
        .body
        .attestations
        .iter()
        .map(|att| att.data.target)
        .collect();
    assert!(
        targets.contains(&att_data.target),
        "produced block missing gap-closing attestation: {targets:?}"
    );

    assert_eq!(post_checkpoints.justified.slot, GAP_TARGET_SLOT);
    assert_eq!(
        post_checkpoints.justified.root,
        hashes[GAP_TARGET_SLOT as usize]
    );
}

Bonus: please port the spec's renamed test_build_block_skips_other_chain_source (from the test_state_aggregation.py diff in leanSpec#716) so we have direct coverage of the chain-match rejection too — feeds two payloads with the same source.slot but one whose source.root doesn't match the chain, asserts only the matching one is included.

4. Nits (optional, can be follow-ups)

  • head_state.historical_block_hashes.iter().copied().collect() allocates a full copy. The STF avoids it via std::mem::take. Perf only — not relevant at current chain length.
  • pub mod justified_slots_ops widens the module's surface area more than needed. Only is_slot_justified is used externally. Either pub(crate) mod + pub use the single fn, or leave it if you expect more callers soon.

MegaRedHand and others added 6 commits May 13, 2026 18:50
…t underflow

Two fixes from PR review:

1. `slot.saturating_sub(parent_slot).saturating_sub(1)` instead of raw
   subtraction. If `slot <= parent_slot` (caller bug), the prior code
   wrapped to a huge u64 and `repeat_n` panicked on allocation before
   the STF could reject with `Error::ParentSlotIsNewer`. Now we
   degrade to 0 empty slots and let the STF error normally.

2. Replace the source-slot bound (`source.slot <= current_justified.slot`)
   with `is_slot_justified(source.slot)`. Justification has gaps:
   between finalized and latest_justified, some slots are justified and
   some aren't. The slot bound admitted unjustified-but-≤-current_justified
   sources that the STF then dropped via is_valid_vote, wasting a
   MAX_ATTESTATIONS_DATA budget slot per such entry. The tighter check
   matches the STF predicate exactly. Worth raising upstream on
   leanSpec#716 — the spec PR has the same gap.
Mirrors the scenario from leanSpec#716 in Rust: head state has
latest_justified at slot 1, an attestation with source=genesis
(older but justified) and target=slot 2 sits in the gossip pool.
The pre-PR exact-equality filter dropped it; with the relaxed
is_slot_justified(source.slot) check the attestation is absorbed
and the candidate block's post-state justifies slot 2.

Asserts both the body contains the gap-closing attestation and the
post-state checkpoints advanced past head.latest_justified.
…ild_block

attestation_data_matches_chain now takes an Option<H256> parent_root that
represents one virtual entry at index historical.len(). Lets build_block
pass head_state.historical_block_hashes directly (no clone of up to
HISTORICAL_ROOTS_LIMIT entries) plus parent_root to cover the slot the
candidate block will materialize.

process_attestations passes None: by then the post-state already contains
the parent's entry.

Slots between parent and candidate are still rejected — they'd resolve
to ZERO_HASH in the conceptual extended view, but the zero-hash guard at
the top of the helper drops those attestations first.
@pablodeymo pablodeymo merged commit 1f0a0be into main May 14, 2026
2 checks passed
@pablodeymo pablodeymo deleted the fix/build-block-justification-gap branch May 14, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants