Skip to content

Commit e2781a1

Browse files
authored
Add wrong-slot STF test with filler support (#161)
* Add wrong-slot STF test with filler support * Leave only functional changes - in the diff * Fix build_block slot advancement issue
1 parent 39bada9 commit e2781a1

File tree

3 files changed

+83
-22
lines changed

3 files changed

+83
-22
lines changed

packages/testing/src/consensus_testing/test_fixtures/state_transition.py

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ class StateTransitionTest(BaseConsensusFixture):
6161
"""
6262
The filled Blocks, processed through the specs.
6363
64-
This is a private attribute not part of the model schema. Tests cannot set this.
65-
The framework populates it during make_fixture().
64+
This is a private attribute not part of the model schema. Tests cannot set
65+
this. The framework populates it during make_fixture().
6666
"""
6767

6868
post: StateExpectation | None = None
@@ -76,6 +76,9 @@ class StateTransitionTest(BaseConsensusFixture):
7676
expect_exception: type[Exception] | None = None
7777
"""Expected exception type for invalid tests."""
7878

79+
expect_exception_message: str | None = None
80+
"""Expected exception message for invalid tests."""
81+
7982
@field_serializer("blocks", when_used="json")
8083
def serialize_blocks(self, value: List[BlockSpec]) -> List[dict[str, Any]]:
8184
"""
@@ -139,11 +142,15 @@ def make_fixture(self) -> "StateTransitionTest":
139142
filled_blocks.append(block)
140143

141144
# Use cached state if available, otherwise run state transition
142-
state = (
143-
cached_state
144-
if cached_state is not None
145-
else state.state_transition(block=block, valid_signatures=True)
146-
)
145+
if cached_state is not None:
146+
state = cached_state
147+
elif getattr(block_spec, "skip_slot_processing", False):
148+
state = state.process_block(block)
149+
else:
150+
state = state.state_transition(
151+
block=block,
152+
valid_signatures=True,
153+
)
147154

148155
actual_post_state = state
149156
except (AssertionError, ValueError) as e:
@@ -168,6 +175,12 @@ def make_fixture(self) -> "StateTransitionTest":
168175
f"Expected {self.expect_exception.__name__} "
169176
f"but got {type(exception_raised).__name__}: {exception_raised}"
170177
)
178+
if self.expect_exception_message is not None:
179+
if str(exception_raised) != self.expect_exception_message:
180+
raise AssertionError(
181+
f"Expected exception message '{self.expect_exception_message}' "
182+
f"but got '{exception_raised}'"
183+
)
171184

172185
# Validate post-state expectations if provided
173186
if self.post is not None and actual_post_state is not None:
@@ -198,12 +211,16 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block,
198211
# Use provided proposer index or compute it
199212
proposer_index = spec.proposer_index or Uint64(int(spec.slot) % len(state.validators))
200213

201-
# Use provided parent root or compute it
214+
temp_state: State | None = None
215+
if not spec.skip_slot_processing:
216+
temp_state = state.process_slots(spec.slot)
217+
218+
# Use provided parent_root or compute it
202219
if spec.parent_root is not None:
203220
parent_root = spec.parent_root
204221
else:
205-
temp_state = state.process_slots(spec.slot)
206-
parent_root = hash_tree_root(temp_state.latest_block_header)
222+
source_state = temp_state if temp_state is not None else state
223+
parent_root = hash_tree_root(source_state.latest_block_header)
207224

208225
# Extract attestations from body if provided
209226
aggregated_attestations = (
@@ -221,25 +238,24 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block,
221238
)
222239
return block, None
223240

224-
# For invalid tests, return incomplete block without processing
225-
if self.expect_exception is not None:
226-
block = Block(
227-
slot=spec.slot,
228-
proposer_index=proposer_index,
229-
parent_root=parent_root,
230-
state_root=Bytes32.zero(),
231-
body=spec.body or BlockBody(attestations=aggregated_attestations),
232-
)
233-
return block, None
241+
temp_block = Block(
242+
slot=spec.slot,
243+
proposer_index=proposer_index,
244+
parent_root=parent_root,
245+
state_root=Bytes32.zero(),
246+
body=spec.body or BlockBody(attestations=aggregated_attestations),
247+
)
248+
249+
if self.expect_exception is not None or spec.skip_slot_processing:
250+
return temp_block, None
234251

235-
# Build the block using the state for standard case
236-
#
237252
# Convert aggregated attestations to plain attestations to build block
238253
plain_attestations = [
239254
Attestation(validator_id=vid, data=agg.data)
240255
for agg in aggregated_attestations
241256
for vid in agg.aggregation_bits.to_validator_indices()
242257
]
258+
243259
block, post_state, _, _ = state.build_block(
244260
slot=spec.slot,
245261
proposer_index=proposer_index,

packages/testing/src/consensus_testing/test_types/block_spec.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,10 @@ class BlockSpec(CamelModel):
110110
Defaults to True (valid signature).
111111
If False, the proposer attestation will be given a dummy/invalid signature.
112112
"""
113+
114+
skip_slot_processing: bool = False
115+
"""
116+
If True, the state transition fixture skips automatic slot advancement before
117+
processing this block. Useful for tests that intentionally exercise slot
118+
mismatch failures.
119+
"""

tests/consensus/devnet/state_transition/test_block_processing.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,44 @@ def test_block_with_invalid_state_root(
273273
)
274274

275275

276+
def test_block_with_wrong_slot(state_transition_test: StateTransitionTestFiller) -> None:
277+
"""
278+
Test that blocks with mismatched slot are rejected.
279+
280+
Scenario
281+
--------
282+
Attempt to process a block at slot 1, but the block claims to be
283+
at slot 2.
284+
285+
Expected Behavior
286+
-----------------
287+
Block processing fails with AssertionError: "Block slot mismatch"
288+
289+
Why This Matters
290+
----------------
291+
Ensures temporal consistency:
292+
- Blocks can't lie about their slot
293+
- Prevents time manipulation attacks
294+
- Maintains protocol timing integrity
295+
- Essential for slot-based consensus
296+
"""
297+
pre_state = generate_pre_state()
298+
pre_state = pre_state.process_slots(Slot(1))
299+
300+
state_transition_test(
301+
pre=pre_state,
302+
blocks=[
303+
BlockSpec(
304+
slot=Slot(2),
305+
skip_slot_processing=True,
306+
),
307+
],
308+
post=None,
309+
expect_exception=AssertionError,
310+
expect_exception_message="Block slot mismatch",
311+
)
312+
313+
276314
def test_block_extends_deep_chain(
277315
state_transition_test: StateTransitionTestFiller,
278316
) -> None:

0 commit comments

Comments
 (0)