Skip to content

Commit 560d632

Browse files
committed
feat(consume): add engine-witness simulator for witness verification
Adds `consume engine-witness`, a Hive simulator that drives Amsterdam blockchain-engine fixtures through a witness-emitting endpoint and diffs the client-generated `ExecutionWitness` against the fixture's expected witness (set-equality on state/codes/headers). Supports two transports: - Default: JSON-RPC `engine_newPayloadWithWitnessVX` with RLP witness (geth PR #30069, already implemented in go-ethereum and forks). - `--ssz`: REST `POST /new-payload-with-witness` with SSZ witness (execution-apis PR #773, implemented on Ethrex `feat/zkengine-http`). Both paths converge on a shared `NewPayloadWithWitnessResponse` dataclass and the same assertion helper. Clients that do not implement the chosen transport skip cleanly (HTTP 404/405 for REST, `-32601 Method not found` for JSON-RPC).
1 parent 713d1ee commit 560d632

12 files changed

Lines changed: 934 additions & 1 deletion

File tree

packages/testing/src/execution_testing/cli/pytest_commands/consume.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ def get_command_logic_test_paths(command_name: str) -> List[Path]:
5151
/ "simulator_logic"
5252
/ f"test_via_{test_command}.py"
5353
]
54+
elif command_name == "engine_witness":
55+
command_logic_test_paths = [
56+
base_path
57+
/ "simulators"
58+
/ "simulator_logic"
59+
/ "test_via_engine_witness.py"
60+
]
5461
elif command_name == "sync":
5562
command_logic_test_paths = [
5663
base_path / "simulators" / "simulator_logic" / "test_via_sync.py"
@@ -79,9 +86,10 @@ def decorator(func: Callable[..., Any]) -> click.Command:
7986
command_name = func.__name__
8087
command_help = func.__doc__
8188
command_logic_test_paths = get_command_logic_test_paths(command_name)
89+
cli_name = command_name.replace("_", "-")
8290

8391
@consume.command(
84-
name=command_name,
92+
name=cli_name,
8593
help=command_help,
8694
context_settings={"ignore_unknown_options": True},
8795
)
@@ -120,6 +128,18 @@ def engine() -> None:
120128
pass
121129

122130

131+
@consume_command(is_hive=True)
132+
def engine_witness() -> None:
133+
"""
134+
Verify client-emitted execution witnesses against the fixture.
135+
136+
Default transport: engine_newPayloadWithWitnessVX JSON-RPC (geth).
137+
Pass --ssz to use the REST POST /new-payload-with-witness endpoint
138+
(execution-apis PR #773).
139+
"""
140+
pass
141+
142+
123143
@consume_command(is_hive=True)
124144
def enginex() -> None:
125145
"""Consume via Engine API with pre-alloc optimization."""

packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py

Whitespace-only changes.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Pytest fixtures for the `consume engine-witness` simulator.
3+
4+
Drives the Hive back-end and EL clients through the REST
5+
`POST /new-payload-with-witness` endpoint (execution-apis PR #773),
6+
asserting the client-generated execution witness matches the fixture.
7+
"""
8+
9+
import io
10+
from typing import Mapping
11+
12+
import pytest
13+
from hive.client import Client
14+
15+
from execution_testing.exceptions import ExceptionMapper
16+
from execution_testing.fixtures import BlockchainEngineFixture
17+
from execution_testing.fixtures.blockchain import FixtureHeader
18+
from execution_testing.rpc import EngineWitnessRPC
19+
20+
pytest_plugins = (
21+
"execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive",
22+
"execution_testing.cli.pytest_commands.plugins.consume.simulators.base",
23+
"execution_testing.cli.pytest_commands.plugins.consume.simulators.single_test_client",
24+
"execution_testing.cli.pytest_commands.plugins.consume.simulators.test_case_description",
25+
"execution_testing.cli.pytest_commands.plugins.consume.simulators.timing_data",
26+
"execution_testing.cli.pytest_commands.plugins.consume.simulators.exceptions",
27+
"execution_testing.cli.pytest_commands.plugins.consume.simulators.engine_api",
28+
)
29+
30+
31+
def pytest_addoption(parser: pytest.Parser) -> None:
32+
"""Register the `--ssz` transport flag for the engine-witness simulator."""
33+
parser.addoption(
34+
"--ssz",
35+
action="store_true",
36+
default=False,
37+
help=(
38+
"Use the REST POST /new-payload-with-witness endpoint with "
39+
"SSZ-encoded response (execution-apis PR #773) instead of the "
40+
"default JSON-RPC engine_newPayloadWithWitnessVX with "
41+
"RLP-encoded witness (geth-style)."
42+
),
43+
)
44+
45+
46+
def pytest_configure(config: pytest.Config) -> None:
47+
"""Set the supported fixture formats for the engine-witness simulator."""
48+
config.supported_fixture_formats = [BlockchainEngineFixture] # type: ignore[attr-defined]
49+
50+
51+
@pytest.fixture(scope="session")
52+
def use_ssz_transport(request: pytest.FixtureRequest) -> bool:
53+
"""Return True when `--ssz` was passed on the CLI."""
54+
return bool(request.config.getoption("--ssz"))
55+
56+
57+
@pytest.fixture(scope="module")
58+
def test_suite_name() -> str:
59+
"""The name of the hive test suite used in this simulator."""
60+
return "eels/consume-engine-witness"
61+
62+
63+
@pytest.fixture(scope="module")
64+
def test_suite_description() -> str:
65+
"""The description of the hive test suite used in this simulator."""
66+
return (
67+
"Execute blockchain-engine fixtures via the REST "
68+
"POST /new-payload-with-witness endpoint (execution-apis PR #773), "
69+
"verifying the client-generated execution witness against the "
70+
"fixture witness."
71+
)
72+
73+
74+
@pytest.fixture(scope="function")
75+
def client_files(
76+
buffered_genesis: io.BufferedReader,
77+
) -> Mapping[str, io.BufferedReader]:
78+
"""Define the files that hive will start the client with."""
79+
files = {}
80+
files["/genesis.json"] = buffered_genesis
81+
return files
82+
83+
84+
@pytest.fixture(scope="function")
85+
def genesis_header(fixture: BlockchainEngineFixture) -> "FixtureHeader":
86+
"""Provide the genesis header from the fixture."""
87+
return fixture.genesis
88+
89+
90+
@pytest.fixture(scope="function")
91+
def engine_witness_rpc(
92+
client: Client, client_exception_mapper: ExceptionMapper | None
93+
) -> EngineWitnessRPC:
94+
"""Provide a REST client for POST /new-payload-with-witness."""
95+
if client_exception_mapper:
96+
return EngineWitnessRPC(
97+
f"http://{client.ip}:8551",
98+
response_validation_context={
99+
"exception_mapper": client_exception_mapper,
100+
},
101+
)
102+
return EngineWitnessRPC(f"http://{client.ip}:8551")

packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py

Whitespace-only changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for set-based witness comparison helper."""
2+
3+
import pytest
4+
5+
from execution_testing.base_types import Bytes
6+
from execution_testing.cli.pytest_commands.plugins.consume.simulators.helpers.witness_diff import ( # noqa: E501
7+
WitnessMismatchError,
8+
assert_witness_matches,
9+
)
10+
from execution_testing.test_types.execution_witness import ExecutionWitness
11+
12+
13+
def _w(
14+
state: list[bytes] | None = None,
15+
codes: list[bytes] | None = None,
16+
headers: list[bytes] | None = None,
17+
) -> ExecutionWitness:
18+
return ExecutionWitness(
19+
state=[Bytes(b) for b in state or []],
20+
codes=[Bytes(b) for b in codes or []],
21+
headers=[Bytes(b) for b in headers or []],
22+
)
23+
24+
25+
def test_matching_witnesses_pass() -> None:
26+
"""Byte-equal witnesses match."""
27+
w = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60"], headers=[b"\xf9"])
28+
assert_witness_matches(expected=w, actual=w)
29+
30+
31+
def test_reordered_witness_matches() -> None:
32+
"""Set-equality ignores ordering — PR #773 does not mandate it."""
33+
expected = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60", b"\x70"])
34+
actual = _w(state=[b"\xbb", b"\xaa"], codes=[b"\x70", b"\x60"])
35+
assert_witness_matches(expected=expected, actual=actual)
36+
37+
38+
def test_duplicates_reduced_to_set() -> None:
39+
"""Duplicate items on either side collapse to a single set element."""
40+
expected = _w(state=[b"\xaa"])
41+
actual = _w(state=[b"\xaa", b"\xaa"])
42+
assert_witness_matches(expected=expected, actual=actual)
43+
44+
45+
def test_missing_state_node_fails() -> None:
46+
"""Client missing a state node gives a 'missing' diff line."""
47+
expected = _w(state=[b"\xaa", b"\xbb"])
48+
actual = _w(state=[b"\xaa"])
49+
with pytest.raises(WitnessMismatchError, match="state: 1 missing"):
50+
assert_witness_matches(expected=expected, actual=actual)
51+
52+
53+
def test_extra_code_fails() -> None:
54+
"""Client over-collecting a code gives an 'extra' diff line."""
55+
expected = _w(codes=[b"\x60"])
56+
actual = _w(codes=[b"\x60", b"\x70"])
57+
with pytest.raises(WitnessMismatchError, match=r"codes: 1 extra"):
58+
assert_witness_matches(expected=expected, actual=actual)
59+
60+
61+
def test_multi_field_mismatch_reports_all() -> None:
62+
"""All mismatching fields are reported in one exception."""
63+
expected = _w(state=[b"\xaa"], codes=[b"\x60"], headers=[b"\xf9"])
64+
actual = _w(state=[b"\xbb"], codes=[b"\x61"], headers=[])
65+
with pytest.raises(WitnessMismatchError) as excinfo:
66+
assert_witness_matches(expected=expected, actual=actual)
67+
msg = str(excinfo.value)
68+
assert "state:" in msg
69+
assert "codes:" in msg
70+
assert "headers:" in msg
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Set-based witness comparison helper for the engine-witness simulator."""
2+
3+
from typing import Iterable, List
4+
5+
from execution_testing.base_types import Bytes
6+
from execution_testing.test_types.execution_witness import ExecutionWitness
7+
8+
9+
def _diff_sets(
10+
field: str, expected: Iterable[Bytes], actual: Iterable[Bytes]
11+
) -> List[str]:
12+
"""Return human-readable diff messages for a single witness field."""
13+
exp = {bytes(x) for x in expected}
14+
act = {bytes(x) for x in actual}
15+
missing = exp - act
16+
extra = act - exp
17+
messages: List[str] = []
18+
if missing:
19+
preview = ", ".join(sorted("0x" + m.hex()[:16] for m in missing)[:5])
20+
messages.append(
21+
f"{field}: {len(missing)} missing (not emitted by client): {preview}"
22+
)
23+
if extra:
24+
preview = ", ".join(sorted("0x" + e.hex()[:16] for e in extra)[:5])
25+
messages.append(
26+
f"{field}: {len(extra)} extra (over-collected by client): {preview}"
27+
)
28+
return messages
29+
30+
31+
class WitnessMismatchError(AssertionError):
32+
"""Raised when a client-emitted witness does not match the fixture's."""
33+
34+
35+
def assert_witness_matches(
36+
expected: ExecutionWitness, actual: ExecutionWitness
37+
) -> None:
38+
"""
39+
Assert the client-emitted `actual` witness matches the fixture `expected`
40+
witness under set-equality on each of `state`, `codes`, `headers`.
41+
42+
Ordering is not mandated by execution-apis PR #773, so any permutation
43+
the client produces is acceptable. Duplicate items on either side are
44+
reduced to a single set element.
45+
"""
46+
messages: List[str] = []
47+
messages += _diff_sets("state", expected.state, actual.state)
48+
messages += _diff_sets("codes", expected.codes, actual.codes)
49+
messages += _diff_sets("headers", expected.headers, actual.headers)
50+
51+
if messages:
52+
raise WitnessMismatchError(
53+
"client witness does not match fixture witness:\n "
54+
+ "\n ".join(messages)
55+
)

0 commit comments

Comments
 (0)