diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py index 29ebe35f9..f7a91839f 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -62,6 +62,10 @@ class VerifySignaturesTest(BaseConsensusFixture): the block's proposer_index field. Use this to exercise the validator-bounds check that the builder skips because its round- robin selection stays within range by construction. + - ``{"operation": "clear_first_attestation_bits"}``: Replace the + first body attestation with one whose aggregation_bits carry no + set bit. Exercises the empty-participants check inside + signature verification. Tampered blocks bypass the builder's structural invariants. The resulting fixture pins the exact rejection a client must raise when @@ -164,4 +168,24 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock: ) return signed_block.model_copy(update={"block": tampered_block}) + if operation == "clear_first_attestation_bits": + from lean_spec.subspecs.containers.attestation import AggregatedAttestation + from lean_spec.subspecs.containers.attestation.aggregation_bits import ( + AggregationBits, + ) + from lean_spec.subspecs.containers.block.types import AggregatedAttestations + from lean_spec.types import Boolean + + body = signed_block.block.body + original = body.attestations.data + if not original: + raise ValueError("clear_first_attestation_bits requires at least one attestation") + first = original[0] + empty_bits = AggregationBits(data=[Boolean(False)] * len(first.aggregation_bits.data)) + cleared = AggregatedAttestation(aggregation_bits=empty_bits, data=first.data) + new_attestations = AggregatedAttestations(data=[cleared, *original[1:]]) + new_body = body.model_copy(update={"attestations": new_attestations}) + new_block = signed_block.block.model_copy(update={"body": new_body}) + return signed_block.model_copy(update={"block": new_block}) + raise ValueError(f"Unknown tamper operation: {operation!r}") diff --git a/tests/consensus/devnet/verify_signatures/test_empty_aggregation_bits.py b/tests/consensus/devnet/verify_signatures/test_empty_aggregation_bits.py new file mode 100644 index 000000000..293570826 --- /dev/null +++ b/tests/consensus/devnet/verify_signatures/test_empty_aggregation_bits.py @@ -0,0 +1,58 @@ +"""Signature verification: empty aggregation_bits rejection vector.""" + +import pytest +from consensus_testing import ( + AggregatedAttestationSpec, + BlockSpec, + VerifySignaturesTestFiller, + generate_pre_state, +) + +from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex + +pytestmark = pytest.mark.valid_until("Devnet") + + +def test_empty_aggregation_bits_rejected( + verify_signatures_test: VerifySignaturesTestFiller, +) -> None: + """A signed block whose attestation references zero participants is rejected. + + Scenario + -------- + - Anchor state has 3 validators. + - Block at slot 1 carries one aggregated attestation. + - The tamper hook replaces the first attestation's aggregation_bits + with a bitfield where no bit is set. + + Expected Behavior + ----------------- + Signature verification fails with AssertionError: + "Aggregated attestation must reference at least one validator" + + Why This Matters + ---------------- + An aggregated attestation with no participants carries no signed + message. The block builder never produces one because its + aggregation pass starts from a non-empty validator set, but a + malicious peer could. Clients must raise before attempting to look + up public keys for a zero-participant attestation. + """ + verify_signatures_test( + anchor_state=generate_pre_state(num_validators=3), + block=BlockSpec( + slot=Slot(1), + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(1), + target_slot=Slot(0), + target_root_label="genesis", + valid_signature=False, + ), + ], + ), + tamper={"operation": "clear_first_attestation_bits"}, + expect_exception=AssertionError, + )