Skip to content

Commit f57f0fc

Browse files
committed
feat(test-specs): add REST+SSZ blockchain fixtures and SSZ vector generator
1 parent b81a658 commit f57f0fc

10 files changed

Lines changed: 950 additions & 18 deletions

File tree

packages/testing/src/execution_testing/fixtures/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
BlockchainFixture,
1515
BlockchainFixtureCommon,
1616
)
17+
from .blockchain_rest_ssz import (
18+
BlockchainRestSszFixture,
19+
FixtureRestExecutionPayload,
20+
FixtureRestNegative,
21+
FixtureRestPayload,
22+
FixtureRestWithdrawal,
23+
PayloadStatusV2,
24+
RestPayloadMutation,
25+
)
1726
from .collector import (
1827
FixtureCollector,
1928
TestInfo,
@@ -37,15 +46,22 @@
3746
"BlockchainEngineXFixture",
3847
"BlockchainFixture",
3948
"BlockchainFixtureCommon",
49+
"BlockchainRestSszFixture",
4050
"FixtureCollector",
4151
"FixtureConsumer",
4252
"FixtureFillingPhase",
4353
"FixtureFormat",
54+
"FixtureRestExecutionPayload",
55+
"FixtureRestNegative",
56+
"FixtureRestPayload",
57+
"FixtureRestWithdrawal",
4458
"LabeledFixtureFormat",
59+
"PayloadStatusV2",
4560
"PreAllocGroup",
4661
"PreAllocGroupBuilder",
4762
"PreAllocGroupBuilders",
4863
"PreAllocGroups",
64+
"RestPayloadMutation",
4965
"StateFixture",
5066
"TestInfo",
5167
"TransactionFixture",
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""
2+
Fixture models for the REST+SSZ blockchain test format.
3+
4+
These pydantic models are the on-disk contract between ``fill`` (which emits
5+
them) and ``consume`` (which reads them) for the REST+SSZ Engine API proposed
6+
in execution-apis PR #793. This module is *only* the data definitions: there
7+
is no fixture-generator logic and no consumer logic here, so a reviewer can
8+
cross-check each field against the #793 spec table in isolation.
9+
10+
The single point where the pydantic world touches the SSZ world is
11+
:meth:`FixtureRestPayload.to_ssz`, which calls the ``envelope_bytes`` adapter
12+
re-exported from :mod:`execution_testing.ssz`. ``remerkleable`` is never
13+
imported here; the SSZ library stays quarantined behind the ``ssz`` package.
14+
"""
15+
16+
from enum import Enum
17+
from functools import cached_property
18+
from typing import Any, ClassVar, List
19+
20+
from pydantic import Field, computed_field, model_validator
21+
22+
from execution_testing.base_types import (
23+
Address,
24+
Alloc,
25+
Bloom,
26+
Bytes,
27+
CamelModel,
28+
Hash,
29+
HexNumber,
30+
)
31+
from execution_testing.forks import Amsterdam, Fork
32+
from execution_testing.ssz import envelope_bytes
33+
34+
from .base import BaseFixture
35+
from .blockchain import FixtureHeader
36+
37+
38+
class PayloadStatusV2(str, Enum):
39+
"""
40+
Status returned by the REST+SSZ ``newPayload`` endpoint (#793).
41+
42+
NOTE: There is deliberately no ``INVALID_BLOCK_HASH`` member. PR #793
43+
removes that status from the enum -- a client that previously answered
44+
``INVALID_BLOCK_HASH`` now answers plain ``INVALID``. Its absence here is
45+
an assertion about that spec change, not an oversight: do not re-add it to
46+
"match" the legacy JSON-RPC ``PayloadStatusEnum`` in
47+
:mod:`execution_testing.rpc.rpc_types`, which still carries it for the
48+
pre-#793 Engine API.
49+
"""
50+
51+
VALID = "VALID"
52+
INVALID = "INVALID"
53+
SYNCING = "SYNCING"
54+
ACCEPTED = "ACCEPTED"
55+
56+
57+
class RestPayloadMutation(str, Enum):
58+
"""
59+
Wire-level mutation applied to a valid payload for a transport negative
60+
test.
61+
62+
Each member names a way to corrupt the request *envelope* (not its
63+
consensus contents) so the server is expected to reject it with an HTTP
64+
error before any consensus validation runs. The mutation logic itself
65+
lives in the consume plugin (a later PR); this enum only fixes the set of
66+
mutations the fixtures may reference.
67+
"""
68+
69+
TRUNCATE_BODY = "TRUNCATE_BODY"
70+
NONMONOTONIC_OFFSET = "NONMONOTONIC_OFFSET"
71+
WRONG_CONTENT_TYPE = "WRONG_CONTENT_TYPE"
72+
FORK_MISMATCH = "FORK_MISMATCH"
73+
74+
75+
class FixtureRestWithdrawal(CamelModel):
76+
"""A validator withdrawal inside a REST+SSZ execution payload."""
77+
78+
index: HexNumber
79+
validator_index: HexNumber
80+
address: Address
81+
amount: HexNumber
82+
83+
84+
class FixtureRestExecutionPayload(CamelModel):
85+
"""
86+
A REST+SSZ ``ExecutionPayloadEnvelope`` in human-readable fixture form,
87+
fork-parameterized.
88+
89+
``fork`` (a registry key such as ``"Amsterdam"`` / ``"Cancun"``) selects
90+
the per-fork SSZ shape this represents; it is fixture metadata, not an SSZ
91+
field. The remaining fields are the union of every fork's payload and
92+
envelope fields, in canonical order, so a single model serves all forks.
93+
Fields a fork does not carry are left ``None``:
94+
95+
* base fields (``parent_hash`` .. ``transactions``) -- every fork,
96+
* ``withdrawals`` -- Shanghai+,
97+
* ``blob_gas_used`` / ``excess_blob_gas`` -- Cancun+,
98+
* ``block_access_list`` / ``slot_number`` -- Amsterdam+,
99+
* ``parent_beacon_block_root`` (envelope) -- Cancun+,
100+
* ``execution_requests`` (envelope) -- Prague+.
101+
102+
The container actually built for ``fork`` is chosen from the registries in
103+
:mod:`execution_testing.ssz`, which include only the fields that fork
104+
declares; supplying a field a fork lacks is harmless, omitting one it needs
105+
raises. The drift round-trip test fails loudly if this model and the
106+
remerkleable container diverge for a fork.
107+
"""
108+
109+
fork: str
110+
parent_hash: Hash
111+
fee_recipient: Address
112+
state_root: Hash
113+
receipts_root: Hash
114+
logs_bloom: Bloom
115+
prev_randao: Hash
116+
block_number: HexNumber
117+
gas_limit: HexNumber
118+
gas_used: HexNumber
119+
timestamp: HexNumber
120+
extra_data: Bytes
121+
base_fee_per_gas: HexNumber
122+
block_hash: Hash
123+
transactions: List[Bytes]
124+
withdrawals: List[FixtureRestWithdrawal] | None = None
125+
blob_gas_used: HexNumber | None = None
126+
excess_blob_gas: HexNumber | None = None
127+
block_access_list: Bytes | None = Field(
128+
None, description="RLP-serialized EIP-7928 Block Access List"
129+
)
130+
slot_number: HexNumber | None = None
131+
parent_beacon_block_root: Hash | None = None
132+
execution_requests: List[Bytes] | None = None
133+
134+
135+
class FixtureRestPayload(CamelModel):
136+
"""
137+
A single ``newPayload`` directive in a REST+SSZ blockchain test.
138+
139+
Carries the execution payload, the expected :class:`PayloadStatusV2`
140+
response, and -- for ``INVALID`` cases -- an optional ``validationError``
141+
string. It deliberately does *not* carry ``expectedBlobVersionedHashes``
142+
(removed in #793) and there is no field for an ``INVALID_BLOCK_HASH``
143+
verdict (removed from the status enum).
144+
"""
145+
146+
payload: FixtureRestExecutionPayload
147+
expected_status: PayloadStatusV2
148+
validation_error: str | None = Field(None)
149+
150+
@model_validator(mode="before")
151+
@classmethod
152+
def _strip_ssz_computed_field(cls, data: Any) -> Any:
153+
"""
154+
Drop the ``ssz`` computed field when re-reading a fixture from disk.
155+
156+
``ssz`` is emitted for human inspection but is not an input field;
157+
without this the ``extra="forbid"`` config rejects round-tripped
158+
fixtures.
159+
"""
160+
if isinstance(data, dict):
161+
data.pop("ssz", None)
162+
return data
163+
164+
def to_ssz(self) -> bytes:
165+
"""
166+
Return the SSZ-encoded ``ExecutionPayloadEnvelope`` wire bytes.
167+
168+
The one place the pydantic layer crosses into SSZ: it forwards the
169+
payload's field values (and its fork) to the fork-parameterized
170+
``envelope_bytes`` adapter and returns the raw bytes. Keep this thin --
171+
no logic beyond the call-through. The consume layer uses these bytes;
172+
the fixture's stored ``ssz`` hex is only for inspection.
173+
"""
174+
p = self.payload
175+
return envelope_bytes(
176+
p.fork,
177+
parent_hash=p.parent_hash,
178+
fee_recipient=p.fee_recipient,
179+
state_root=p.state_root,
180+
receipts_root=p.receipts_root,
181+
logs_bloom=p.logs_bloom,
182+
prev_randao=p.prev_randao,
183+
block_number=p.block_number,
184+
gas_limit=p.gas_limit,
185+
gas_used=p.gas_used,
186+
timestamp=p.timestamp,
187+
extra_data=p.extra_data,
188+
base_fee_per_gas=p.base_fee_per_gas,
189+
block_hash=p.block_hash,
190+
transactions=p.transactions,
191+
withdrawals=(
192+
None
193+
if p.withdrawals is None
194+
else [w.model_dump() for w in p.withdrawals]
195+
),
196+
blob_gas_used=p.blob_gas_used,
197+
excess_blob_gas=p.excess_blob_gas,
198+
block_access_list=p.block_access_list,
199+
slot_number=p.slot_number,
200+
parent_beacon_block_root=p.parent_beacon_block_root,
201+
execution_requests=p.execution_requests,
202+
)
203+
204+
@computed_field # type: ignore[prop-decorator]
205+
@cached_property
206+
def ssz(self) -> Bytes:
207+
"""SSZ wire bytes as a hex string, stored for human inspection."""
208+
return Bytes(self.to_ssz())
209+
210+
211+
class FixtureRestNegative(CamelModel):
212+
"""
213+
A transport-only negative directive: a wire-level error, not a consensus
214+
verdict.
215+
216+
Unlike :class:`FixtureRestPayload`, whose expected outcome is a
217+
consensus :class:`PayloadStatusV2`, the expected outcome here is an HTTP
218+
error code. A valid base payload is corrupted per ``mutation`` and the
219+
server is expected to answer with ``expected_http_status`` before reaching
220+
consensus validation. That difference in outcome -- HTTP code vs. consensus
221+
status -- is the whole reason this is a separate model.
222+
223+
Speculative until the consume plugin implements the mutation logic: the
224+
mutation set is sketched ahead of its consumer from the known negative
225+
vectors, since adding a mutation variant later is cheaper than reshaping
226+
the model.
227+
"""
228+
229+
payload: FixtureRestExecutionPayload
230+
mutation: RestPayloadMutation
231+
expected_http_status: int
232+
233+
234+
class BlockchainRestSszFixture(BaseFixture):
235+
"""Top-level REST+SSZ blockchain test fixture."""
236+
237+
format_name: ClassVar[str] = "blockchain_test_rest_ssz"
238+
description: ClassVar[str] = (
239+
"Tests that generate a blockchain test fixture for the REST+SSZ "
240+
"Engine API."
241+
)
242+
243+
fork: Fork = Field(..., alias="network")
244+
pre: Alloc
245+
genesis: FixtureHeader = Field(..., alias="genesisBlockHeader")
246+
payloads: List[FixtureRestPayload] = Field(..., alias="restPayloads")
247+
last_forkchoice_head: Hash
248+
"""
249+
Head the forkchoice settles on after the final payload.
250+
251+
Typed as a plain :class:`Hash` for now. Its semantics depend on the
252+
still-unresolved forkchoice-atomicity question in #793: if forkchoice
253+
stays atomic with ``newPayload`` this single hash suffices, but if it
254+
splits into a separate payload-attributes call this field's shape will
255+
need to change. Treat the shape as provisional until that settles.
256+
"""
257+
258+
def get_fork(self) -> Fork | None:
259+
"""Return the fixture's fork."""
260+
return self.fork
261+
262+
@classmethod
263+
def supports_fork(cls, fork: Fork) -> bool:
264+
"""
265+
Return whether the fixture can be generated for ``fork``.
266+
267+
The models and the SSZ bridge are fork-parameterized (every payload
268+
fork has a container), but the REST+SSZ Engine API itself is introduced
269+
by PR #793 at Amsterdam, so generation is gated there. Lower this bound
270+
if/when earlier-fork REST+SSZ endpoints become testable -- no model
271+
change is required.
272+
"""
273+
return fork >= Amsterdam

0 commit comments

Comments
 (0)