Skip to content

Commit a61beb3

Browse files
authored
state: fix bug about duplicate roots in root to slots map (#296)
1 parent 50d3b6c commit a61beb3

File tree

2 files changed

+145
-10
lines changed

2 files changed

+145
-10
lines changed

src/lean_spec/subspecs/containers/state/state.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -421,13 +421,18 @@ def process_attestations(
421421
finalized_slot = latest_finalized.slot
422422
justified_slots = self.justified_slots
423423

424-
# Map roots to their slots for pruning when finalization advances.
425-
# Only track roots after the finalized boundary; earlier roots are pruned.
424+
# Map roots to all slots where they appear.
425+
#
426+
# Missed slots produce duplicate zero hashes in history.
427+
# During pruning, we must check all slot occurrences for each root.
428+
# A single-slot mapping would fail when iterating over slots.
426429
start_slot = int(finalized_slot) + 1
427-
root_to_slot = {
428-
self.historical_block_hashes[i]: Slot(i)
429-
for i in range(start_slot, len(self.historical_block_hashes))
430-
}
430+
root_to_slots: dict[Bytes32, list[Slot]] = {}
431+
for i in range(start_slot, len(self.historical_block_hashes)):
432+
root = self.historical_block_hashes[i]
433+
if root not in root_to_slots:
434+
root_to_slots[root] = []
435+
root_to_slots[root].append(Slot(i))
431436

432437
# Process each attestation independently
433438
#
@@ -550,17 +555,19 @@ def process_attestations(
550555

551556
# Rebase/prune justification tracking across the new finalized boundary.
552557
#
553-
# The state stores `justified_slots` starting at (finalized_slot + 1),
558+
# The state stores justified slot flags starting at (finalized_slot + 1),
554559
# so when finalization advances by `delta`, we drop the first `delta` bits.
555560
#
556-
# We also prune any pending `justifications` whose slots are now finalized.
561+
# We also prune any pending justifications whose slots are now finalized.
562+
# A root may appear at multiple slots; keep the justification if ANY
563+
# slot for that root is still unfinalized (conservative approach).
557564
delta = int(finalized_slot - old_finalized_slot)
558565
if delta > 0:
559566
justified_slots = justified_slots.shift_window(delta)
560567
justifications = {
561568
root: votes
562569
for root, votes in justifications.items()
563-
if root_to_slot.get(root, Slot(0)) > finalized_slot
570+
if any(slot > finalized_slot for slot in root_to_slots.get(root, []))
564571
}
565572

566573
# Convert the vote structure back into SSZ format

tests/lean_spec/subspecs/containers/test_state_justified_slots.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
from lean_spec.subspecs.containers.checkpoint import Checkpoint
1313
from lean_spec.subspecs.containers.slot import Slot
1414
from lean_spec.subspecs.containers.state import State
15-
from lean_spec.types import Uint64
15+
from lean_spec.subspecs.containers.state.types import (
16+
HistoricalBlockHashes,
17+
JustificationRoots,
18+
JustificationValidators,
19+
)
20+
from lean_spec.types import ZERO_HASH, Boolean, Uint64
1621
from tests.lean_spec.helpers import make_aggregated_attestation, make_block, make_validators
1722

1823

@@ -97,3 +102,126 @@ def test_is_slot_justified_raises_on_out_of_bounds() -> None:
97102
State.generate_genesis(Uint64(0), make_validators(1)).justified_slots.is_slot_justified(
98103
Slot(0), Slot(1)
99104
)
105+
106+
107+
def test_duplicate_roots_in_root_to_slots_mapping() -> None:
108+
"""
109+
Verify duplicate block roots are tracked correctly for pruning decisions.
110+
111+
Missed slots produce empty block hashes (zeros).
112+
Multiple missed slots create duplicate entries in the history.
113+
114+
When finalization advances, pending justifications must be pruned.
115+
The pruning logic needs to know which slots each root appears at.
116+
117+
The root-to-slots mapping must store all slots where each root appears.
118+
Otherwise, iteration during pruning fails.
119+
120+
Test strategy:
121+
122+
1. Build a chain with zeros at two slots (simulating missed blocks)
123+
2. Add a pending justification that should survive pruning
124+
3. Trigger finalization to run the pruning logic
125+
4. Verify the pending justification survives correctly
126+
"""
127+
# Two of three validators form a supermajority.
128+
state = State.generate_genesis(genesis_time=Uint64(0), validators=make_validators(3))
129+
130+
# Phase 1: Build a chain and justify slot 1.
131+
#
132+
# We need an existing justified checkpoint before we can test pruning.
133+
134+
state = state.process_slots(Slot(1))
135+
block_1 = make_block(state, Slot(1), attestations=[])
136+
state = state.process_block(block_1)
137+
138+
state = state.process_slots(Slot(2))
139+
block_2 = make_block(state, Slot(2), attestations=[])
140+
source_0 = Checkpoint(root=block_1.parent_root, slot=Slot(0))
141+
target_1 = Checkpoint(root=block_2.parent_root, slot=Slot(1))
142+
att_0_to_1 = make_aggregated_attestation(
143+
participant_ids=[0, 1],
144+
attestation_slot=Slot(2),
145+
source=source_0,
146+
target=target_1,
147+
)
148+
block_2 = make_block(state, Slot(2), attestations=[att_0_to_1])
149+
state = state.process_block(block_2)
150+
151+
assert state.latest_finalized.slot == Slot(0)
152+
assert state.latest_justified.slot == Slot(1)
153+
154+
# Phase 2: Extend chain to populate more history entries.
155+
#
156+
# We need enough slots to inject duplicate roots later.
157+
158+
state = state.process_slots(Slot(3))
159+
block_3 = make_block(state, Slot(3), attestations=[])
160+
state = state.process_block(block_3)
161+
162+
state = state.process_slots(Slot(4))
163+
block_4 = make_block(state, Slot(4), attestations=[])
164+
state = state.process_block(block_4)
165+
166+
state = state.process_slots(Slot(5))
167+
block_5 = make_block(state, Slot(5), attestations=[])
168+
state = state.process_block_header(block_5)
169+
170+
# Phase 3: Inject duplicate roots to simulate missed blocks.
171+
#
172+
# Missed blocks leave zeros in the history.
173+
# Multiple missed blocks create the same root at different slots.
174+
# The pruning logic must handle this case correctly.
175+
176+
slot_3_root = state.historical_block_hashes[3]
177+
modified_hashes = list(state.historical_block_hashes.data)
178+
modified_hashes[2] = ZERO_HASH
179+
modified_hashes[4] = ZERO_HASH
180+
181+
# Register a pending justification for slot 3.
182+
#
183+
# This justification should survive pruning because slot 3
184+
# comes after the finalized boundary.
185+
pending_votes = [Boolean(True), Boolean(False), Boolean(False)]
186+
187+
state = state.model_copy(
188+
update={
189+
"historical_block_hashes": HistoricalBlockHashes(data=modified_hashes),
190+
"justifications_roots": JustificationRoots(data=[slot_3_root]),
191+
"justifications_validators": JustificationValidators(data=pending_votes),
192+
}
193+
)
194+
195+
# Sanity check: zeros at slots 2 and 4, real root at slot 3.
196+
assert state.historical_block_hashes[2] == ZERO_HASH
197+
assert state.historical_block_hashes[4] == ZERO_HASH
198+
assert state.historical_block_hashes[3] == slot_3_root
199+
200+
# Phase 4: Trigger finalization to exercise pruning.
201+
#
202+
# This attestation justifies slot 2 and finalizes slot 1.
203+
# Finalization triggers pruning of stale justifications.
204+
205+
source_1 = Checkpoint(root=state.historical_block_hashes[1], slot=Slot(1))
206+
target_2 = Checkpoint(root=ZERO_HASH, slot=Slot(2))
207+
att_1_to_2 = make_aggregated_attestation(
208+
participant_ids=[0, 1],
209+
attestation_slot=Slot(5),
210+
source=source_1,
211+
target=target_2,
212+
)
213+
214+
# Processing this attestation runs the pruning logic.
215+
#
216+
# Pruning iterates over all slots for each root in history.
217+
# Duplicate roots must map to multiple slots, not just one.
218+
state = state.process_attestations([att_1_to_2])
219+
220+
# Verify finalization succeeded.
221+
assert state.latest_finalized.slot == Slot(1)
222+
assert state.latest_justified.slot == Slot(2)
223+
224+
# The pending justification for slot 3 must survive.
225+
#
226+
# Slot 3 is beyond the finalized boundary, so pruning keeps it.
227+
assert slot_3_root in list(state.justifications_roots)

0 commit comments

Comments
 (0)