diff --git a/src/lean_spec/spec/forks/lstar/spec.py b/src/lean_spec/spec/forks/lstar/spec.py index 3a39dead..97db4b81 100644 --- a/src/lean_spec/spec/forks/lstar/spec.py +++ b/src/lean_spec/spec/forks/lstar/spec.py @@ -522,13 +522,16 @@ def process_attestations( # Finalization requires a continuous chain of trust from the # previously finalized checkpoint up to the new justified point. # - # If every slot in between is justifiable relative to the old - # finalized point, then the earlier source checkpoint becomes finalized. + # Finalization advances only when the source lies past the old finalized point. + # A source at or behind that boundary is already final. + # Such a source may still justify a newer target, but it must not re-finalize. + # When the source is newer and every slot in between is justifiable + # relative to that old finalized point, the source checkpoint becomes finalized. # # In short: # # If there is no break in the chain, advance finalization. - if not any( + if source.slot > finalized_slot and not any( Slot(slot).is_justifiable_after(finalized_slot) for slot in range(source.slot + Slot(1), target.slot) ): diff --git a/tests/consensus/lstar/state_transition/test_finalization.py b/tests/consensus/lstar/state_transition/test_finalization.py index 438fa460..42e13c70 100644 --- a/tests/consensus/lstar/state_transition/test_finalization.py +++ b/tests/consensus/lstar/state_transition/test_finalization.py @@ -493,6 +493,256 @@ def test_finalization_prunes_stale_pending_votes_and_rebases_window( ) +def test_stale_finalized_source_justifies_without_rewinding_finalization( + state_transition_test: StateTransitionTestFiller, +) -> None: + """ + Test the post-state when a vote uses a source behind the finalized boundary. + + Scenario + -------- + 1. Finalize block_4 through ordinary supermajority attestations + 2. Process block_7 with a supermajority attesting from block_1 to block_6 + + Expected Behavior + ----------------- + 1. The post-state slot is 7 + 2. latest_justified_slot advances to 6 + 3. latest_finalized_slot remains 4 + 4. justified_slots marks slots 5 and 6 as justified + 5. There are no pending justifications + """ + state_transition_test( + pre=generate_pre_state(), + blocks=[ + BlockSpec(slot=Slot(1), label="block_1"), + BlockSpec( + slot=Slot(2), + parent_label="block_1", + label="block_2", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(2), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + BlockSpec( + slot=Slot(3), + parent_label="block_2", + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), + BlockSpec(slot=Slot(4), parent_label="block_3", label="block_4"), + BlockSpec( + slot=Slot(5), + parent_label="block_4", + label="block_5", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(5), + target_slot=Slot(4), + target_root_label="block_4", + ), + ], + ), + BlockSpec( + slot=Slot(6), + parent_label="block_5", + label="block_6", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(6), + target_slot=Slot(5), + target_root_label="block_5", + ), + ], + ), + BlockSpec( + slot=Slot(7), + parent_label="block_6", + forced_attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(7), + source_slot=Slot(1), + source_root_label="block_1", + target_slot=Slot(6), + target_root_label="block_6", + ), + ], + ), + ], + post=StateExpectation( + slot=Slot(7), + latest_justified_slot=Slot(6), + latest_justified_root_label="block_6", + latest_finalized_slot=Slot(4), + latest_finalized_root_label="block_4", + justified_slots=JustifiedSlots(data=[Boolean(True), Boolean(True)]), + justifications_roots=JustificationRoots(data=[]), + justifications_validators=JustificationValidators(data=[]), + ), + ) + + +def test_source_at_finalized_boundary_justifies_without_refinalizing( + state_transition_test: StateTransitionTestFiller, +) -> None: + """ + Test the post-state when a vote uses a source exactly at the finalized boundary. + + This pins the boundary case of the finalization-advance guard. + A source whose slot equals the finalized slot is already final. + It may justify a newer target, but it must never re-finalize. + + Scenario + -------- + 1. Finalize block_4 through ordinary supermajority attestations + 2. Process block_7 with a supermajority attesting from block_4 to block_6 + + Expected Behavior + ----------------- + 1. The post-state slot is 7 + 2. latest_justified_slot advances to 6 + 3. latest_finalized_slot remains 4 + 4. justified_slots marks slots 5 and 6 as justified + 5. There are no pending justifications + """ + state_transition_test( + pre=generate_pre_state(), + blocks=[ + BlockSpec(slot=Slot(1), label="block_1"), + BlockSpec( + slot=Slot(2), + parent_label="block_1", + label="block_2", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(2), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + BlockSpec( + slot=Slot(3), + parent_label="block_2", + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), + BlockSpec(slot=Slot(4), parent_label="block_3", label="block_4"), + BlockSpec( + slot=Slot(5), + parent_label="block_4", + label="block_5", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(5), + target_slot=Slot(4), + target_root_label="block_4", + ), + ], + ), + BlockSpec( + slot=Slot(6), + parent_label="block_5", + label="block_6", + attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(6), + target_slot=Slot(5), + target_root_label="block_5", + ), + ], + ), + BlockSpec( + slot=Slot(7), + parent_label="block_6", + forced_attestations=[ + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(2), + ], + slot=Slot(7), + source_slot=Slot(4), + source_root_label="block_4", + target_slot=Slot(6), + target_root_label="block_6", + ), + ], + ), + ], + post=StateExpectation( + slot=Slot(7), + latest_justified_slot=Slot(6), + latest_justified_root_label="block_6", + latest_finalized_slot=Slot(4), + latest_finalized_root_label="block_4", + justified_slots=JustifiedSlots(data=[Boolean(True), Boolean(True)]), + justifications_roots=JustificationRoots(data=[]), + justifications_validators=JustificationValidators(data=[]), + ), + ) + + def test_non_adjacent_justification_finalizes_across_non_justifiable_gap( state_transition_test: StateTransitionTestFiller, ) -> None: