From afac38ef63a03ce580e3c526a46deff8276183da Mon Sep 17 00:00:00 2001 From: bomanaps Date: Thu, 13 Nov 2025 16:20:40 +0100 Subject: [PATCH 1/3] Add wrong-slot STF test with filler support --- .../test_fixtures/state_transition.py | 78 +++++++++++++------ .../test_types/block_spec.py | 7 ++ .../state_transition/test_block_processing.py | 38 +++++++++ 3 files changed, 100 insertions(+), 23 deletions(-) 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 892b0b27..7943280d 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -61,8 +61,8 @@ class StateTransitionTest(BaseConsensusFixture): """ The filled Blocks, processed through the specs. - This is a private attribute not part of the model schema. Tests cannot set this. - The framework populates it during make_fixture(). + This is a private attribute not part of the model schema. + Tests cannot set this. The framework populates it during make_fixture(). """ post: StateExpectation | None = None @@ -76,6 +76,9 @@ class StateTransitionTest(BaseConsensusFixture): expect_exception: type[Exception] | None = None """Expected exception type for invalid tests.""" + expect_exception_message: str | None = None + """Expected exception message for invalid tests.""" + @field_serializer("blocks", when_used="json") def serialize_blocks(self, value: List[BlockSpec]) -> List[dict[str, Any]]: """ @@ -111,7 +114,8 @@ def make_fixture(self) -> "StateTransitionTest": """ Generate the fixture by running the spec. - Builds blocks from BlockSpec if needed, then processes them through state_transition. + Builds blocks from BlockSpec if needed, then processes them through + state_transition. Returns: ------- @@ -138,12 +142,17 @@ def make_fixture(self) -> "StateTransitionTest": # Store the filled Block for serialization filled_blocks.append(block) + # Process block through state transition # Use cached state if available, otherwise run state transition - state = ( - cached_state - if cached_state is not None - else state.state_transition(block=block, valid_signatures=True) - ) + if cached_state is not None: + state = cached_state + elif getattr(block_spec, "skip_slot_processing", False): + state = state.process_block(block) + else: + state = state.state_transition( + block=block, + valid_signatures=True, + ) actual_post_state = state except (AssertionError, ValueError) as e: @@ -168,6 +177,12 @@ def make_fixture(self) -> "StateTransitionTest": f"Expected {self.expect_exception.__name__} " f"but got {type(exception_raised).__name__}: {exception_raised}" ) + if self.expect_exception_message is not None: + if str(exception_raised) != self.expect_exception_message: + raise AssertionError( + f"Expected exception message '{self.expect_exception_message}' " + f"but got '{exception_raised}'" + ) # Validate post-state expectations if provided if self.post is not None and actual_post_state is not None: @@ -180,9 +195,16 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, """ Build a Block from a BlockSpec, optionally caching the post-state. + Uses provided fields from spec, computes any missing fields. + This mimics what a local block builder would do. + Returns both the block and the cached post-state (if computed) to avoid redundant state transitions. + TODO: If the spec implements a State.produce_block() method in the + future, we should use that instead of manually computing fields here. + Until then, this manual approach is necessary. + Parameters ---------- spec : BlockSpec @@ -198,12 +220,17 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, # Use provided proposer index or compute it proposer_index = spec.proposer_index or Uint64(int(spec.slot) % len(state.validators)) - # Use provided parent root or compute it + # Optionally advance a temporary state to the block's slot + temp_state: State | None = None + if not spec.skip_slot_processing: + temp_state = state.process_slots(spec.slot) + + # Use provided parent_root or compute it if spec.parent_root is not None: parent_root = spec.parent_root else: - temp_state = state.process_slots(spec.slot) - parent_root = hash_tree_root(temp_state.latest_block_header) + source_state = temp_state if temp_state is not None else state + parent_root = hash_tree_root(source_state.latest_block_header) # Extract attestations from body if provided aggregated_attestations = ( @@ -221,26 +248,31 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, ) return block, None - # For invalid tests, return incomplete block without processing - if self.expect_exception is not None: - block = Block( - slot=spec.slot, - proposer_index=proposer_index, - parent_root=parent_root, - state_root=Bytes32.zero(), - body=spec.body or BlockBody(attestations=aggregated_attestations), - ) - return block, None + # Create temporary block with zero state root (will be computed) + temp_block = Block( + slot=spec.slot, + proposer_index=proposer_index, + parent_root=parent_root, + state_root=Bytes32.zero(), + body=spec.body or BlockBody(attestations=aggregated_attestations), + ) + + # If we are expecting an exception or skipping slot advancement, + # return the partially-filled block without running the transition. + if self.expect_exception is not None or spec.skip_slot_processing: + return temp_block, None # Build the block using the state for standard case - # # Convert aggregated attestations to plain attestations to build block plain_attestations = [ Attestation(validator_id=vid, data=agg.data) for agg in aggregated_attestations for vid in agg.aggregation_bits.to_validator_indices() ] - block, post_state, _, _ = state.build_block( + + # Use temp_state if available (already advanced to slot), otherwise use state + build_state = temp_state if temp_state is not None else state + block, post_state, _, _ = build_state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index cc3054ce..d372c91d 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -110,3 +110,10 @@ class BlockSpec(CamelModel): Defaults to True (valid signature). If False, the proposer attestation will be given a dummy/invalid signature. """ + + skip_slot_processing: bool = False + """ + If True, the state transition fixture skips automatic slot advancement before + processing this block. Useful for tests that intentionally exercise slot + mismatch failures. + """ diff --git a/tests/consensus/devnet/state_transition/test_block_processing.py b/tests/consensus/devnet/state_transition/test_block_processing.py index 0d2dd1ad..2b6de639 100644 --- a/tests/consensus/devnet/state_transition/test_block_processing.py +++ b/tests/consensus/devnet/state_transition/test_block_processing.py @@ -273,6 +273,44 @@ def test_block_with_invalid_state_root( ) +def test_block_with_wrong_slot(state_transition_test: StateTransitionTestFiller) -> None: + """ + Test that blocks with mismatched slot are rejected. + + Scenario + -------- + Attempt to process a block at slot 1, but the block claims to be + at slot 2. + + Expected Behavior + ----------------- + Block processing fails with AssertionError: "Block slot mismatch" + + Why This Matters + ---------------- + Ensures temporal consistency: + - Blocks can't lie about their slot + - Prevents time manipulation attacks + - Maintains protocol timing integrity + - Essential for slot-based consensus + """ + pre_state = generate_pre_state() + pre_state = pre_state.process_slots(Slot(1)) + + state_transition_test( + pre=pre_state, + blocks=[ + BlockSpec( + slot=Slot(2), + skip_slot_processing=True, + ), + ], + post=None, + expect_exception=AssertionError, + expect_exception_message="Block slot mismatch", + ) + + def test_block_extends_deep_chain( state_transition_test: StateTransitionTestFiller, ) -> None: From 12810de3845e492608aa6fa69ef295e2faa746c5 Mon Sep 17 00:00:00 2001 From: bomanaps Date: Sat, 15 Nov 2025 07:47:38 +0100 Subject: [PATCH 2/3] Leave only functional changes - in the diff --- .../consensus_testing/test_fixtures/state_transition.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 7943280d..daaabfb2 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -61,8 +61,8 @@ class StateTransitionTest(BaseConsensusFixture): """ The filled Blocks, processed through the specs. - This is a private attribute not part of the model schema. - Tests cannot set this. The framework populates it during make_fixture(). + This is a private attribute not part of the model schema. Tests cannot set + this. The framework populates it during make_fixture(). """ post: StateExpectation | None = None @@ -114,8 +114,7 @@ def make_fixture(self) -> "StateTransitionTest": """ Generate the fixture by running the spec. - Builds blocks from BlockSpec if needed, then processes them through - state_transition. + Builds blocks from BlockSpec if needed, then processes them through state_transition. Returns: ------- From 2242f6145929c9f6c3b88ced804f81c78b7e0ecc Mon Sep 17 00:00:00 2001 From: bomanaps Date: Wed, 17 Dec 2025 07:34:49 +0100 Subject: [PATCH 3/3] Fix build_block slot advancement issue --- .../test_fixtures/state_transition.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) 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 daaabfb2..3bec4630 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -141,7 +141,6 @@ def make_fixture(self) -> "StateTransitionTest": # Store the filled Block for serialization filled_blocks.append(block) - # Process block through state transition # Use cached state if available, otherwise run state transition if cached_state is not None: state = cached_state @@ -194,16 +193,9 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, """ Build a Block from a BlockSpec, optionally caching the post-state. - Uses provided fields from spec, computes any missing fields. - This mimics what a local block builder would do. - Returns both the block and the cached post-state (if computed) to avoid redundant state transitions. - TODO: If the spec implements a State.produce_block() method in the - future, we should use that instead of manually computing fields here. - Until then, this manual approach is necessary. - Parameters ---------- spec : BlockSpec @@ -219,7 +211,6 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, # Use provided proposer index or compute it proposer_index = spec.proposer_index or Uint64(int(spec.slot) % len(state.validators)) - # Optionally advance a temporary state to the block's slot temp_state: State | None = None if not spec.skip_slot_processing: temp_state = state.process_slots(spec.slot) @@ -247,7 +238,6 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, ) return block, None - # Create temporary block with zero state root (will be computed) temp_block = Block( slot=spec.slot, proposer_index=proposer_index, @@ -256,12 +246,9 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, body=spec.body or BlockBody(attestations=aggregated_attestations), ) - # If we are expecting an exception or skipping slot advancement, - # return the partially-filled block without running the transition. if self.expect_exception is not None or spec.skip_slot_processing: return temp_block, None - # Build the block using the state for standard case # Convert aggregated attestations to plain attestations to build block plain_attestations = [ Attestation(validator_id=vid, data=agg.data) @@ -269,9 +256,7 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, for vid in agg.aggregation_bits.to_validator_indices() ] - # Use temp_state if available (already advanced to slot), otherwise use state - build_state = temp_state if temp_state is not None else state - block, post_state, _, _ = build_state.build_block( + block, post_state, _, _ = state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root,