Skip to content

fix: allow older-but-justified sources in build_block#716

Open
MegaRedHand wants to merge 11 commits into
leanEthereum:mainfrom
lambdaclass:fix/include-older-source-attestations
Open

fix: allow older-but-justified sources in build_block#716
MegaRedHand wants to merge 11 commits into
leanEthereum:mainfrom
lambdaclass:fix/include-older-source-attestations

Conversation

@MegaRedHand
Copy link
Copy Markdown
Contributor

@MegaRedHand MegaRedHand commented May 13, 2026

Problem Summary

The fixed-point loop in build_block requires att.source to equal current_justified exactly. When the store's justified checkpoint has advanced via a sibling fork (e.g. block_6 justifying block_2 while the canonical head is on a fork at block_1), while the current head justified an earlier slot, the head chain's pool holds an attestation that would close the gap between them, but its source is different from the head's latest_justified checkpoint so it is filtered out and block production aborts with Fixed-point attestation loop did not converge.

Reproduction

tests/lean_spec/forks/lstar/forkchoice/test_block_production_justification_gap.py builds the scenario explicitly:

                       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 keeps block_5 as head; the store's latest_justified is now block_2, but block_5's post-state still sits at block_1. Producing on slot 7 must close the gap.

PR Description

This PR relaxes that filter to a slot bound: an attestation is selectable as long as its source slot is at or before the head chain's latest justified slot.

- if att_data.source != current_justified:
+ if att_data.source.slot > current_justified.slot:
      continue

Additionally, we also include some other, now required, sanity checks:

  • source.root matches source.slot's expected root on the chain
  • target.root matches target.slot's expected root on the chain
  • the target isn't already justified (otherwise the attestation is useless), but we allow source=target=0 votes for bootstrapping fork-choice

There was a specific test for the check we removed (test_build_block_skips_non_matching_source), which was renamed to test_build_block_skips_other_chain_source and repurposed for the new checks on source and target.

The fixed-point loop required `att.source` to equal `current_justified`
exactly. That rejected legitimate gap-closing attestations whose source
is at or before the head chain's latest justified slot (e.g. a genesis
source when the head chain has already justified slot 1). Block
production aborted with "Fixed-point attestation loop did not converge"
when the store's justified checkpoint advanced via a sibling fork.

Replace the Checkpoint-equality with a slot bound: skip atts whose
source slot is past the current justified slot. This minimum change
unblocks the gap-closing path; the right shape of the broader filter
is left as an open question for review.
Build a fork tree where the canonical head (block_5) lags behind the
store's justified checkpoint (block_2, advanced by sibling block_6).
Assert that produce_block_with_signatures succeeds, the produced
block's post-state catches up to block_2, and the body includes
block_6's gap-closing attestation.
Layer additional filters onto the source-slot bound so build_block
matches process_attestations more closely without re-introducing the
strict source-equality rule:

- Source and target roots must match the chain at their slots
  (historical_block_hashes for [0, parent.slot - 1], parent_root at
  parent.slot, ZERO_HASH for empty slots between parent and the
  candidate). Mirrors the STF's own source/target consistency check.

- Target slot must not already be justified on the chain, with one
  exception: the degenerate source.slot == target.slot == 0 genesis
  self-vote stays selectable so existing fixtures keep working. The
  STF still drops these, but they're tolerated in the block body.

Track the justified-slot bitfield and finalized slot through the
fixed-point loop so the target check stays accurate after each
iteration advances justification or finalization.

With these additions, the previously failing
test_build_block_skips_non_matching_source,
test_block_builder_fixed_point_advances_justification, and
test_block_builder_recovers_finality_after_non_zero_boundary_stall
all pass without modification.
Selecting an attestation already adds its data to processed_att_data,
and the cap check at the top of the loop short-circuits before doing
anything with already-processed entries. The explicit duplicate-check
before the add was redundant.
Trim the explanatory comments around the chain-match and
already-justified checks now that the surrounding code is settled.


def test_build_block_skips_non_matching_source(
def test_build_block_skips_other_chain_source(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The original test's purpose no longer applies, but the actual assertions do, so we renamed it.

Both process_attestations and build_block need to verify that an
attestation's source and target checkpoints reference the chain at
their respective slots. Lift that logic into
_attestation_data_matches_chain so the two call sites share a single
implementation.

build_block constructs the chain view that process_block_header would
produce on the candidate block (parent_state.historical_block_hashes
plus parent_root, plus ZERO_HASH for empty slots) and passes it to
the helper, instead of branching inline on source.slot and
target.slot.
…nature

ty doesn't recognize the SSZ HistoricalBlockHashes container as a
Sequence[Bytes32]. Use the concrete type to satisfy the typechecker
without introducing structural-typing concessions.
@MegaRedHand MegaRedHand marked this pull request as ready for review May 13, 2026 19:06
@MegaRedHand MegaRedHand changed the title fix(forks/lstar): allow older-but-justified sources in build_block fix: allow older-but-justified sources in build_block May 13, 2026
Empty slots between blocks carry ZERO_HASH in historical_block_hashes.
An attestation whose source or target root is ZERO_HASH could
otherwise satisfy the chain-match helper by colliding with one of
those entries, even though it doesn't reference a real block. Inline
the explicit zero-hash rejection into the helper so both
process_attestations and build_block benefit.
The previous filter only bounded source.slot by the latest justified
slot. That admits attestations whose source lies on an unjustified
slot before the latest justified one (gaps inside the bitfield).
Match the STF: skip the attestation unless its source slot is set in
the justified-slots bitfield.
@unnawut unnawut added the specs Scope: Changes to the specifications label May 14, 2026
@unnawut unnawut added this to the pq-devnet-4 milestone May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

specs Scope: Changes to the specifications

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants