Skip to content

Commit c1aa2bc

Browse files
unnawuttcoratger
andauthored
fix: align process_attestations() with intended specs (leanEthereum#160)
* fix: align process_attestations() with intended specs * refactor: justifications processing becomes pure functions * fix: slot typing * fix: in progress fix of justification reorg test * fix: stray code must go * refactor: align justifications with leanEthereum#165 * fix: linting * fix: update code comments * fix: add extra attestations * fix: linting * fix: linting * fix: type count * Update packages/testing/src/consensus_testing/test_types/store_checks.py Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> * fix: docstring * fix: lowercase justified --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com>
1 parent 35f855a commit c1aa2bc

3 files changed

Lines changed: 171 additions & 82 deletions

File tree

packages/testing/src/consensus_testing/test_types/store_checks.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,6 @@ class StoreChecks(CamelModel):
128128
latest_justified_root: Bytes32 | None = None
129129
"""Expected latest justified checkpoint root."""
130130

131-
latest_finalized_slot: Slot | None = None
132-
"""Expected latest finalized checkpoint slot."""
133-
134131
latest_justified_root_label: str | None = None
135132
"""
136133
Expected latest justified checkpoint root by label reference.
@@ -140,9 +137,21 @@ class StoreChecks(CamelModel):
140137
and validate the latest justified checkpointroot matches.
141138
"""
142139

140+
latest_finalized_slot: Slot | None = None
141+
"""Expected latest finalized checkpoint slot."""
142+
143143
latest_finalized_root: Bytes32 | None = None
144144
"""Expected latest finalized checkpoint root."""
145145

146+
latest_finalized_root_label: str | None = None
147+
"""
148+
Expected latest finalized checkpoint root by label reference.
149+
150+
Alternative to `latest_justified_root` that uses the block label system.
151+
The framework will resolve this label to the actual block root
152+
and validate the latest finalized checkpoint root matches.
153+
"""
154+
146155
safe_target: Bytes32 | None = None
147156
"""Expected safe target root."""
148157

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

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from ..checkpoint import Checkpoint
2323
from ..config import Config
2424
from ..slot import Slot
25+
from .helpers import flatten_justifications_map, get_justifications_map
2526
from .types import (
2627
HistoricalBlockHashes,
2728
JustificationRoots,
@@ -336,61 +337,97 @@ def process_attestations(
336337
State
337338
A new state with updated justification/finalization.
338339
"""
339-
# Start with current justifications and finalization state.
340-
justified_slots = list(self.justified_slots)
340+
# Get justifications, justified slots and historical block hashes are already up to
341+
# date as per the processing in process_block_header
342+
justifications = get_justifications_map(
343+
justifications_roots=self.justifications_roots,
344+
justifications_validators=self.justifications_validators,
345+
validator_count=self.validators.count,
346+
)
347+
348+
# Track state changes to be applied at the end
341349
latest_justified = self.latest_justified
342350
latest_finalized = self.latest_finalized
351+
justified_slots = list(self.justified_slots)
343352

344353
# Process each attestation in the block.
345354
for attestation in attestations:
346355
attestation_data = attestation.data
347356
source = attestation_data.source
348357
target = attestation_data.target
349358

350-
# Validate that this is a reasonable attestation (source comes before target).
351-
if source.slot.as_int() >= target.slot.as_int():
352-
continue # Skip invalid attestation
353-
354-
# Check if source checkpoint is justified.
355-
source_slot_int = source.slot.as_int()
356-
target_slot_int = target.slot.as_int()
357-
358-
# Ensure we have enough justified slots history.
359-
if source_slot_int < len(justified_slots):
360-
source_is_justified = justified_slots[source_slot_int]
361-
else:
362-
continue # Source is too far in the past
363-
364-
# If source is justified, consider justifying the target.
365-
if (
366-
source_is_justified
367-
and target_slot_int < len(justified_slots)
368-
and justified_slots[target_slot_int]
369-
):
370-
# Target is already justified, check for finalization.
371-
if (
372-
source.slot.as_int() + 1 == target.slot.as_int()
373-
and latest_justified.slot.as_int() < target.slot.as_int()
359+
# Ignore attestations whose source is not already justified,
360+
# or whose target is not in the history, or whose target is not a
361+
# valid justifiable slot
362+
source_slot = source.slot.as_int()
363+
target_slot = target.slot.as_int()
364+
365+
# Source slot must be justified
366+
if not justified_slots[source_slot]:
367+
continue
368+
369+
# Target slot must not be already justified
370+
# This condition is missing in 3sf mini but has been added here because
371+
# we don't want to re-introduce the target again for remaining votes if
372+
# the slot is already justified and its tracking already cleared out
373+
# from justifications map
374+
if justified_slots[target_slot]:
375+
continue
376+
377+
# Source root must match the state's historical block hashes
378+
if source.root != self.historical_block_hashes[source_slot]:
379+
continue
380+
381+
# Target root must match the state's historical block hashes
382+
if target.root != self.historical_block_hashes[target_slot]:
383+
continue
384+
385+
# Target slot must be after source slot
386+
if target.slot <= source.slot:
387+
continue
388+
389+
# Target slot must be justifiable after the latest finalized slot
390+
if not target.slot.is_justifiable_after(self.latest_finalized.slot):
391+
continue
392+
393+
# Track attempts to justify new hashes
394+
if target.root not in justifications:
395+
justifications[target.root] = [Boolean(False)] * self.validators.count
396+
397+
validator_id = attestation.validator_id.as_int()
398+
if not justifications[target.root][validator_id]:
399+
justifications[target.root][validator_id] = Boolean(True)
400+
401+
count = sum(bool(justified) for justified in justifications[target.root])
402+
403+
# If 2/3 attested to the same new valid hash to justify
404+
# in 3sf mini this is strict equality, but we have updated it to >=
405+
# also have modified it from count >= (2 * state.config.num_validators) // 3
406+
# to prevent integer division which could lead to less than 2/3 of validators
407+
# justifying specially if the num_validators is low in testing scenarios
408+
if 3 * count >= (2 * self.validators.count):
409+
latest_justified = target
410+
justified_slots[target_slot] = True
411+
del justifications[target.root]
412+
413+
# Finalization: if the target is the next valid justifiable
414+
# hash after the source
415+
if not any(
416+
Slot(slot).is_justifiable_after(self.latest_finalized.slot)
417+
for slot in range(source_slot + 1, target_slot)
374418
):
375-
# Consecutive justified checkpoints -> finalize the source.
376419
latest_finalized = source
377-
latest_justified = target
378-
379-
else:
380-
# Try to justify the target if source is justified.
381-
if source_is_justified:
382-
# Ensure justified_slots is long enough, then mark the target slot.
383-
while len(justified_slots) <= target_slot_int:
384-
justified_slots.append(Boolean(False))
385-
justified_slots[target_slot_int] = Boolean(True)
386420

387-
# Update latest_justified if this target is newer.
388-
if target.slot.as_int() > latest_justified.slot.as_int():
389-
latest_justified = target
421+
# Flatten and set updated justifications back to the state
422+
justifications_roots, justifications_validators = flatten_justifications_map(
423+
justifications, self.validators.count
424+
)
390425

391426
# Return the updated state.
392427
return self.model_copy(
393428
update={
429+
"justifications_roots": justifications_roots,
430+
"justifications_validators": justifications_validators,
394431
"justified_slots": self.justified_slots.__class__(data=justified_slots),
395432
"latest_justified": latest_justified,
396433
"latest_finalized": latest_finalized,

tests/consensus/devnet/fc/test_fork_choice_reorgs.py

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -777,22 +777,25 @@ def test_reorg_on_newly_justified_slot(
777777
778778
Scenario
779779
--------
780-
Two forks compete across multiple justifiable slots. Fork choice must
781-
correctly handle reorgs while respecting justification rules.
780+
Two forks compete. Fork A is heavier and longer, but Fork B manages to
781+
become justified. Fork choice must switch to the justified fork regardless
782+
of weight/length.
782783
783784
- Slot 1: Base
784-
- Slot 2: Fork A (competing with B)
785-
- Slot 2: Fork B (competing with A)
786-
- Slot 3: Fork A (Fork A now has depth 2 - becomes head)
787-
- Slot 4: Fork A (Fork A now has depth 3 - still head)
788-
- Slot 5: Fork B with enough justifications for Slot 2 → triggers reorg → Fork B becomes head
785+
- Slots 2-4: Fork A extends (becomes head with depth 3)
786+
- Slot 5: Fork B appears (descending from Base, skipping slots 2-4)
787+
- Slot 6: Fork B extends. This block contains enough attestations to
788+
justify Fork B at Slot 5.
789789
790790
Expected Behavior
791791
-----------------
792-
1. Fork A and Fork B initially have equal weight at Slot 2
793-
2. Fork A takes lead at Slot 3 and Slot 4
794-
2. At Slot 5, enough attestations justified Fork B at Slot 2, Fork A permanently non-canonical
795-
3. Fork B becomes head at Slot 5 due to justification of Fork B at Slot 2
792+
1. Fork A takes the lead initially (Slots 2-4) as the heaviest chain.
793+
2. Fork B appears at Slot 5 but is initially lighter.
794+
3. At Slot 6, the new block includes attestations that justify Fork B at Slot 5.
795+
4. The justified checkpoint updates to Slot 5 (fork_b_1).
796+
5. Fork A is immediately discarded because it does not descend from the new
797+
justified checkpoint (Fork A is on a branch from Slot 1).
798+
6. Fork B becomes the canonical head.
796799
797800
Why This Matters
798801
----------------
@@ -806,6 +809,10 @@ def test_reorg_on_newly_justified_slot(
806809
- Safety guarantees maintained during reorgs
807810
"""
808811
fork_choice_test(
812+
# Using 9 validators: 3 for Fork A and 6 for Fork B to achieve 2/3rd for Fork B
813+
anchor_state=generate_pre_state(
814+
validators=Validators(data=[Validator(pubkey=Bytes52.zero()) for _ in range(9)])
815+
),
809816
steps=[
810817
# Common base at slot 1
811818
BlockStep(
@@ -816,63 +823,99 @@ def test_reorg_on_newly_justified_slot(
816823
),
817824
),
818825
# Fork A: slot 2
826+
# Fork A is the heaviest chain (1 block from justified slot)
819827
BlockStep(
820-
block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_a_2"),
828+
block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_a_1"),
821829
checks=StoreChecks(
822830
head_slot=Slot(2),
823-
head_root_label="fork_a_2",
831+
head_root_label="fork_a_1",
824832
),
825833
),
826-
# Fork B: slot 2 (competing)
834+
# Fork A: slot 3
835+
# Fork A is the heaviest chain (2 blocks from justified slot)
827836
BlockStep(
828-
block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"),
837+
block=BlockSpec(slot=Slot(3), parent_label="fork_a_1", label="fork_a_2"),
829838
checks=StoreChecks(
830-
head_slot=Slot(2),
831-
head_root_label="fork_b_2",
839+
head_slot=Slot(3),
840+
head_root_label="fork_a_2",
832841
),
833842
),
834-
# Fork A: slot 3 (extends first, takes lead)
843+
# Fork A: slot 4
844+
# Fork A is the heaviest chain (3 blocks from justified slot)
835845
BlockStep(
836-
block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"),
846+
block=BlockSpec(slot=Slot(4), parent_label="fork_a_2", label="fork_a_3"),
837847
checks=StoreChecks(
838-
head_slot=Slot(3),
839-
head_root_label="fork_a_3", # Fork A leads (2 blocks vs 1)
848+
head_slot=Slot(4),
849+
head_root_label="fork_a_3",
840850
),
841851
),
842-
# Fork A: slot 4 (extends, still head)
852+
# Fork B: slot 5 (first block of fork B)
853+
# Fork A is still the heaviest chain (3 blocks from justified slot)
843854
BlockStep(
844-
block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"),
855+
block=BlockSpec(slot=Slot(5), parent_label="base", label="fork_b_1"),
845856
checks=StoreChecks(
846857
head_slot=Slot(4),
847-
head_root_label="fork_a_4", # Fork A leads (3 blocks vs 1)
858+
head_root_label="fork_a_3",
848859
),
849860
),
850-
# Fork B: slot 5 (enough attestations justifying Slot 2)
861+
# Fork B: slot 6
862+
# Validator 5 justified fork_b_1 in slot 5
863+
# Validator 6 justifying fork_b_2 in slot 6
864+
# Add extra justifications on fork_b_1 from validator 0, 1, 7, 8
865+
# This makes fork_b_1 justified by 2/3rd of validators: 0, 1, 5, 6, 7, 8
851866
BlockStep(
852867
block=BlockSpec(
853-
slot=Slot(5),
854-
parent_label="fork_a_4",
855-
label="fork_b_5",
868+
slot=Slot(6),
869+
parent_label="fork_b_1",
870+
label="fork_b_2",
856871
attestations=[
857-
# The proposer validator_id is 5 % 4 = 1, so adding attestations for 2 and 3
858872
SignedAttestationSpec(
859-
validator_id=ValidatorIndex(2),
860-
slot=Slot(4),
861-
target_slot=Slot(2),
862-
target_root_label="fork_b_2",
873+
validator_id=ValidatorIndex(0),
874+
slot=Slot(5),
875+
target_slot=Slot(5),
876+
target_root_label="fork_b_1",
877+
),
878+
SignedAttestationSpec(
879+
validator_id=ValidatorIndex(1),
880+
slot=Slot(5),
881+
target_slot=Slot(5),
882+
target_root_label="fork_b_1",
883+
),
884+
# fork_b_1 should be able to justify without extra attestations
885+
# from validator 5 and 6 but the test is failing without these
886+
# two attestations below because block proposer's attestations
887+
# are not being counted towards justification
888+
SignedAttestationSpec(
889+
validator_id=ValidatorIndex(5),
890+
slot=Slot(5),
891+
target_slot=Slot(5),
892+
target_root_label="fork_b_1",
863893
),
864894
SignedAttestationSpec(
865-
validator_id=ValidatorIndex(3),
866-
slot=Slot(4),
867-
target_slot=Slot(2),
868-
target_root_label="fork_b_2",
895+
validator_id=ValidatorIndex(6),
896+
slot=Slot(5),
897+
target_slot=Slot(5),
898+
target_root_label="fork_b_1",
899+
),
900+
SignedAttestationSpec(
901+
validator_id=ValidatorIndex(7),
902+
slot=Slot(5),
903+
target_slot=Slot(5),
904+
target_root_label="fork_b_1",
905+
),
906+
SignedAttestationSpec(
907+
validator_id=ValidatorIndex(8),
908+
slot=Slot(5),
909+
target_slot=Slot(5),
910+
target_root_label="fork_b_1",
869911
),
870912
],
871913
),
872914
checks=StoreChecks(
873-
head_slot=Slot(5),
874-
latest_justified_slot=Slot(2),
875-
head_root_label="fork_b_5", # Fork B leads as Fork B at Slot 2 is justified
915+
head_slot=Slot(6),
916+
head_root_label="fork_b_2", # Fork B now leads as fork_b_1 is justified
917+
latest_justified_slot=Slot(5),
918+
latest_justified_root_label="fork_b_1",
876919
),
877920
),
878921
],

0 commit comments

Comments
 (0)