Skip to content

Commit 81ed4aa

Browse files
latifkasulitcoratgerclaude
authored
fix: enforce attestation checkpoint ancestry (leanEthereum#833)
* fix: enforce attestation checkpoint ancestry * fix: address review findings on attestation checkpoint ancestry - document the ancestry check rationale and list it in the validation contract - expand the ancestry helper docstring with its conservative failure behavior - correct the chain-match docstring wording for three checkpoints - add positive deep-chain and unknown-parent ancestry unit tests - cover the head zero-hash and head out-of-bounds rejection branches - add language-neutral rejection reasons for the ancestry asserts - add consensus vectors: sibling-fork head and source rejection in gossip validation, off-canonical head vote excluded from justification in state transition Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5cee882 commit 81ed4aa

8 files changed

Lines changed: 459 additions & 8 deletions

File tree

packages/testing/src/consensus_testing/rejection.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@
2828
("Source checkpoint slot mismatch", RejectionReason.SOURCE_SLOT_MISMATCH),
2929
("Target checkpoint slot mismatch", RejectionReason.TARGET_SLOT_MISMATCH),
3030
("Head checkpoint slot mismatch", RejectionReason.HEAD_SLOT_MISMATCH),
31+
(
32+
"Source checkpoint must be ancestor of target",
33+
RejectionReason.SOURCE_NOT_ANCESTOR_OF_TARGET,
34+
),
35+
(
36+
"Target checkpoint must be ancestor of head",
37+
RejectionReason.TARGET_NOT_ANCESTOR_OF_HEAD,
38+
),
3139
("Attestation too far in future", RejectionReason.ATTESTATION_TOO_FAR_IN_FUTURE),
3240
("not found in state", RejectionReason.VALIDATOR_NOT_IN_STATE),
3341
("Validator index out of range", RejectionReason.VALIDATOR_INDEX_OUT_OF_RANGE),

src/lean_spec/spec/forks/lstar/fork_choice.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@
3636
class ForkChoiceMixin(LstarSpecBase):
3737
"""Fork choice and store maintenance for the lstar fork."""
3838

39+
def _checkpoint_is_ancestor(
40+
self,
41+
store: LstarStore,
42+
ancestor: Checkpoint,
43+
descendant: Checkpoint,
44+
) -> bool:
45+
"""
46+
Return whether the ancestor checkpoint lies on the descendant's parent chain.
47+
48+
Walks parent links from the descendant down to the ancestor's slot.
49+
Conservative: a skipped slot or a block missing from the store yields False.
50+
"""
51+
if ancestor.slot > descendant.slot:
52+
return False
53+
54+
current_root = descendant.root
55+
while current_root in store.blocks:
56+
current_block = store.blocks[current_root]
57+
if current_block.slot == ancestor.slot:
58+
return current_root == ancestor.root
59+
if current_block.slot < ancestor.slot:
60+
return False
61+
current_root = current_block.parent_root
62+
63+
return False
64+
3965
def create_store(
4066
self,
4167
state: SpecStateType,
@@ -146,7 +172,8 @@ def validate_attestation(self, store: LstarStore, attestation_data: AttestationD
146172
2. A vote cannot span backwards in time (source > target).
147173
3. The head must be at least as recent as source and target.
148174
4. Checkpoint slots must match the actual block slots.
149-
5. The vote's slot must have started locally (a small disparity margin is allowed).
175+
5. Source, target, and head must lie on one parent chain.
176+
6. The vote's slot must have started locally (a small disparity margin is allowed).
150177
151178
Raises:
152179
AssertionError: If attestation fails validation.
@@ -189,6 +216,17 @@ def validate_attestation(self, store: LstarStore, attestation_data: AttestationD
189216
assert target_block.slot == target_checkpoint.slot, "Target checkpoint slot mismatch"
190217
assert head_block.slot == head_checkpoint.slot, "Head checkpoint slot mismatch"
191218

219+
# Ancestry Check
220+
#
221+
# Why: fork-choice weight accrues to every ancestor of the attested head.
222+
# A sibling head would steer that weight onto a non-canonical branch.
223+
assert self._checkpoint_is_ancestor(store, source_checkpoint, target_checkpoint), (
224+
"Source checkpoint must be ancestor of target"
225+
)
226+
assert self._checkpoint_is_ancestor(store, target_checkpoint, head_checkpoint), (
227+
"Target checkpoint must be ancestor of head"
228+
)
229+
192230
# Time Check
193231
#
194232
# Honest validators emit votes only after their slot has begun.

src/lean_spec/spec/forks/lstar/rejection_reason.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ class RejectionReason(StrEnum):
7070
HEAD_SLOT_MISMATCH = "HEAD_SLOT_MISMATCH"
7171
"""The head checkpoint slot disagrees with the referenced block."""
7272

73+
SOURCE_NOT_ANCESTOR_OF_TARGET = "SOURCE_NOT_ANCESTOR_OF_TARGET"
74+
"""The attestation source checkpoint is not an ancestor of its target."""
75+
76+
TARGET_NOT_ANCESTOR_OF_HEAD = "TARGET_NOT_ANCESTOR_OF_HEAD"
77+
"""The attestation target checkpoint is not an ancestor of its head."""
78+
7379
ATTESTATION_TOO_FAR_IN_FUTURE = "ATTESTATION_TOO_FAR_IN_FUTURE"
7480
"""The attestation slot is beyond the store's acceptance horizon."""
7581

src/lean_spec/spec/forks/lstar/state_transition.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,39 +31,47 @@ def attestation_data_matches_chain(
3131
historical_block_hashes: Sequence[Bytes32],
3232
) -> bool:
3333
"""
34-
Check that source and target checkpoints point to blocks on a chain.
34+
Check that attestation checkpoints point to blocks on a chain.
3535
3636
Args:
3737
attestation_data: The attestation being validated.
3838
historical_block_hashes: Chain view indexed by slot.
3939
Empty slots carry the zero hash.
4040
4141
Returns:
42-
True when both checkpoint roots match the chain at their slot.
43-
False when either root is the zero hash.
44-
False when either checkpoint slot is past the end of the chain view.
42+
True when all checkpoint roots match the chain at their slot.
43+
False when any root is the zero hash.
44+
False when any checkpoint slot is past the end of the chain view.
4545
"""
4646
# Reject zero-hash checkpoints up front.
4747
#
4848
# Empty slots carry the zero hash on the chain.
4949
# A vote whose recorded root equals the zero hash is meaningless.
50-
if attestation_data.source.root == ZERO_HASH or attestation_data.target.root == ZERO_HASH:
50+
if (
51+
attestation_data.source.root == ZERO_HASH
52+
or attestation_data.target.root == ZERO_HASH
53+
or attestation_data.head.root == ZERO_HASH
54+
):
5155
return False
5256

5357
# Reject checkpoints whose slot is beyond the chain view.
5458
#
5559
# Without this guard, indexed access raises IndexError.
5660
source_slot = int(attestation_data.source.slot)
5761
target_slot = int(attestation_data.target.slot)
62+
head_slot = int(attestation_data.head.slot)
5863
if source_slot >= len(historical_block_hashes):
5964
return False
6065
if target_slot >= len(historical_block_hashes):
6166
return False
67+
if head_slot >= len(historical_block_hashes):
68+
return False
6269

63-
# Both checkpoint roots must match the chain at their slot.
70+
# All checkpoint roots must match the chain at their slot.
6471
return (
6572
attestation_data.source.root == historical_block_hashes[source_slot]
6673
and attestation_data.target.root == historical_block_hashes[target_slot]
74+
and attestation_data.head.root == historical_block_hashes[head_slot]
6775
)
6876

6977

tests/consensus/lstar/fc/test_gossip_attestation_validation.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,3 +790,105 @@ def test_attestation_unknown_source_block_rejected(
790790
),
791791
],
792792
)
793+
794+
795+
def test_attestation_head_on_sibling_fork_rejected(
796+
fork_choice_test: ForkChoiceTestFiller,
797+
) -> None:
798+
"""
799+
Attestation whose head sits on a sibling fork of the target is rejected.
800+
801+
Scenario
802+
--------
803+
Build a common base at slot 1.
804+
Create two competing blocks from that same base at slots 2 and 3.
805+
Distinct slots give the siblings distinct roots so they never collide.
806+
Vote with source on the base, target on the slot-2 block, and head on the
807+
slot-3 block.
808+
Every slot and availability check passes, yet target and head diverge.
809+
810+
Expected:
811+
- Validation fails with "Target checkpoint must be ancestor of head"
812+
"""
813+
fork_choice_test(
814+
steps=[
815+
BlockStep(
816+
block=BlockSpec(slot=Slot(1), label="base"),
817+
checks=StoreChecks(head_slot=Slot(1)),
818+
),
819+
BlockStep(
820+
block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_left"),
821+
checks=StoreChecks(head_slot=Slot(2)),
822+
),
823+
BlockStep(
824+
block=BlockSpec(slot=Slot(3), parent_label="base", label="fork_right"),
825+
),
826+
AttestationStep(
827+
attestation=GossipAttestationSpec(
828+
validator_index=ValidatorIndex(1),
829+
slot=Slot(3),
830+
target_slot=Slot(2),
831+
target_root_label="fork_left",
832+
head_slot=Slot(3),
833+
head_root_label="fork_right",
834+
source_root_label="base",
835+
valid_signature=False,
836+
),
837+
valid=False,
838+
expected_error="Target checkpoint must be ancestor of head",
839+
),
840+
],
841+
)
842+
843+
844+
def test_attestation_source_on_sibling_fork_rejected(
845+
fork_choice_test: ForkChoiceTestFiller,
846+
) -> None:
847+
"""
848+
Attestation whose source sits on a sibling fork of the target is rejected.
849+
850+
Scenario
851+
--------
852+
Build a common base at slot 1.
853+
Create a slot-2 block on one branch from that base.
854+
Create a slot-3 block on a competing branch from that same base.
855+
Distinct slots give the branches distinct roots so they never collide.
856+
Extend the competing branch with a slot-4 block.
857+
Vote with source on the abandoned slot-2 block, target on the slot-4 block,
858+
and head on that same slot-4 block.
859+
Source slot precedes the target slot, yet source lies off the target chain.
860+
861+
Expected:
862+
- Validation fails with "Source checkpoint must be ancestor of target"
863+
"""
864+
fork_choice_test(
865+
steps=[
866+
BlockStep(
867+
block=BlockSpec(slot=Slot(1), label="base"),
868+
checks=StoreChecks(head_slot=Slot(1)),
869+
),
870+
BlockStep(
871+
block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_left"),
872+
checks=StoreChecks(head_slot=Slot(2)),
873+
),
874+
BlockStep(
875+
block=BlockSpec(slot=Slot(3), parent_label="base", label="fork_right"),
876+
),
877+
BlockStep(
878+
block=BlockSpec(slot=Slot(4), parent_label="fork_right", label="fork_right_head"),
879+
),
880+
AttestationStep(
881+
attestation=GossipAttestationSpec(
882+
validator_index=ValidatorIndex(1),
883+
slot=Slot(4),
884+
target_slot=Slot(4),
885+
target_root_label="fork_right_head",
886+
head_root_label="fork_right_head",
887+
source_root_label="fork_left",
888+
valid_signature=False,
889+
),
890+
valid=False,
891+
expected_error="Source checkpoint must be ancestor of target",
892+
),
893+
],
894+
)

tests/consensus/lstar/state_transition/test_justification.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,64 @@ def test_attestation_with_target_root_not_in_historical_hashes_is_skipped(
800800
)
801801

802802

803+
def test_attestation_with_off_canonical_head_does_not_justify_target(
804+
state_transition_test: StateTransitionTestFiller,
805+
) -> None:
806+
"""
807+
Test that a vote with an off-canonical head cannot justify its target.
808+
809+
Scenario
810+
--------
811+
1. Start from genesis with 4 validators
812+
2. Process block_1 at slot 1 and block_2 at slot 2 on the canonical chain
813+
3. Force a supermajority attestation into block_2 whose source and target
814+
match the canonical chain but whose head points to a sibling root at a
815+
slot that already holds a canonical block
816+
817+
Expected Behavior
818+
-----------------
819+
1. The source and target roots match the canonical chain at their slots
820+
2. The head root does not match the canonical block at its slot
821+
3. The whole vote is skipped before any tally is recorded
822+
4. latest_justified_slot stays at genesis with no pending votes
823+
"""
824+
state_transition_test(
825+
blocks=[
826+
BlockSpec(slot=Slot(1), label="block_1"),
827+
BlockSpec(
828+
slot=Slot(2),
829+
parent_label="block_1",
830+
forced_attestations=[
831+
# Source genesis and target block_1 both sit on the
832+
# canonical chain.
833+
# The head root is a sibling at slot 1, where the canonical
834+
# block is block_1.
835+
# Threshold: 3 of 4 would justify slot 1 if the head matched.
836+
AggregatedAttestationSpec(
837+
validator_indices=[
838+
ValidatorIndex(0),
839+
ValidatorIndex(1),
840+
ValidatorIndex(2),
841+
],
842+
slot=Slot(2),
843+
target_slot=Slot(1),
844+
target_root_label="block_1",
845+
head_root=Bytes32(b"\x99" * 32),
846+
head_slot=Slot(1),
847+
),
848+
],
849+
),
850+
],
851+
post=StateExpectation(
852+
slot=Slot(2),
853+
latest_justified_slot=Slot(0),
854+
latest_finalized_slot=Slot(0),
855+
justifications_roots=JustificationRoots(data=[]),
856+
justifications_validators=JustificationValidators(data=[]),
857+
),
858+
)
859+
860+
803861
def test_justification_clears_only_the_resolved_target_votes(
804862
state_transition_test: StateTransitionTestFiller,
805863
) -> None:

0 commit comments

Comments
 (0)