Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -194,14 +199,17 @@ 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.

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
Expand All @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
27 changes: 26 additions & 1 deletion src/lean_spec/spec/forks/lstar/state_transition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
220 changes: 220 additions & 0 deletions tests/consensus/lstar/state_transition/test_aggregation_bits.py
Original file line number Diff line number Diff line change
@@ -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=[]),
),
)
Loading