Skip to content

Commit 693dbbd

Browse files
fix(fill-stateful): verify receipt status when filling stateful fixtures (#3142)
* fix(fill-stateful): verify receipt status when filling stateful fixtures * chore: small suggestion * fix(fill-stateful): build Osaka-valid headers in receipt-status unit tests * refactor: add missing attribute - add extract_opcode_count, debug_rpc to client_backend --------- Co-authored-by: LouisTsai <q1030176@gmail.com>
1 parent e6382bc commit 693dbbd

3 files changed

Lines changed: 267 additions & 1 deletion

File tree

packages/testing/src/execution_testing/specs/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ def validate_receipt_status(
307307
receipts match. Catches silent OOG failures that roll
308308
back state and invalidate benchmarks.
309309
"""
310-
if "expected_receipt_status" not in self.model_fields_set:
310+
if self.expected_receipt_status is None:
311311
return
312312
for i, receipt in enumerate(receipts):
313313
if receipt.status is not None and (

packages/testing/src/execution_testing/specs/blockchain.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,8 @@ class TestingBuildBlock(BuiltBlock):
606606
so ``make_stateful_fixture`` can record what the client built.
607607
"""
608608

609+
__test__ = False # "Test" prefix; keep pytest from collecting it
610+
609611
model_config = CamelModel.model_config | {"arbitrary_types_allowed": True}
610612

611613
engine_payload: EnginePayloadMetadata
@@ -1504,6 +1506,14 @@ def make_stateful_fixture(
15041506
if block_opcode_count is not None
15051507
else None
15061508
)
1509+
# Setup blocks (pre-alloc funding/deploys) are exempt:
1510+
# ``expected_receipt_status`` describes the test's own
1511+
# transactions, and setup txs always succeed.
1512+
if built_block.result.receipts:
1513+
self.validate_receipt_status(
1514+
receipts=built_block.result.receipts,
1515+
block_number=int(built_block.header.number),
1516+
)
15071517
if self.operation_mode == OpMode.BENCHMARKING:
15081518
benchmark_gas_used = int(built_block.result.gas_used)
15091519
# Consumed by BenchmarkTest's opcode-count verification.
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"""Test suite for receipt-status verification in ``make_stateful_fixture``."""
2+
3+
from typing import Any, Iterator, List
4+
5+
import pytest
6+
7+
from execution_testing.base_types import Address, Bloom, Hash
8+
from execution_testing.client_clis import ClientBackend
9+
from execution_testing.client_clis.cli_types import (
10+
EnginePayloadMetadata,
11+
LazyAllocJson,
12+
Result,
13+
)
14+
from execution_testing.fixtures.blockchain import (
15+
BlockchainEngineStatefulFixture,
16+
FixtureExecutionPayload,
17+
FixtureHeader,
18+
)
19+
from execution_testing.forks import Osaka
20+
from execution_testing.rpc.rpc_types import GetPayloadResponse
21+
from execution_testing.test_types import (
22+
Alloc,
23+
Environment,
24+
TestPhase,
25+
Transaction,
26+
)
27+
from execution_testing.test_types.receipt_types import TransactionReceipt
28+
29+
from ..base import FillResult
30+
from ..blockchain import Block, BlockchainTest, TestingBuildBlock
31+
32+
FORK = Osaka
33+
START_BLOCK_NUMBER = 1
34+
35+
36+
def _header(number: int) -> FixtureHeader:
37+
"""Build a minimal valid header for the test fork."""
38+
return FixtureHeader(
39+
fork=FORK,
40+
fee_recipient=Address(0),
41+
state_root=Hash(0),
42+
number=number,
43+
gas_limit=30_000_000,
44+
gas_used=21_000,
45+
timestamp=number * 12,
46+
extra_data=b"\x00",
47+
base_fee_per_gas=7,
48+
withdrawals_root=Hash(0),
49+
blob_gas_used=0,
50+
excess_blob_gas=0,
51+
parent_beacon_block_root=Hash(0),
52+
requests_hash=Hash(0),
53+
)
54+
55+
56+
def _tx(phase: TestPhase) -> Transaction:
57+
"""Build a Transaction tagged with the given test phase."""
58+
tx = Transaction()
59+
tx.test_phase = phase
60+
return tx
61+
62+
63+
def _built_block(number: int, statuses: List[int]) -> TestingBuildBlock:
64+
"""
65+
Build a ``TestingBuildBlock`` whose receipts carry ``statuses``,
66+
mimicking what ``ClientBackend.evaluate`` assembles from a live
67+
client's ``testing_buildBlockV1`` + ``eth_getTransactionReceipt``.
68+
"""
69+
header = _header(number)
70+
payload = FixtureExecutionPayload.from_fixture_header(
71+
header=header,
72+
transactions=[],
73+
withdrawals=None,
74+
)
75+
new_payload_version = FORK.engine_new_payload_version()
76+
forkchoice_updated_version = FORK.engine_forkchoice_updated_version()
77+
assert new_payload_version is not None
78+
assert forkchoice_updated_version is not None
79+
return TestingBuildBlock(
80+
header=header,
81+
env=Environment(number=number, timestamp=number * 12),
82+
alloc=LazyAllocJson(raw={}, _state_root=Hash(0)),
83+
state_root=Hash(0),
84+
txs=[],
85+
ommers=[],
86+
withdrawals=None,
87+
requests=None,
88+
result=Result(
89+
state_root=Hash(0),
90+
transactions_trie=Hash(0),
91+
receipts_root=Hash(0),
92+
logs_hash=Hash(0),
93+
logs_bloom=Bloom(0),
94+
receipts=[TransactionReceipt(status=s) for s in statuses],
95+
gas_used=21_000,
96+
),
97+
fork=FORK,
98+
block_access_list=None,
99+
engine_payload=EnginePayloadMetadata(
100+
payload_response=GetPayloadResponse(execution_payload=payload),
101+
new_payload_version=new_payload_version,
102+
forkchoice_updated_version=forkchoice_updated_version,
103+
parent_beacon_block_root=Hash(0),
104+
),
105+
)
106+
107+
108+
@pytest.fixture
109+
def client_backend() -> ClientBackend:
110+
"""
111+
Stub ``ClientBackend`` with snapshot/start blocks pre-captured.
112+
"""
113+
# ``__new__`` skips ``__init__``: no live RPC endpoints are needed
114+
# because ``generate_block_data`` is monkeypatched below.
115+
backend = ClientBackend.__new__(ClientBackend)
116+
start_header = _header(START_BLOCK_NUMBER)
117+
block_dict = start_header.model_dump(
118+
by_alias=True, mode="json", exclude_none=True
119+
)
120+
block_dict["hash"] = str(start_header.block_hash)
121+
backend.fork = FORK
122+
backend.snapshot_block = block_dict
123+
backend.start_block = block_dict
124+
backend.extract_opcode_count = False
125+
backend.debug_rpc = None
126+
return backend
127+
128+
129+
def _fill_stateful(
130+
monkeypatch: pytest.MonkeyPatch,
131+
client_backend: ClientBackend,
132+
statuses_per_block: List[List[int]],
133+
phases: List[TestPhase],
134+
**kwargs: Any,
135+
) -> FillResult:
136+
"""
137+
Run ``make_stateful_fixture`` with one block per entry of ``phases``,
138+
stubbing ``generate_block_data`` to return receipts with the matching
139+
``statuses_per_block`` entry.
140+
"""
141+
assert len(statuses_per_block) == len(phases)
142+
calls: Iterator[List[int]] = iter(statuses_per_block)
143+
block_numbers = iter(
144+
range(START_BLOCK_NUMBER + 1, START_BLOCK_NUMBER + 1 + len(phases))
145+
)
146+
147+
def fake_generate_block_data(
148+
_self: BlockchainTest, **_kwargs: Any
149+
) -> TestingBuildBlock:
150+
return _built_block(next(block_numbers), next(calls))
151+
152+
monkeypatch.setattr(
153+
BlockchainTest, "generate_block_data", fake_generate_block_data
154+
)
155+
test = BlockchainTest(
156+
fork=FORK,
157+
pre=Alloc(),
158+
post=Alloc(),
159+
blocks=[Block(txs=[_tx(phase)]) for phase in phases],
160+
**kwargs,
161+
)
162+
return test.make_stateful_fixture(client_backend)
163+
164+
165+
def test_execution_status_mismatch_raises(
166+
monkeypatch: pytest.MonkeyPatch, client_backend: ClientBackend
167+
) -> None:
168+
"""A status-0 receipt with ``expected_receipt_status=1`` must throw."""
169+
with pytest.raises(Exception, match=r"receipt status 0, expected 1"):
170+
_fill_stateful(
171+
monkeypatch,
172+
client_backend,
173+
statuses_per_block=[[0]],
174+
phases=[TestPhase.EXECUTION],
175+
expected_receipt_status=1,
176+
)
177+
178+
179+
def test_expected_failure_but_tx_succeeded_raises(
180+
monkeypatch: pytest.MonkeyPatch, client_backend: ClientBackend
181+
) -> None:
182+
"""A status-1 receipt with ``expected_receipt_status=0`` must throw."""
183+
with pytest.raises(Exception, match=r"receipt status 1, expected 0"):
184+
_fill_stateful(
185+
monkeypatch,
186+
client_backend,
187+
statuses_per_block=[[1]],
188+
phases=[TestPhase.EXECUTION],
189+
expected_receipt_status=0,
190+
)
191+
192+
193+
def test_single_failed_receipt_in_block_raises(
194+
monkeypatch: pytest.MonkeyPatch, client_backend: ClientBackend
195+
) -> None:
196+
"""One bad receipt among good ones is enough to throw."""
197+
with pytest.raises(
198+
Exception,
199+
match=r"Transaction 2 in block \d+ has receipt status 0",
200+
):
201+
_fill_stateful(
202+
monkeypatch,
203+
client_backend,
204+
statuses_per_block=[[1, 1, 0]],
205+
phases=[TestPhase.EXECUTION],
206+
expected_receipt_status=1,
207+
)
208+
209+
210+
@pytest.mark.parametrize("status", [0, 1])
211+
def test_matching_status_fills(
212+
monkeypatch: pytest.MonkeyPatch,
213+
client_backend: ClientBackend,
214+
status: int,
215+
) -> None:
216+
"""Receipts matching ``expected_receipt_status`` fill normally."""
217+
result = _fill_stateful(
218+
monkeypatch,
219+
client_backend,
220+
statuses_per_block=[[status, status]],
221+
phases=[TestPhase.EXECUTION],
222+
expected_receipt_status=status,
223+
)
224+
assert isinstance(result.fixture, BlockchainEngineStatefulFixture)
225+
226+
227+
def test_setup_phase_blocks_are_exempt(
228+
monkeypatch: pytest.MonkeyPatch, client_backend: ClientBackend
229+
) -> None:
230+
"""
231+
``expected_receipt_status`` describes the test's own transactions;
232+
a mismatching status in a SETUP-phase block must not throw.
233+
"""
234+
result = _fill_stateful(
235+
monkeypatch,
236+
client_backend,
237+
statuses_per_block=[[0], [1]],
238+
phases=[TestPhase.SETUP, TestPhase.EXECUTION],
239+
expected_receipt_status=1,
240+
)
241+
assert isinstance(result.fixture, BlockchainEngineStatefulFixture)
242+
assert len(result.fixture.setup_payloads) == 1
243+
assert len(result.fixture.payloads) == 1
244+
245+
246+
def test_unset_expected_status_skips_validation(
247+
monkeypatch: pytest.MonkeyPatch, client_backend: ClientBackend
248+
) -> None:
249+
"""Without ``expected_receipt_status``, any status fills normally."""
250+
result = _fill_stateful(
251+
monkeypatch,
252+
client_backend,
253+
statuses_per_block=[[0, 1]],
254+
phases=[TestPhase.EXECUTION],
255+
)
256+
assert isinstance(result.fixture, BlockchainEngineStatefulFixture)

0 commit comments

Comments
 (0)