-
Notifications
You must be signed in to change notification settings - Fork 447
Expand file tree
/
Copy pathbase.py
More file actions
326 lines (285 loc) · 10.5 KB
/
base.py
File metadata and controls
326 lines (285 loc) · 10.5 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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""
Base test class and helper functions for Ethereum state and blockchain tests.
"""
from abc import abstractmethod
from enum import StrEnum, unique
from functools import reduce
from typing import (
Any,
Callable,
ClassVar,
Dict,
Generator,
List,
Sequence,
Type,
)
import pytest
from pydantic import BaseModel, ConfigDict, Field
from typing_extensions import Self
from execution_testing.base_types import to_hex
from execution_testing.client_clis import Result, TransitionTool
from execution_testing.client_clis.cli_types import OpcodeCount
from execution_testing.execution import (
BaseExecute,
ExecuteFormat,
LabeledExecuteFormat,
)
from execution_testing.fixtures import (
BaseFixture,
FixtureFormat,
LabeledFixtureFormat,
)
from execution_testing.fixtures.post_verifications import PostVerifications
from execution_testing.forks import Fork, TransitionFork
from execution_testing.forks.base_fork import BaseFork
from execution_testing.test_types import Environment, Withdrawal
from execution_testing.test_types.receipt_types import (
TransactionReceipt,
)
class HashMismatchExceptionError(Exception):
"""Exception raised when the expected and actual hashes don't match."""
def __init__(
self,
expected_hash: str,
actual_hash: str,
message: str = "Hashes do not match",
) -> None:
"""Initialize the exception with the expected and actual hashes."""
self.expected_hash = expected_hash
self.actual_hash = actual_hash
self.message = message
super().__init__(self.message)
def __str__(self) -> str:
"""Return the error message."""
return (
f"{self.message}: Expected {self.expected_hash}, "
f"got {self.actual_hash}"
)
def verify_result(result: Result, env: Environment) -> None:
"""
Verify that values in the t8n result match the expected values. Raises
exception on unexpected values.
"""
if env.withdrawals is not None:
assert result.withdrawals_root == to_hex(
Withdrawal.list_root(env.withdrawals)
)
@unique
class OpMode(StrEnum):
"""Operation mode for the fill and execute."""
CONSENSUS = "consensus"
BENCHMARKING = "benchmarking"
OPTIMIZE_GAS = "optimize-gas"
OPTIMIZE_GAS_POST_PROCESSING = "optimize-gas-post-processing"
class FillResult(BaseModel):
"""
Result of the filling operation, returned by the `generate` method.
"""
fixture: BaseFixture
gas_optimization: int | None
benchmark_gas_used: int | None = None
benchmark_opcode_count: OpcodeCount | None = None
post_verifications: PostVerifications | None = None
metadata: Dict[str, Any] = Field(default_factory=dict)
class BaseTest(BaseModel):
"""
Represents a base Ethereum test which must return a single test fixture.
"""
model_config = ConfigDict(extra="forbid")
fork: Fork | TransitionFork = (
BaseFork
# default to BaseFork to allow the filler to set it,
# instead of each test having to set it
)
operation_mode: OpMode | None = None
gas_optimization_max_gas_limit: int | None = None
expected_benchmark_gas_used: int | None = None
skip_gas_used_validation: bool = False
expected_receipt_status: int | None = None
is_tx_gas_heavy_test: bool = False
is_exception_test: bool = False
# Class variables, to be set by subclasses
spec_types: ClassVar[Dict[str, Type["BaseTest"]]] = {}
supported_fixture_formats: ClassVar[
Sequence[FixtureFormat | LabeledFixtureFormat]
] = []
supported_execute_formats: ClassVar[Sequence[LabeledExecuteFormat]] = []
supported_markers: ClassVar[Dict[str, str]] = {}
def model_post_init(self, __context: Any, /) -> None:
"""
Model post-init to assert that the custom pre-allocation was
provided and the default was not used.
"""
super().model_post_init(__context)
assert self.fork != BaseFork, (
"Fork was not provided by the filler/executor."
)
@classmethod
def discard_fixture_format_by_marks(
cls,
fixture_format: FixtureFormat,
fork: Fork | TransitionFork,
markers: List[pytest.Mark],
) -> bool:
"""
Discard a fixture format from filling if the appropriate marker is
used.
"""
del fork, fixture_format, markers
return False
@classmethod
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
"""
Register all subclasses of BaseFixture with a fixture format name set
as possible fixture formats.
"""
super().__pydantic_init_subclass__(**kwargs)
# Don't register dynamically generated wrapper classes
if getattr(cls, "__is_base_test_wrapper__", False):
return
if cls.pytest_parameter_name():
# Register the new fixture format
BaseTest.spec_types[cls.pytest_parameter_name()] = cls
@classmethod
def from_test(
cls: Type[Self],
*,
base_test: "BaseTest",
**kwargs: Any,
) -> Self:
"""Create a test in a different format from a base test."""
for k in BaseTest.model_fields.keys():
if k not in kwargs and k in base_test.model_fields_set:
kwargs[k] = getattr(base_test, k)
return cls(**kwargs)
@classmethod
def discard_execute_format_by_marks(
cls,
execute_format: ExecuteFormat,
fork: Fork | TransitionFork,
markers: List[pytest.Mark],
) -> bool:
"""
Discard an execute format from executing if the appropriate marker is
used.
"""
del execute_format, fork, markers
return False
@abstractmethod
def generate(
self,
*,
t8n: TransitionTool,
fixture_format: FixtureFormat,
) -> FillResult:
"""Generate the test fixture using the given fixture format."""
pass
def execute(
self,
*,
execute_format: ExecuteFormat,
) -> BaseExecute:
"""Generate the list of test fixtures."""
raise Exception(f"Unsupported execute format: {execute_format}")
@classmethod
def pytest_parameter_name(cls) -> str:
"""
Must return the name of the parameter used in pytest to select this
spec type as filler for the test.
By default, it returns the underscore separated name of the class.
"""
if cls == BaseTest:
return ""
return reduce(
lambda x, y: x + ("_" if y.isupper() else "") + y, cls.__name__
).lower()
def check_exception_test(
self,
*,
exception: bool,
) -> None:
"""Compare the test marker against the outcome of the test."""
if self.is_exception_test != exception:
if exception:
raise Exception(
"Test produced an invalid block or transaction but was "
"not marked with the `exception_test` marker. Add the "
"`@pytest.mark.exception_test` decorator to the test."
)
else:
raise Exception(
"Test didn't produce an invalid block or transaction but "
"was marked with the `exception_test` marker. Remove the "
"`@pytest.mark.exception_test` decorator from the test."
)
def get_genesis_environment(self) -> Environment:
"""
Get the genesis environment for pre-allocation groups.
Must be implemented by subclasses to provide the appropriate
environment.
"""
raise NotImplementedError(
f"{self.__class__.__name__} must implement genesis environment "
"access for use with pre-allocation groups."
)
def validate_benchmark_gas(
self, *, benchmark_gas_used: int | None, gas_benchmark_value: int
) -> None:
"""
Validates the total consumed gas of the last block in the test matches
the expectation of the benchmark test.
Requires the following fields to be set:
- expected_benchmark_gas_used
- operation_mode
"""
if self.operation_mode != OpMode.BENCHMARKING:
return
assert benchmark_gas_used is not None, "_benchmark_gas_used is not set"
# Perform gas validation if required for benchmarking.
# Ensures benchmark tests consume exactly the expected gas.
if not self.skip_gas_used_validation:
# Verify that the total gas consumed in the last block
# matches expectations
expected_benchmark_gas_used = self.expected_benchmark_gas_used
if expected_benchmark_gas_used is None:
expected_benchmark_gas_used = gas_benchmark_value
diff = benchmark_gas_used - expected_benchmark_gas_used
assert benchmark_gas_used == expected_benchmark_gas_used, (
f"Total gas used ({benchmark_gas_used}) does not "
"match expected benchmark gas "
f"({expected_benchmark_gas_used}), "
f"difference: {diff}"
)
# Gas used should never exceed the maximum benchmark gas allowed.
assert benchmark_gas_used <= gas_benchmark_value, (
f"benchmark_gas_used ({benchmark_gas_used}) exceeds maximum "
"benchmark gas allowed for this configuration: "
f"{gas_benchmark_value}"
)
def validate_receipt_status(
self,
*,
receipts: List[TransactionReceipt],
block_number: int,
) -> None:
"""
Validate receipt status for every transaction in a block.
When expected_receipt_status is set, verify that all
receipts match. Catches silent OOG failures that roll
back state and invalidate benchmarks.
"""
if "expected_receipt_status" not in self.model_fields_set:
return
for i, receipt in enumerate(receipts):
if receipt.status is not None and (
int(receipt.status) != self.expected_receipt_status
):
raise Exception(
f"Transaction {i} in block "
f"{block_number} has receipt "
f"status {int(receipt.status)}, "
f"expected "
f"{self.expected_receipt_status}."
)
TestSpec = Callable[[Fork], Generator[BaseTest, None, None]]