diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 526538c0c..6457318e9 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -123,6 +123,7 @@ def validate_attestation(self, signed_vote: "SignedVote") -> None: # Validate vote targets exist in store assert vote.source.root in self.blocks, f"Unknown source block: {vote.source.root.hex()}" assert vote.target.root in self.blocks, f"Unknown target block: {vote.target.root.hex()}" + assert vote.head.root in self.blocks, f"Unknown head block: {vote.head.root.hex()}" # Validate slot relationships source_block = self.blocks[vote.source.root] @@ -136,7 +137,7 @@ def validate_attestation(self, signed_vote: "SignedVote") -> None: assert target_block.slot == vote.target.slot, "Target checkpoint slot mismatch" # Validate attestation is not too far in the future - current_slot = Slot(self.time // SECONDS_PER_INTERVAL) + current_slot = Slot(self.time // INTERVALS_PER_SLOT) assert vote.slot <= Slot(current_slot + Slot(1)), "Attestation too far in future" def process_attestation(self, signed_vote: "SignedVote", is_from_block: bool = False) -> None: @@ -173,7 +174,7 @@ def process_attestation(self, signed_vote: "SignedVote", is_from_block: bool = F # Network gossip attestation processing # Ensure forkchoice is current before processing gossip - time_slots = self.time // SECONDS_PER_INTERVAL + time_slots = self.time // INTERVALS_PER_SLOT assert vote.slot <= time_slots, "Attestation from future slot" # Update new votes if this is latest from validator diff --git a/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py b/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py index adf13d944..0eecfa5a5 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py +++ b/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py @@ -215,6 +215,52 @@ def test_validate_attestation_too_far_future(self, sample_store: Store) -> None: with pytest.raises(AssertionError, match="Attestation too far in future"): sample_store.validate_attestation(signed_vote) + def test_validate_attestation_unknown_head_rejected(self, sample_store: Store) -> None: + """Test validation fails when head block is unknown. + + This ensures consistency with source/target validation and prevents + spam attestations with fabricated head roots. + """ + # Create valid source and target blocks + source_block = Block( + slot=Slot(1), + proposer_index=Uint64(1), + parent_root=Bytes32.zero(), + state_root=Bytes32(b"source" + b"\x00" * 26), + body=BlockBody(attestations=Attestations(data=[])), + ) + source_hash = hash_tree_root(source_block) + + target_block = Block( + slot=Slot(2), + proposer_index=Uint64(2), + parent_root=source_hash, + state_root=Bytes32(b"target" + b"\x00" * 26), + body=BlockBody(attestations=Attestations(data=[])), + ) + target_hash = hash_tree_root(target_block) + + # Add source and target blocks to store + sample_store.blocks[source_hash] = source_block + sample_store.blocks[target_hash] = target_block + + # Create an unknown head root that doesn't exist in the store + unknown_head_root = Bytes32(b"\x99" * 32) + + # Create vote with unknown head but valid source and target + vote = Vote( + validator_id=ValidatorIndex(0), + slot=Slot(2), + head=Checkpoint(root=unknown_head_root, slot=Slot(2)), # Unknown head! + target=Checkpoint(root=target_hash, slot=Slot(2)), + source=Checkpoint(root=source_hash, slot=Slot(1)), + ) + signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) + + # Should raise assertion error for unknown head + with pytest.raises(AssertionError, match="Unknown head block"): + sample_store.validate_attestation(signed_vote) + class TestAttestationProcessing: """Test attestation processing logic."""