diff --git a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py index 7cdbb3ab..88ecc7b2 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -9,7 +9,7 @@ from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_types import AggregatedAttestationSpec, BlockSpec, StateExpectation from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.forks import AggregationBits, SpecRejectionError +from lean_spec.spec.forks import SpecRejectionError from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, AggregatedAttestations, @@ -135,9 +135,14 @@ def generate(self) -> StateTransitionFixture: try: state = self.pre - for block_spec in self.blocks: + for block_index, block_spec in enumerate(self.blocks): # Build block and optionally get cached post-state to avoid redundant transitions - block, cached_state = self._build_block_from_spec(block_spec, state, block_registry) + block, cached_state = self._build_block_from_spec( + block_spec, + state, + block_registry, + is_final_block=block_index == len(self.blocks) - 1, + ) # Store the filled Block for serialization filled_blocks.append(block) @@ -194,6 +199,7 @@ def _build_block_from_spec( block_spec: BlockSpec, state: State, block_registry: dict[str, Block], + is_final_block: bool, ) -> tuple[Block, State | None]: """ Build a Block from a BlockSpec, optionally caching the post-state. @@ -201,7 +207,9 @@ def _build_block_from_spec( Three construction paths: 1. Explicit state_root -- caller controls the root, no transition - 2. Invalid or skip-slot -- placeholder zero root, no transition + 2. Final block of an invalid test, or skip-slot -- placeholder zero + root, no transition. Earlier blocks of an invalid test build + normally so processing reaches the block expected to fail. 3. Normal -- full build_block with computed state root After construction, any forced attestations are appended to the @@ -212,6 +220,7 @@ def _build_block_from_spec( block_spec: Block specification with optional field overrides. state: Current state to build against. block_registry: Labeled blocks for parent and target resolution. + is_final_block: Whether this is the last block of the test. Returns: Block and cached post-state (None if not computed). @@ -245,8 +254,11 @@ def _build_block_from_spec( body=body, ) - # Path 2: invalid test or skip-slot -- placeholder root, no transition. - elif self.expected_rejection is not None or block_spec.skip_slot_processing: + # Path 2: failing block of an invalid test, or skip-slot -- + # placeholder root, no transition. + elif ( + self.expected_rejection is not None and is_final_block + ) or block_spec.skip_slot_processing: block = Block( slot=block_spec.slot, proposer_index=proposer_index, @@ -282,9 +294,7 @@ def _build_block_from_spec( if block_spec.forced_attestations: forced = [ AggregatedAttestation( - aggregation_bits=AggregationBits.from_indices( - forced_attestation.validator_indices - ), + aggregation_bits=forced_attestation.resolve_aggregation_bits(), data=forced_attestation.build_attestation_data( block_registry, state.latest_justified ), diff --git a/packages/testing/src/consensus_testing/test_types/attestation_specs.py b/packages/testing/src/consensus_testing/test_types/attestation_specs.py index d6987455..8f3f95a5 100644 --- a/packages/testing/src/consensus_testing/test_types/attestation_specs.py +++ b/packages/testing/src/consensus_testing/test_types/attestation_specs.py @@ -159,6 +159,27 @@ class AggregatedAttestationSpec(AttestationSpec): Must have same length as validator_indices when specified. """ + aggregation_bits: AggregationBits | None = None + """ + Raw aggregation bits placed into the block body verbatim. + + When None (default), the bits are derived from the validator indices, + producing the tightest bitfield that covers the highest set index. + Set this to author bit patterns the derivation cannot express: + a zero-length bitfield, all-false bits, or trailing padding past the + validator registry. + + Only honored by the state transition format's forced-attestation + path, which bypasses signing. Signed paths derive their bits from + the validator indices so proofs match the claimed participants. + """ + + def resolve_aggregation_bits(self) -> AggregationBits: + """Return the explicit bits override, or bits derived from the validator indices.""" + if self.aggregation_bits is not None: + return self.aggregation_bits + return AggregationBits.from_indices(self.validator_indices) + def build_signed( self, block_registry: dict[str, Block], diff --git a/src/lean_spec/spec/forks/lstar/state_transition.py b/src/lean_spec/spec/forks/lstar/state_transition.py index 2b591bf1..69da34f6 100644 --- a/src/lean_spec/spec/forks/lstar/state_transition.py +++ b/src/lean_spec/spec/forks/lstar/state_transition.py @@ -329,6 +329,12 @@ def process_attestations( 1. Processes each attestation 2. Updates justified status for target checkpoints 3. Applies finalization rules based on justified status + + Raises: + SpecRejectionError: EMPTY_AGGREGATION_BITS if an attestation that passes + the vote filters has no set bits. + SpecRejectionError: VALIDATOR_INDEX_OUT_OF_RANGE if a set bit points + outside the validator registry. """ # Reconstruct the vote-tracking structure # @@ -438,6 +444,25 @@ def process_attestations( if not target.slot.is_justifiable_after(finalized_slot): continue + # Resolve the participating validators from the aggregation bits. + # + # An aggregation with no set bits names no voter at all. + # Such an attestation is malformed and rejects the whole block. + voting_validator_indices = attestation.aggregation_bits.to_validator_indices() + + # Reject set bits that point outside the validator registry. + # + # The tally below holds one flag per registered validator. + # A vote from a nonexistent validator has no flag to set, + # so the whole block is invalid. + # Trailing unset bits beyond the registry are harmless padding. + for validator_index in voting_validator_indices: + if not validator_index.is_within_registry(Uint64(len(state.validators))): + raise SpecRejectionError( + RejectionReason.VALIDATOR_INDEX_OUT_OF_RANGE, + "Attestation aggregation bits reference a validator outside the registry", + ) + # Record the vote. # # If this is the first vote for the target block, create a fresh tally sheet: @@ -449,7 +474,7 @@ def process_attestations( # # A vote is a boolean flag set to True. # Re-marking an existing voter is idempotent, so no guard is needed. - for validator_index in attestation.aggregation_bits.to_validator_indices(): + for validator_index in voting_validator_indices: justifications[target.root][validator_index] = Boolean(True) # Check whether the vote count crosses the supermajority threshold. diff --git a/tests/consensus/lstar/state_transition/test_aggregation_bits.py b/tests/consensus/lstar/state_transition/test_aggregation_bits.py new file mode 100644 index 00000000..152a0612 --- /dev/null +++ b/tests/consensus/lstar/state_transition/test_aggregation_bits.py @@ -0,0 +1,220 @@ +"""State Transition: Aggregation Bits Validation""" + +import pytest + +from consensus_testing import ( + AggregatedAttestationSpec, + BlockSpec, + ExpectedRejection, + StateExpectation, + StateTransitionTestFiller, +) +from lean_spec.spec.forks import AggregationBits, RejectionReason, Slot, ValidatorIndex +from lean_spec.spec.forks.lstar.containers import JustificationRoots, JustificationValidators +from lean_spec.spec.ssz import Boolean + +pytestmark = pytest.mark.valid_until("Lstar") + + +def test_aggregation_bit_beyond_validator_registry_rejects_block( + state_transition_test: StateTransitionTestFiller, +) -> None: + """ + A set aggregation bit past the validator registry rejects the block. + + Given + ----- + - 4 validators (indices 0-3); the vote tally has one flag per validator. + - the chain: + genesis -> block_1(1) -> block(2) + - block(2) carries an attestation for block_1 whose aggregation bits + name V0 and V1 plus nonexistent validator 4. + + When + ---- + - the chain processes block(2). + + Then + ---- + - the block is rejected with VALIDATOR_INDEX_OUT_OF_RANGE. + - a client that silently skips the attestation instead would accept a + block the rest of the network refuses: a consensus split on a single + crafted block. + """ + state_transition_test( + blocks=[ + BlockSpec(slot=Slot(1), label="block_1"), + BlockSpec( + slot=Slot(2), + parent_label="block_1", + forced_attestations=[ + # Bits 0, 1, and 4 are set. + # Bit 4 points one past the 4-validator registry. + AggregatedAttestationSpec( + validator_indices=[ + ValidatorIndex(0), + ValidatorIndex(1), + ValidatorIndex(4), + ], + slot=Slot(2), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + ], + post=None, + expected_rejection=ExpectedRejection(reason=RejectionReason.VALIDATOR_INDEX_OUT_OF_RANGE), + ) + + +def test_all_false_aggregation_bits_rejects_block( + state_transition_test: StateTransitionTestFiller, +) -> None: + """ + An attestation whose aggregation bits are all false rejects the block. + + Given + ----- + - 4 validators. + - the chain: + genesis -> block_1(1) -> block(2) + - block(2) carries an attestation for block_1 with a registry-sized + bitfield where no bit is set. + + When + ---- + - the chain processes block(2). + + Then + ---- + - the block is rejected with EMPTY_AGGREGATION_BITS. + - a client that processes the attestation as a no-op instead leaves an + all-false tally entry in its post-state, diverging from clients that + reject the block. + """ + state_transition_test( + blocks=[ + BlockSpec(slot=Slot(1), label="block_1"), + BlockSpec( + slot=Slot(2), + parent_label="block_1", + forced_attestations=[ + AggregatedAttestationSpec( + validator_indices=[], + aggregation_bits=AggregationBits(data=[Boolean(False)] * 4), + slot=Slot(2), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + ], + post=None, + expected_rejection=ExpectedRejection(reason=RejectionReason.EMPTY_AGGREGATION_BITS), + ) + + +def test_zero_length_aggregation_bits_rejects_block( + state_transition_test: StateTransitionTestFiller, +) -> None: + """ + An attestation with a zero-length bitfield rejects the block. + + Given + ----- + - 4 validators. + - the chain: + genesis -> block_1(1) -> block(2) + - block(2) carries an attestation for block_1 whose aggregation bits + hold no bits at all. + + When + ---- + - the chain processes block(2). + + Then + ---- + - the block is rejected with EMPTY_AGGREGATION_BITS. + - the zero-length bitfield is a distinct SSZ encoding from an all-false + bitfield of registry size; both name no voter and both must reject + the block identically across clients. + """ + state_transition_test( + blocks=[ + BlockSpec(slot=Slot(1), label="block_1"), + BlockSpec( + slot=Slot(2), + parent_label="block_1", + forced_attestations=[ + AggregatedAttestationSpec( + validator_indices=[], + aggregation_bits=AggregationBits(data=[]), + slot=Slot(2), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + ], + post=None, + expected_rejection=ExpectedRejection(reason=RejectionReason.EMPTY_AGGREGATION_BITS), + ) + + +def test_oversized_aggregation_bits_with_in_range_votes_processes_normally( + state_transition_test: StateTransitionTestFiller, +) -> None: + """ + Trailing unset bits past the registry do not invalidate a vote. + + Given + ----- + - 4 validators; a slot needs 3 votes (2/3) to be justified. + - the chain: + genesis -> block_1(1) -> block(2) + - block(2) carries an attestation for block_1 whose bitfield is 6 bits + long (two bits past the registry) with only V0, V1, and V2 set. + + When + ---- + - the chain processes both blocks. + + Then + ---- + - the attestation is processed normally: every set bit addresses a real + validator, and the trailing unset padding is harmless. + - block_1's slot is justified and its pending tally is cleared. + - a client that skips the attestation because of the bitfield length + computes a different post-state for a valid block; the pinned + post-state root catches that divergence directly. + """ + state_transition_test( + blocks=[ + BlockSpec(slot=Slot(1), label="block_1"), + BlockSpec( + slot=Slot(2), + parent_label="block_1", + forced_attestations=[ + # Bits 0-2 are set; bits 3-5 are unset. + # Bits 4 and 5 pad past the 4-validator registry. + AggregatedAttestationSpec( + validator_indices=[], + aggregation_bits=AggregationBits( + data=[Boolean(True)] * 3 + [Boolean(False)] * 3 + ), + slot=Slot(2), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + ], + post=StateExpectation( + slot=Slot(2), + latest_justified_slot=Slot(1), + latest_finalized_slot=Slot(0), + justifications_roots=JustificationRoots(data=[]), + justifications_validators=JustificationValidators(data=[]), + ), + )