forked from leanEthereum/leanSpec
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstate_transition.py
More file actions
253 lines (205 loc) · 9.22 KB
/
Copy pathstate_transition.py
File metadata and controls
253 lines (205 loc) · 9.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
"""State transition test fixture format."""
from typing import Any, ClassVar, List
from pydantic import ConfigDict, PrivateAttr, field_serializer
from lean_spec.subspecs.containers.block.block import Block, BlockBody
from lean_spec.subspecs.containers.block.types import Attestations
from lean_spec.subspecs.containers.state.state import State
from lean_spec.subspecs.ssz.hash import hash_tree_root
from lean_spec.types import Bytes32, ValidatorIndex
from ..test_types import BlockSpec, StateExpectation
from .base import BaseConsensusFixture
class StateTransitionTest(BaseConsensusFixture):
"""
Test fixture for block processing through state_transition().
This is the primary test type that covers:
- Operations (attestations via blocks)
- Slot advancement (empty slots)
- Multi-block sequences
- Justification and finalization
- Invalid blocks
Tests everything through the main state_transition() public API.
Structure:
pre: Initial consensus state
blocks: Sequence of signed blocks to process
post: Expected state after processing (None if invalid, filled by spec)
expect_exception: Expected exception for invalid tests
"""
format_name: ClassVar[str] = "state_transition_test"
description: ClassVar[str] = (
"Tests block processing through state_transition() - covers operations, "
"epochs, and finality"
)
model_config = ConfigDict(arbitrary_types_allowed=True)
pre: State
"""The initial consensus state before processing."""
blocks: List[BlockSpec]
"""
Block specifications to process through the spec.
Tests provide a list of BlockSpec objects with required slots and optional
field overrides. The framework fills complete Block objects during
make_fixture() and stores them in the private _filled_blocks attribute.
"""
# TODO: We should figure out a configuration to raise if a private attr is
# attempted to be set during model initialization.
_filled_blocks: List[Block] = PrivateAttr(default_factory=list)
"""
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().
"""
post: StateExpectation | None = None
"""
Expected state after processing all blocks.
Only fields explicitly set in the StateExpectation will be validated.
If None, no post-state validation is performed (e.g., for invalid tests).
"""
expect_exception: type[Exception] | None = None
"""Expected exception type for invalid tests."""
@field_serializer("blocks", when_used="json")
def serialize_blocks(self, value: List[BlockSpec]) -> List[dict[str, Any]]:
"""
Serialize the filled `Block`s instead of the `BlockSpec`s.
This ensures the fixture output contains the complete `Blocks` that were
filled from the specs, not the input `BlockSpec`s.
Parameters:
----------
value : List[BlockSpec]
The BlockSpec list (ignored, we use _filled_blocks instead).
Returns:
-------
List[dict[str, Any]]
The serialized Blocks.
"""
del value
return [block.to_json() for block in self._filled_blocks]
@field_serializer("expect_exception", when_used="json")
def serialize_exception(self, value: type[Exception] | None) -> str | None:
"""Serialize exception type to string."""
if value is None:
return None
# Format: "ExceptionClassName" (just the class name for now)
# TODO: This can be used to map exceptions to expected exceptions from clients
# as in execution-spec-tests - e.g., "StateTransitionException.INVALID_SLOT"
return value.__name__
def make_fixture(self) -> "StateTransitionTest":
"""
Generate the fixture by running the spec.
Builds blocks from BlockSpec if needed, then processes them through state_transition.
Returns:
-------
StateTransitionTest
A validated fixture.
Raises:
------
AssertionError
If processing fails unexpectedly or validation fails.
"""
actual_post_state: State | None = None
exception_raised: Exception | None = None
# Initialize filled_blocks list that will be populated as we process blocks
filled_blocks: list[Block] = []
try:
state = self.pre
for block_spec in 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)
# Store the filled Block for serialization
filled_blocks.append(block)
# 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)
)
actual_post_state = state
except (AssertionError, ValueError) as e:
exception_raised = e
# If we expect an exception, this is fine
if self.expect_exception is None:
# Unexpected failure
raise AssertionError(f"Unexpected error processing blocks: {e}") from e
finally:
# Always store filled blocks for serialization, even if an exception occurred
# This ensures the test fixture includes all blocks that were attempted
self._filled_blocks = filled_blocks
# Validate exception expectations
if self.expect_exception is not None:
if exception_raised is None:
raise AssertionError(
f"Expected exception {self.expect_exception.__name__} but processing succeeded"
)
if not isinstance(exception_raised, self.expect_exception):
raise AssertionError(
f"Expected {self.expect_exception.__name__} "
f"but got {type(exception_raised).__name__}: {exception_raised}"
)
# Validate post-state expectations if provided
if self.post is not None and actual_post_state is not None:
self.post.validate_against_state(actual_post_state)
# Return self (fixture is already complete)
return self
def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, State | None]:
"""
Build a Block from a BlockSpec, optionally caching the post-state.
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
Block specification with optional field overrides.
state : State
Current state to build against.
Returns:
-------
tuple[Block, State | None]
Block and cached post-state (None if not computed).
"""
# Use provided proposer_index or compute it
if spec.proposer_index is not None:
proposer_index = spec.proposer_index
else:
proposer_index = ValidatorIndex(int(spec.slot) % int(state.validators.count))
# 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)
# Use provided body or create empty one
if spec.body is not None:
body = spec.body
else:
body = BlockBody(attestations=Attestations(data=[]))
# Compute state_root and cache post-state
cached_post_state: State | None = None
if spec.state_root is not None:
# Explicit override: use provided state root, no caching possible
state_root = spec.state_root
else:
# Compute state_root via dry-run state transition
temp_state = state.process_slots(spec.slot)
temp_block = Block(
slot=spec.slot,
proposer_index=proposer_index,
parent_root=parent_root,
state_root=Bytes32.zero(),
body=body,
)
# For invalid tests, return incomplete block without processing
if self.expect_exception is not None:
return temp_block, None
# Run state transition once and cache result
cached_post_state = temp_state.process_block(temp_block)
state_root = hash_tree_root(cached_post_state)
# Return final block with cached post-state
block = Block(
slot=spec.slot,
proposer_index=proposer_index,
parent_root=parent_root,
state_root=state_root,
body=body,
)
return block, cached_post_state