diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index ba303411..c0754d5e 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -298,7 +298,11 @@ def make_fixture(self) -> Self: # Gossip attestations arrive outside of blocks. # They influence the fork choice weight calculation. signed_attestation = self._build_signed_attestation_from_spec( - step.attestation, self._block_registry, key_manager + step.attestation, + self._block_registry, + key_manager, + store, + step.valid, ) step._filled_attestation = signed_attestation store = store.on_gossip_attestation( @@ -330,7 +334,14 @@ def make_fixture(self) -> Self: f"Step {i} ({type(step).__name__}) failed unexpectedly: {e}" ) from e - # Expected failure occurred. Continue to next step. + # Verify the failure reason matches when specified. + if step.expected_error is not None and step.expected_error not in str(e): + raise AssertionError( + f"Step {i} ({type(step).__name__}) failed with wrong error.\n" + f" Expected error containing: {step.expected_error!r}\n" + f" Actual error: {e!r}" + ) from e + continue # Handle unexpected success. @@ -589,6 +600,35 @@ def _build_attestations_from_spec( return attestations, signature_lookup, valid_attestations + def _resolve_checkpoint( + self, + label: str, + slot_override: Slot | None, + block_registry: dict[str, Block], + ) -> Checkpoint: + """ + Resolve a block label and optional slot override into a Checkpoint. + + Args: + label: Block label in the registry. + slot_override: When set, overrides the block's actual slot. + block_registry: Labeled blocks for lookup. + + Returns: + Checkpoint with the block's root and resolved slot. + + Raises: + ValueError: If label not found in registry. + """ + if (block := block_registry.get(label)) is None: + raise ValueError( + f"label '{label}' not found - available: {list(block_registry.keys())}" + ) + return Checkpoint( + root=hash_tree_root(block), + slot=block.slot if slot_override is None else slot_override, + ) + def _build_attestation_data_from_spec( self, spec: AggregatedAttestationSpec, @@ -619,19 +659,10 @@ def _build_attestation_data_from_spec( Raises: ValueError: If target label not found in registry. """ - # Resolve target from label. - # The label references a block that will be the target checkpoint. - if (target_block := block_registry.get(spec.target_root_label)) is None: - raise ValueError( - f"target_root_label '{spec.target_root_label}' not found - " - f"available: {list(block_registry.keys())}" - ) - - target_root = hash_tree_root(target_block) - target = Checkpoint(root=target_root, slot=spec.target_slot) + target = self._resolve_checkpoint(spec.target_root_label, spec.target_slot, block_registry) - # Build the attestation data. # In simplified tests, head equals target for convenience. + # # Source is the state's last justified checkpoint (Casper FFG link). return AttestationData( slot=spec.slot, @@ -645,14 +676,26 @@ def _build_signed_attestation_from_spec( spec: GossipAttestationSpec, block_registry: dict[str, Block], key_manager: XmssKeyManager, + store: Store, + expected_valid: bool, ) -> SignedAttestation: """ Build a signed attestation from a gossip attestation specification. + Two paths: + + - Valid with no overrides: delegates to the spec's own attestation data + production. This ensures valid test vectors match what an honest validator + would produce. + - Invalid or with overrides: manually constructs checkpoints to create + specific non-standard attestations for validation testing. + Args: spec: Gossip attestation specification. block_registry: Labeled blocks for target resolution. key_manager: XMSS key manager for signing. + store: Fork choice store for spec-based attestation data production. + expected_valid: Whether the step expects this attestation to succeed. Returns: Signed attestation ready for gossip processing. @@ -660,70 +703,90 @@ def _build_signed_attestation_from_spec( Raises: ValueError: If target label not found in registry. """ - # Resolve target from label. - if (target_block := block_registry.get(spec.target_root_label)) is None: - raise ValueError( - f"target_root_label '{spec.target_root_label}' not found - " - f"available: {list(block_registry.keys())}" - ) + has_overrides = ( + spec.head_root_label is not None + or spec.head_slot is not None + or spec.source_root_label is not None + or spec.source_slot is not None + or spec.target_root_override is not None + or spec.head_root_override is not None + or spec.source_root_override is not None + ) + + if not expected_valid or has_overrides: + attestation_data = self._build_overridden_attestation_data(spec, block_registry) + else: + # Reuse the spec for honest attestations. + attestation_data = store.produce_attestation_data(spec.slot) + + signature = ( + key_manager.sign_attestation_data(spec.validator_id, attestation_data) + if spec.valid_signature + else create_dummy_signature() + ) + + return SignedAttestation( + validator_id=spec.validator_id, + data=attestation_data, + signature=signature, + ) + + def _build_overridden_attestation_data( + self, + spec: GossipAttestationSpec, + block_registry: dict[str, Block], + ) -> AttestationData: + """ + Build attestation data with explicit checkpoint overrides. + + Used for invalid or non-standard attestations where the test + intentionally creates mismatches for validation testing. - target_root = hash_tree_root(target_block) - target = Checkpoint(root=target_root, slot=spec.target_slot) + Args: + spec: Gossip attestation specification with override fields. + block_registry: Labeled blocks for target resolution. + + Returns: + Attestation data with overridden checkpoints. + """ + target = self._resolve_checkpoint(spec.target_root_label, spec.target_slot, block_registry) # Resolve head checkpoint. # Defaults to the target checkpoint when not overridden. if spec.head_root_label is not None: - if (head_block := block_registry.get(spec.head_root_label)) is None: - raise ValueError( - f"head_root_label '{spec.head_root_label}' not found - " - f"available: {list(block_registry.keys())}" - ) - head_root = hash_tree_root(head_block) - head_slot = spec.head_slot if spec.head_slot is not None else head_block.slot - head = Checkpoint(root=head_root, slot=head_slot) + head = self._resolve_checkpoint(spec.head_root_label, spec.head_slot, block_registry) else: head = Checkpoint( - root=target_root, - slot=spec.head_slot if spec.head_slot is not None else spec.target_slot, + root=target.root, + slot=target.slot if spec.head_slot is None else spec.head_slot, ) # Resolve source checkpoint. + # # Defaults to the anchor (genesis) block when not overridden. assert self.anchor_block is not None, "anchor_block must be set before building attestation" if spec.source_root_label is not None: - if (source_block := block_registry.get(spec.source_root_label)) is None: - raise ValueError( - f"source_root_label '{spec.source_root_label}' not found - " - f"available: {list(block_registry.keys())}" - ) - source_root = hash_tree_root(source_block) - source_slot = spec.source_slot if spec.source_slot is not None else source_block.slot - source = Checkpoint(root=source_root, slot=source_slot) + source = self._resolve_checkpoint( + spec.source_root_label, spec.source_slot, block_registry + ) else: - anchor_root = hash_tree_root(self.anchor_block) source = Checkpoint( - root=anchor_root, - slot=spec.source_slot if spec.source_slot is not None else self.anchor_block.slot, + root=hash_tree_root(self.anchor_block), + slot=self.anchor_block.slot if spec.source_slot is None else spec.source_slot, ) - attestation_data = AttestationData( + # Apply raw root overrides. + # These inject roots not in the store for testing unknown block rejection. + if spec.target_root_override is not None: + target = Checkpoint(root=spec.target_root_override, slot=target.slot) + if spec.head_root_override is not None: + head = Checkpoint(root=spec.head_root_override, slot=head.slot) + if spec.source_root_override is not None: + source = Checkpoint(root=spec.source_root_override, slot=source.slot) + + return AttestationData( slot=spec.slot, head=head, target=target, source=source, ) - - # Generate signature or use dummy. - if spec.valid_signature: - signature = key_manager.sign_attestation_data( - spec.validator_id, - attestation_data, - ) - else: - signature = create_dummy_signature() - - return SignedAttestation( - validator_id=spec.validator_id, - data=attestation_data, - signature=signature, - ) diff --git a/packages/testing/src/consensus_testing/test_types/gossip_attestation_spec.py b/packages/testing/src/consensus_testing/test_types/gossip_attestation_spec.py index 4a6302a8..4d0de1aa 100644 --- a/packages/testing/src/consensus_testing/test_types/gossip_attestation_spec.py +++ b/packages/testing/src/consensus_testing/test_types/gossip_attestation_spec.py @@ -2,7 +2,7 @@ from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex -from lean_spec.types import CamelModel +from lean_spec.types import Bytes32, CamelModel class GossipAttestationSpec(CamelModel): @@ -61,6 +61,30 @@ class GossipAttestationSpec(CamelModel): When specified, creates a mismatch for testing consistency checks. """ + target_root_override: Bytes32 | None = None + """ + Raw root override for the target checkpoint. + + Bypasses label resolution. Used to inject roots not in the store + for testing unknown block rejection. + """ + + head_root_override: Bytes32 | None = None + """ + Raw root override for the head checkpoint. + + Bypasses label resolution. Used to inject roots not in the store + for testing unknown block rejection. + """ + + source_root_override: Bytes32 | None = None + """ + Raw root override for the source checkpoint. + + Bypasses label resolution. Used to inject roots not in the store + for testing unknown block rejection. + """ + valid_signature: bool = True """ Flag whether the generated attestation signature should be valid. diff --git a/packages/testing/src/consensus_testing/test_types/step_types.py b/packages/testing/src/consensus_testing/test_types/step_types.py index 586097d1..ce734ae6 100644 --- a/packages/testing/src/consensus_testing/test_types/step_types.py +++ b/packages/testing/src/consensus_testing/test_types/step_types.py @@ -25,6 +25,15 @@ class BaseForkChoiceStep(CamelModel): valid: bool = True """Whether this step is expected to succeed.""" + expected_error: str | None = None + """ + Expected error message substring when valid=False. + + When set, the exception message must contain this string. + When None and valid=False, any exception is accepted. + Ignored when valid=True. + """ + checks: StoreChecks | None = None """ Store state checks to validate after processing this step. diff --git a/tests/consensus/devnet/fc/test_gossip_attestation_validation.py b/tests/consensus/devnet/fc/test_gossip_attestation_validation.py index c29b35a9..e0e20ad0 100644 --- a/tests/consensus/devnet/fc/test_gossip_attestation_validation.py +++ b/tests/consensus/devnet/fc/test_gossip_attestation_validation.py @@ -12,6 +12,7 @@ from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex +from lean_spec.types import Bytes32 pytestmark = pytest.mark.valid_until("Devnet") @@ -86,6 +87,7 @@ def test_attestation_target_slot_mismatch_rejected( target_root_label="block_2", ), valid=False, + expected_error="Target checkpoint slot mismatch", ), ], ) @@ -123,6 +125,7 @@ def test_attestation_too_far_in_future_rejected( target_root_label="block_2", ), valid=False, + expected_error="Attestation too far in future", ), ], ) @@ -263,6 +266,7 @@ def test_gossip_attestation_with_invalid_signature( valid_signature=False, ), valid=False, + expected_error="Signature verification failed", ), ], ) @@ -302,6 +306,7 @@ def test_gossip_attestation_with_unknown_validator( valid_signature=False, ), valid=False, + expected_error="not found in state", ), ], ) @@ -345,6 +350,7 @@ def test_attestation_source_slot_exceeds_target_rejected( source_slot=Slot(3), ), valid=False, + expected_error="Source checkpoint slot must not exceed target", ), ], ) @@ -387,6 +393,46 @@ def test_attestation_head_older_than_target_rejected( head_root_label="block_1", ), valid=False, + expected_error="Head checkpoint must not be older than target", + ), + ], + ) + + +def test_attestation_source_slot_override_exceeds_target_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestation where source slot exceeds target slot is rejected (via slot override). + + Scenario + -------- + Build a chain with blocks at slots 1 and 2. + Submit attestation where the source slot (5) exceeds the target slot (2). + + Expected: + - Validation fails because source slot (5) exceeds target slot (2) + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1)), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + source_slot=Slot(5), + ), + valid=False, + expected_error="Source checkpoint slot must not exceed target", ), ], ) @@ -401,8 +447,9 @@ def test_attestation_source_slot_mismatch_rejected( Scenario -------- Build a chain with blocks at slots 1 and 2. - Submit attestation where the source slot (5) does not match - the source block's actual slot (0, the genesis/anchor block). + Submit attestation where the source slot (1) does not match + the source block's actual slot (0, the genesis/anchor block), + but still satisfies source <= target ordering. Expected: - Validation fails with "Source checkpoint slot mismatch" @@ -423,9 +470,10 @@ def test_attestation_source_slot_mismatch_rejected( slot=Slot(2), target_slot=Slot(2), target_root_label="block_2", - source_slot=Slot(5), + source_slot=Slot(1), ), valid=False, + expected_error="Source checkpoint slot mismatch", ), ], ) @@ -465,6 +513,7 @@ def test_attestation_head_slot_mismatch_rejected( head_slot=Slot(5), ), valid=False, + expected_error="Head checkpoint slot mismatch", ), ], ) @@ -522,3 +571,127 @@ def test_gossip_attestation_chain_extended_after_gossip( ), ], ) + + +FAKE_ROOT = Bytes32(b"\xde\xad" + b"\x00" * 30) +"""A root that will never appear in the store.""" + + +def test_attestation_unknown_target_block_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestation referencing an unknown target block is rejected. + + Scenario + -------- + Build a chain with blocks at slots 1 and 2. + Submit attestation whose target root does not exist in the store. + + Expected: + - Validation fails with "Unknown target block" + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1)), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + target_root_override=FAKE_ROOT, + valid_signature=False, + ), + valid=False, + expected_error="Unknown target block", + ), + ], + ) + + +def test_attestation_unknown_head_block_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestation referencing an unknown head block is rejected. + + Scenario + -------- + Build a chain with blocks at slots 1 and 2. + Submit attestation whose head root does not exist in the store. + + Expected: + - Validation fails with "Unknown head block" + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1)), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + head_root_override=FAKE_ROOT, + valid_signature=False, + ), + valid=False, + expected_error="Unknown head block", + ), + ], + ) + + +def test_attestation_unknown_source_block_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestation referencing an unknown source block is rejected. + + Scenario + -------- + Build a chain with blocks at slots 1 and 2. + Submit attestation whose source root does not exist in the store. + + Expected: + - Validation fails with "Unknown source block" + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1)), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + source_root_override=FAKE_ROOT, + valid_signature=False, + ), + valid=False, + expected_error="Unknown source block", + ), + ], + )