Skip to content

Commit e9faefa

Browse files
tcoratgerclaude
andauthored
fix(forks/lstar): harden build_block filter against crashes and duplicates (leanEthereum#718)
* fix(forks/lstar): harden build_block filter against crashes and duplicates Reorder filters so chain-match runs first. A source slot beyond the candidate slot no longer crashes the producer with IndexError on the justified-slot bitfield. Restore the processed_att_data dedup guard. Without it, attestations whose target fails to justify get re-selected on every fixed-point iteration and appended as duplicates to the candidate block body. Add state-transition parity filters with a genesis-anchor bypass. Target must be strictly after source. Target must fall on a slot the state transition accepts. Without these, gossip-pool entries the STF will silently drop can starve real votes out of MAX_ATTESTATIONS_DATA. Make the chain-match helper a staticmethod. Swap its parameter order to (data, chain). Widen its chain parameter to Sequence[Bytes32] so callers can pass plain lists without reconstructing SSZ list instances. Use full-Checkpoint equality in the new test per testing-style.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(forks/lstar): drop STF-parity filters that strip fork-choice signal Remove two filters added in the previous commit: - target.slot > source.slot - target.slot.is_justifiable_after(current_finalized_slot) Both are enforced by the state transition, but the state transition is not the only consumer of body attestations. Block processing also merges every body attestation into the known aggregated payload pool, which is what fork choice reads for head votes. Filtering these entries at production time hides head votes that the producer's chain would otherwise have seen. The MAX_ATTESTATIONS_DATA cap test and the deep-fork-split / reorg tests rely on this behavior: without the filters they pass, with them they shrink the body or move the head away from the expected subtree. The chain-match-first reorder, the processed_att_data dedup guard, the target-already-justified check with its genesis self-vote bypass, and the helper signature changes are unaffected and remain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 00556d8 commit e9faefa

2 files changed

Lines changed: 120 additions & 119 deletions

File tree

src/lean_spec/forks/lstar/spec.py

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Lstar fork — identity and construction facade."""
22

33
from collections import defaultdict
4-
from collections.abc import Iterable
4+
from collections.abc import Iterable, Sequence
55
from collections.abc import Set as AbstractSet
66
from typing import Any, ClassVar
77

@@ -354,29 +354,41 @@ def process_block(self, state: State, block: Block) -> State:
354354

355355
return self.process_attestations(state, block.body.attestations)
356356

357+
@staticmethod
357358
def _attestation_data_matches_chain(
358-
self,
359-
historical_block_hashes: HistoricalBlockHashes,
360359
attestation_data: AttestationData,
360+
historical_block_hashes: Sequence[Bytes32],
361361
) -> bool:
362-
"""Whether both source and target checkpoints match the chain at their slots.
362+
"""Check that source and target checkpoints point to blocks on a chain.
363363
364-
Callers pass the chain's historical block hashes as they would appear
365-
after process_block_header on the consuming block: covering
366-
[0, block.slot - 1] with parent_root at parent.slot and ZERO_HASH
367-
for empty slots between parent and the candidate.
364+
Args:
365+
attestation_data: The attestation being validated.
366+
historical_block_hashes: Chain view indexed by slot.
367+
Empty slots carry the zero hash.
368368
369-
Empty slots carry ZERO_HASH; a vote referencing one is rejected here
370-
even when the recorded root happens to compare equal.
369+
Returns:
370+
True when both checkpoint roots match the chain at their slot.
371+
False when either root is the zero hash.
372+
False when either checkpoint slot is past the end of the chain view.
371373
"""
374+
# Reject zero-hash checkpoints up front.
375+
#
376+
# Empty slots carry the zero hash on the chain.
377+
# A vote whose recorded root equals the zero hash is meaningless.
372378
if attestation_data.source.root == ZERO_HASH or attestation_data.target.root == ZERO_HASH:
373379
return False
380+
381+
# Reject checkpoints whose slot is beyond the chain view.
382+
#
383+
# Without this guard, indexed access raises IndexError.
374384
source_slot = int(attestation_data.source.slot)
375385
target_slot = int(attestation_data.target.slot)
376386
if source_slot >= len(historical_block_hashes):
377387
return False
378388
if target_slot >= len(historical_block_hashes):
379389
return False
390+
391+
# Both checkpoint roots must match the chain at their slot.
380392
return (
381393
attestation_data.source.root == historical_block_hashes[source_slot]
382394
and attestation_data.target.root == historical_block_hashes[target_slot]
@@ -472,10 +484,15 @@ def process_attestations(
472484
continue
473485

474486
# Ensure the vote refers to blocks that actually exist on our chain.
475-
# Prevents votes about unknown or conflicting forks; also rejects
476-
# zero-hash source or target roots inline.
487+
#
488+
# The attestation must match our canonical chain.
489+
# Both the source root and target root must equal the recorded block roots.
490+
# The recorded roots are the ones stored for those slots in history.
491+
#
492+
# This prevents votes about unknown or conflicting forks.
493+
# It also rejects zero-hash source or target roots.
477494
if not self._attestation_data_matches_chain(
478-
state.historical_block_hashes, attestation.data
495+
attestation.data, state.historical_block_hashes.data
479496
):
480497
continue
481498

@@ -676,21 +693,24 @@ def build_block(
676693
else:
677694
current_justified = state.latest_justified
678695

679-
# Track the justified-slot bitfield so we can skip attestations
680-
# whose target slot is already justified on this chain. Extend
681-
# so is_slot_justified doesn't raise for target slots between
682-
# parent.slot and slot - 1.
696+
# Track the justified-slot bitfield to skip already-justified targets.
697+
#
698+
# Extend the bitfield to cover every slot we might query.
699+
# The range runs from the finalized boundary up to slot - 1 inclusive.
683700
current_finalized_slot = state.latest_finalized.slot
684701
current_justified_slots = state.justified_slots.extend_to_slot(
685702
current_finalized_slot, slot - Slot(1)
686703
)
687704

688-
# Build the chain view that process_block_header would produce on
689-
# the candidate block. Lets the chain-match helper validate source
690-
# and target roots without waiting for the STF to drop mismatches.
705+
# Build the chain view as it will appear on the candidate block.
706+
#
707+
# The view is the recorded history up to the parent.
708+
# Then comes the parent root at the parent's slot.
709+
# Then zero-hash entries for any skipped slots up to the new block.
710+
# The chain-match helper uses this view to validate source and target roots.
691711
num_empty_slots = int(slot - state.latest_block_header.slot - Slot(1))
692-
extended_historical_block_hashes = (
693-
state.historical_block_hashes + [parent_root] + [ZERO_HASH] * num_empty_slots
712+
extended_historical_block_hashes: list[Bytes32] = (
713+
list(state.historical_block_hashes) + [parent_root] + [ZERO_HASH] * num_empty_slots
694714
)
695715

696716
processed_att_data: set[AttestationData] = set()
@@ -701,32 +721,46 @@ def build_block(
701721
for att_data, proofs in sorted(
702722
aggregated_payloads.items(), key=lambda item: item[0].target.slot
703723
):
704-
if (
705-
Uint8(len(processed_att_data)) >= MAX_ATTESTATIONS_DATA
706-
and att_data not in processed_att_data
707-
):
724+
if att_data in processed_att_data:
725+
continue
726+
727+
if Uint8(len(processed_att_data)) >= MAX_ATTESTATIONS_DATA:
708728
break
709729

710730
if att_data.head.root not in known_block_roots:
711731
continue
712732

713-
# Source slot must already be justified on this chain.
714-
if not current_justified_slots.is_slot_justified(
715-
current_finalized_slot, att_data.source.slot
733+
# Chain-match runs first.
734+
#
735+
# It rejects checkpoints whose slot is past the chain view.
736+
# That prevents the bounded queries below from indexing out of range.
737+
if not self._attestation_data_matches_chain(
738+
att_data, extended_historical_block_hashes
716739
):
717740
continue
718741

719-
if not self._attestation_data_matches_chain(
720-
extended_historical_block_hashes, att_data
742+
# The source slot must already be justified on this chain.
743+
if not current_justified_slots.is_slot_justified(
744+
current_finalized_slot, att_data.source.slot
721745
):
722746
continue
723747

724-
# Skip attestations whose target slot is already
725-
# justified on this chain. Ignore genesis self-votes
726-
# for fork-choice bootstrapping
727-
is_genesis_self_vote = att_data.source.slot == Slot(
728-
0
729-
) and att_data.target.slot == Slot(0)
748+
# Genesis-anchored votes have source.slot = target.slot = 0.
749+
#
750+
# They cannot advance justification: the state transition drops them.
751+
# They still carry head-vote weight for fork choice.
752+
# Including them in the body propagates them into peers' payload pool.
753+
# The bypass below keeps them past the target-already-justified check,
754+
# since slot 0 is implicitly justified and would otherwise filter them.
755+
is_genesis_self_vote = att_data.source.slot == Slot(0) and (
756+
att_data.target.slot == Slot(0)
757+
)
758+
759+
# Skip attestations whose target slot is already justified.
760+
#
761+
# Justification adds nothing for them.
762+
# Entries the state transition will later drop are still kept here.
763+
# They carry head-vote weight for fork choice.
730764
if not is_genesis_self_vote and current_justified_slots.is_slot_justified(
731765
current_finalized_slot, att_data.target.slot
732766
):
@@ -763,6 +797,11 @@ def build_block(
763797
)
764798
post_state = self.process_block(self.process_slots(state, slot), candidate_block)
765799

800+
# Re-run the filter when justification or finalization advanced.
801+
#
802+
# Both quantities are monotonic in 3SF-mini, so the loop is bounded.
803+
# Finalization advancement shifts the justified window forward.
804+
# That can unlock attestations whose target slot was outside it before.
766805
if (
767806
post_state.latest_justified != current_justified
768807
or post_state.latest_finalized.slot != current_finalized_slot
Lines changed: 44 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
"""
2-
Block production closes the justification gap on a canonical head that lags.
3-
4-
The scenario builds a fork tree where one branch advances the store's justified
5-
checkpoint past the level that the canonical head's chain has proven. The
6-
fixed-point attestation loop inside build_block picks up the gap-closing
7-
attestation from the gossip pool and produces a block whose post-state
8-
catches up to the store.
9-
"""
1+
"""Block production closes the justification gap when the canonical head lags."""
102

113
from __future__ import annotations
124

@@ -28,63 +20,45 @@ def test_produce_block_on_head_with_lagging_justification(
2820
keyed_genesis_block: Block,
2921
key_manager: XmssKeyManager,
3022
) -> None:
31-
"""
32-
Closing the justification gap via the fixed-point attestation loop.
23+
r"""Producer on a lagging head pulls a sibling's attestation to catch up.
3324
34-
Fork tree (labels are block names; slot numbers shown in parentheses)::
25+
Fork tree::
3526
36-
block_4(4) -- block_5(5) <-- head
37-
/
27+
block_4(4) -- block_5(5) <-- head
28+
/
3829
genesis -- 1 -- 2 -- 3
39-
\
40-
block_6(6)
41-
42-
Setup:
43-
44-
- block_4 carries 6 of 8 attestations targeting block_1, justifying block_1.
45-
- block_5 carries 2 attestations (validators 6, 7) with head=block_4. These
46-
give block_5's subtree fork-choice weight against block_6.
47-
- block_6 (sibling of block_4) carries 6 of 8 attestations targeting block_2.
48-
Block_6 alone moves the store's latest_justified to block_2.
49-
50-
After all six blocks are processed:
51-
52-
- store.latest_justified points at block_2 (slot 2)
53-
- fork choice still picks block_5 as the head:
54-
validators 0-5 vote head=block_2 (a common ancestor)
55-
validators 6-7 vote head=block_4 (in block_5's subtree)
56-
weight on block_3's child block_4 is 2; weight on block_6 is 0
57-
58-
Proposing at slot 7 builds on block_5, whose post-state has
59-
latest_justified=block_1. The gossip pool holds block_6's attestation
60-
(source=genesis, target=block_2). The fixed-point loop accepts that
61-
attestation because genesis is justified on block_5's chain and
62-
block_2 is on the common ancestor segment, so the produced block's
63-
post-state catches up to the store's justified checkpoint.
30+
\
31+
block_6(6)
32+
33+
The head branch only justifies block_1.
34+
The sibling branch justifies block_2 and pushes the store ahead of the head.
35+
Producing on top of the head must reuse the sibling's attestation to close the gap.
6436
"""
6537
store = keyed_store
6638
block_registry: dict[str, Block] = {"genesis": keyed_genesis_block}
6739

6840
def add_block(block_spec: BlockSpec) -> None:
41+
"""Build the spec'd block on the current store and apply it."""
6942
nonlocal store
7043
signed_block = block_spec.build_signed_block_with_store(
7144
store, block_registry, key_manager, "test"
7245
)
7346
if block_spec.label is not None:
7447
block_registry[block_spec.label] = signed_block.block
75-
# Re-align store time with the block's slot before processing.
76-
# build_signed_block_with_store already ticks forward; this second tick
77-
# is idempotent and mirrors the fork-choice spec-test fixture.
48+
# The block builder helper ticks a local store copy and discards it.
49+
# The outer store therefore still sits at genesis time.
50+
# Without this tick, the block is rejected as too far in the future.
7851
target_interval = Interval.from_slot(signed_block.block.slot)
7952
store, _ = spec.on_tick(store, target_interval, has_proposal=True, is_aggregator=True)
8053
store = spec.on_block(store, signed_block)
8154

82-
# Linear chain through block_3.
55+
# Phase 1: linear chain genesis -> block_1 -> block_2 -> block_3.
8356
add_block(BlockSpec(slot=Slot(1), label="block_1"))
8457
add_block(BlockSpec(slot=Slot(2), label="block_2"))
8558
add_block(BlockSpec(slot=Slot(3), label="block_3"))
8659

87-
# block_4: 6 of 8 validators attest target=block_1, justifying block_1.
60+
# Phase 2a: block_4 carries 6/8 votes for target=block_1.
61+
# Crosses the 2/3 threshold, so block_1 becomes justified.
8862
add_block(
8963
BlockSpec(
9064
slot=Slot(4),
@@ -100,12 +74,12 @@ def add_block(block_spec: BlockSpec) -> None:
10074
],
10175
)
10276
)
103-
assert store.latest_justified.slot == Slot(1)
104-
assert store.latest_justified.root == hash_tree_root(block_registry["block_1"])
77+
block_1_root = hash_tree_root(block_registry["block_1"])
78+
assert store.latest_justified == Checkpoint(root=block_1_root, slot=Slot(1))
10579

106-
# block_5: validators 6, 7 attest target=block_4 with head=block_4.
107-
# The head vote pulls fork-choice weight into block_5's subtree without
108-
# advancing justification.
80+
# Phase 2b: block_5 carries 2/8 head votes for block_4.
81+
# Below the 2/3 threshold, so justification does not advance.
82+
# The votes pull fork-choice weight into block_5's subtree.
10983
add_block(
11084
BlockSpec(
11185
slot=Slot(5),
@@ -121,11 +95,11 @@ def add_block(block_spec: BlockSpec) -> None:
12195
],
12296
)
12397
)
124-
assert store.latest_justified.slot == Slot(1)
98+
assert store.latest_justified == Checkpoint(root=block_1_root, slot=Slot(1))
12599

126-
# block_6: sibling of block_4 (parent=block_3). 6 of 8 validators attest
127-
# target=block_2 with source=genesis. After processing, the store's
128-
# latest_justified advances to block_2.
100+
# Phase 3: block_6 (sibling of block_4) carries 6/8 votes for target=block_2.
101+
# The store learns block_2 is justified.
102+
# block_5's chain still has latest_justified = block_1: the divergence.
129103
add_block(
130104
BlockSpec(
131105
slot=Slot(6),
@@ -142,46 +116,34 @@ def add_block(block_spec: BlockSpec) -> None:
142116
)
143117
)
144118

145-
# Sanity: block_5 is the head and the store's justified is block_2.
146-
# Validators 0-5's latest vote is for head=block_2 (common ancestor).
147-
# Validators 6, 7's latest vote is for head=block_4 (in block_5's subtree).
148-
# block_4's subtree carries weight 2; block_6's subtree carries weight 0.
149-
assert store.head == hash_tree_root(block_registry["block_5"])
150-
assert store.latest_justified.slot == Slot(2)
151-
assert store.latest_justified.root == hash_tree_root(block_registry["block_2"])
152-
153-
# block_6's body attestation justifying block_2 is merged into the
154-
# store's known aggregated payload pool. Its source is genesis (the
155-
# latest_justified at block_6's parent, block_3), so the filter must
156-
# match on source-slot-justified, not full Checkpoint equality, to be
157-
# able to reuse it from block_5's chain.
119+
# Pre-condition: head is block_5, but the store's justified is ahead at block_2.
120+
# Validators 0-5 vote head=block_2 (a common ancestor); validators 6-7 vote head=block_4.
121+
# block_5's subtree wins fork choice with weight 2 vs 0.
158122
genesis_root = hash_tree_root(keyed_genesis_block)
159123
block_2_root = hash_tree_root(block_registry["block_2"])
124+
block_2_checkpoint = Checkpoint(root=block_2_root, slot=Slot(2))
125+
assert store.head == hash_tree_root(block_registry["block_5"])
126+
assert store.latest_justified == block_2_checkpoint
127+
128+
# The gap-closing attestation is in the pool: source=genesis, target=block_2.
129+
# Its source is NOT block_5's latest_justified (which is block_1).
130+
# The filter must accept it on source-slot-justified, not full-Checkpoint equality.
160131
block_6_target_atts = [
161-
att
162-
for att in store.latest_known_aggregated_payloads
163-
if att.target.root == block_2_root and att.target.slot == Slot(2)
132+
att for att in store.latest_known_aggregated_payloads if att.target == block_2_checkpoint
164133
]
165134
assert len(block_6_target_atts) == 1
166135
assert block_6_target_atts[0].source == Checkpoint(root=genesis_root, slot=Slot(0))
167136
assert block_6_target_atts[0].slot == Slot(6)
168137

169-
# Propose at slot 7 on top of block_5 (the head). The fixed-point loop
170-
# picks up block_6's attestation (source=genesis matches the chain at
171-
# slot 0) and advances the produced block's justified checkpoint to
172-
# block_2 to match the store.
138+
# Propose at slot 7 on top of block_5.
139+
# The block builder picks up the gap-closing attestation and advances justification.
173140
new_store, new_block, _ = spec.produce_block_with_signatures(store, Slot(7), ValidatorIndex(7))
174141

175-
# The store's justified checkpoint stays at block_2; the new block
176-
# closes the gap rather than leaving the producer behind.
177-
block_2_checkpoint = Checkpoint(root=block_2_root, slot=Slot(2))
178-
assert new_store.latest_justified == block_2_checkpoint
179-
180-
# The new block's post-state caught up to the store's justified checkpoint.
142+
# The produced block's post-state caught up to the store's justified checkpoint.
143+
# Its body carries the attestation that closed the gap.
181144
new_block_root = hash_tree_root(new_block)
145+
body_targets = [att.data.target for att in new_block.body.attestations]
146+
assert new_store.latest_justified == block_2_checkpoint
182147
assert new_block.parent_root == hash_tree_root(block_registry["block_5"])
183148
assert new_store.states[new_block_root].latest_justified == block_2_checkpoint
184-
185-
# The produced block must include the attestation that justifies block_2.
186-
body_targets = [att.data.target for att in new_block.body.attestations]
187149
assert block_2_checkpoint in body_targets

0 commit comments

Comments
 (0)