From 40af4fde5a9354da51d408b555d64ebe32126d66 Mon Sep 17 00:00:00 2001 From: Adam Mohammed A Latif Date: Fri, 29 May 2026 21:14:24 +0000 Subject: [PATCH 1/3] fix stale finalized source finalization --- src/lean_spec/spec/forks/lstar/spec.py | 7 +- .../state_transition/test_finalization.py | 125 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/lean_spec/spec/forks/lstar/spec.py b/src/lean_spec/spec/forks/lstar/spec.py index 3a39dead..7b40ae7d 100644 --- a/src/lean_spec/spec/forks/lstar/spec.py +++ b/src/lean_spec/spec/forks/lstar/spec.py @@ -522,13 +522,14 @@ 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. + # If the source is newer than the old finalized point and every + # slot in between is justifiable relative to that old finalized + # point, then the earlier 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..af1aa94a 100644 --- a/tests/consensus/lstar/state_transition/test_finalization.py +++ b/tests/consensus/lstar/state_transition/test_finalization.py @@ -493,6 +493,131 @@ 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_ids=[ + 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_ids=[ + 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_ids=[ + 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_ids=[ + 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_ids=[ + 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=[]).model_copy( + update={"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: From bf2e9d2fcafe0a0675a6b45bacd703ada1a01857 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 30 May 2026 17:19:52 +0200 Subject: [PATCH 2/3] fix: rebase onto main and correct finalization test Rebase the stale-source finalization fix onto current main: the spec file moved under the spec/ package and attestation specs renamed validator_ids to validator_indices. Also fix the new test expectation. Building justified_slots via model_copy(update=...) bypassed Pydantic validation, leaving data as a list instead of a tuple, so the post-state comparison failed even with the fix applied. Use the canonical constructor instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lean_spec/spec/forks/lstar/spec.py | 8 +++++--- .../lstar/state_transition/test_finalization.py | 14 ++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lean_spec/spec/forks/lstar/spec.py b/src/lean_spec/spec/forks/lstar/spec.py index 7b40ae7d..97db4b81 100644 --- a/src/lean_spec/spec/forks/lstar/spec.py +++ b/src/lean_spec/spec/forks/lstar/spec.py @@ -522,9 +522,11 @@ def process_attestations( # Finalization requires a continuous chain of trust from the # previously finalized checkpoint up to the new justified point. # - # If the source is newer than the old finalized point and every - # slot in between is justifiable relative to that 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: # diff --git a/tests/consensus/lstar/state_transition/test_finalization.py b/tests/consensus/lstar/state_transition/test_finalization.py index af1aa94a..37df5dea 100644 --- a/tests/consensus/lstar/state_transition/test_finalization.py +++ b/tests/consensus/lstar/state_transition/test_finalization.py @@ -522,7 +522,7 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( label="block_2", attestations=[ AggregatedAttestationSpec( - validator_ids=[ + validator_indices=[ ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2), @@ -539,7 +539,7 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( label="block_3", attestations=[ AggregatedAttestationSpec( - validator_ids=[ + validator_indices=[ ValidatorIndex(0), ValidatorIndex(1), ], @@ -556,7 +556,7 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( label="block_5", attestations=[ AggregatedAttestationSpec( - validator_ids=[ + validator_indices=[ ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2), @@ -573,7 +573,7 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( label="block_6", attestations=[ AggregatedAttestationSpec( - validator_ids=[ + validator_indices=[ ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2), @@ -589,7 +589,7 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( parent_label="block_6", forced_attestations=[ AggregatedAttestationSpec( - validator_ids=[ + validator_indices=[ ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2), @@ -609,9 +609,7 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( latest_justified_root_label="block_6", latest_finalized_slot=Slot(4), latest_finalized_root_label="block_4", - justified_slots=JustifiedSlots(data=[]).model_copy( - update={"data": [Boolean(True), Boolean(True)]} - ), + justified_slots=JustifiedSlots(data=[Boolean(True), Boolean(True)]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), ), From 56e6942473da254ce9a6278a5396b15c526fa706 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 30 May 2026 17:25:19 +0200 Subject: [PATCH 3/3] test: pin source-at-finalized-boundary finalization case Add the boundary case raised in review: a source whose slot equals the finalized slot. Such a source is already final, so it may justify a newer target but must never re-finalize. This locks the > (not >=) intent of the finalization-advance guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../state_transition/test_finalization.py | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/consensus/lstar/state_transition/test_finalization.py b/tests/consensus/lstar/state_transition/test_finalization.py index 37df5dea..42e13c70 100644 --- a/tests/consensus/lstar/state_transition/test_finalization.py +++ b/tests/consensus/lstar/state_transition/test_finalization.py @@ -616,6 +616,133 @@ def test_stale_finalized_source_justifies_without_rewinding_finalization( ) +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: