diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/consume.py b/packages/testing/src/execution_testing/cli/pytest_commands/consume.py index 3059f463c05..a0c123a449f 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/consume.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/consume.py @@ -51,6 +51,13 @@ def get_command_logic_test_paths(command_name: str) -> List[Path]: / "simulator_logic" / f"test_via_{test_command}.py" ] + elif command_name == "engine_witness": + command_logic_test_paths = [ + base_path + / "simulators" + / "simulator_logic" + / "test_via_engine_witness.py" + ] elif command_name == "sync": command_logic_test_paths = [ base_path / "simulators" / "simulator_logic" / "test_via_sync.py" @@ -79,9 +86,10 @@ def decorator(func: Callable[..., Any]) -> click.Command: command_name = func.__name__ command_help = func.__doc__ command_logic_test_paths = get_command_logic_test_paths(command_name) + cli_name = command_name.replace("_", "-") @consume.command( - name=command_name, + name=cli_name, help=command_help, context_settings={"ignore_unknown_options": True}, ) @@ -120,6 +128,18 @@ def engine() -> None: pass +@consume_command(is_hive=True) +def engine_witness() -> None: + """ + Verify client-emitted execution witnesses against the fixture. + + Default transport: engine_newPayloadWithWitnessVX JSON-RPC (geth). + Pass --ssz to use the REST POST /new-payload-with-witness endpoint + (execution-apis PR #773). + """ + pass + + @consume_command(is_hive=True) def enginex() -> None: """Consume via Engine API with pre-alloc optimization.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py new file mode 100644 index 00000000000..8cb4f79d784 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py @@ -0,0 +1,102 @@ +""" +Pytest fixtures for the `consume engine-witness` simulator. + +Drives the Hive back-end and EL clients through the REST +`POST /new-payload-with-witness` endpoint (execution-apis PR #773), +asserting the client-generated execution witness matches the fixture. +""" + +import io +from typing import Mapping + +import pytest +from hive.client import Client + +from execution_testing.exceptions import ExceptionMapper +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.rpc import EngineWitnessRPC + +pytest_plugins = ( + "execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.base", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.single_test_client", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.test_case_description", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.timing_data", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.exceptions", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine_api", +) + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Register the `--ssz` transport flag for the engine-witness simulator.""" + parser.addoption( + "--ssz", + action="store_true", + default=False, + help=( + "Use the REST POST /new-payload-with-witness endpoint with " + "SSZ-encoded response (execution-apis PR #773) instead of the " + "default JSON-RPC engine_newPayloadWithWitnessVX with " + "RLP-encoded witness (geth-style)." + ), + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Set the supported fixture formats for the engine-witness simulator.""" + config.supported_fixture_formats = [BlockchainEngineFixture] # type: ignore[attr-defined] + + +@pytest.fixture(scope="session") +def use_ssz_transport(request: pytest.FixtureRequest) -> bool: + """Return True when `--ssz` was passed on the CLI.""" + return bool(request.config.getoption("--ssz")) + + +@pytest.fixture(scope="module") +def test_suite_name() -> str: + """The name of the hive test suite used in this simulator.""" + return "eels/consume-engine-witness" + + +@pytest.fixture(scope="module") +def test_suite_description() -> str: + """The description of the hive test suite used in this simulator.""" + return ( + "Execute blockchain-engine fixtures via the REST " + "POST /new-payload-with-witness endpoint (execution-apis PR #773), " + "verifying the client-generated execution witness against the " + "fixture witness." + ) + + +@pytest.fixture(scope="function") +def client_files( + buffered_genesis: io.BufferedReader, +) -> Mapping[str, io.BufferedReader]: + """Define the files that hive will start the client with.""" + files = {} + files["/genesis.json"] = buffered_genesis + return files + + +@pytest.fixture(scope="function") +def genesis_header(fixture: BlockchainEngineFixture) -> "FixtureHeader": + """Provide the genesis header from the fixture.""" + return fixture.genesis + + +@pytest.fixture(scope="function") +def engine_witness_rpc( + client: Client, client_exception_mapper: ExceptionMapper | None +) -> EngineWitnessRPC: + """Provide a REST client for POST /new-payload-with-witness.""" + if client_exception_mapper: + return EngineWitnessRPC( + f"http://{client.ip}:8551", + response_validation_context={ + "exception_mapper": client_exception_mapper, + }, + ) + return EngineWitnessRPC(f"http://{client.ip}:8551") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py new file mode 100644 index 00000000000..75c79ff0851 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py @@ -0,0 +1,70 @@ +"""Tests for set-based witness comparison helper.""" + +import pytest + +from execution_testing.base_types import Bytes +from execution_testing.cli.pytest_commands.plugins.consume.simulators.helpers.witness_diff import ( # noqa: E501 + WitnessMismatchError, + assert_witness_matches, +) +from execution_testing.test_types.execution_witness import ExecutionWitness + + +def _w( + state: list[bytes] | None = None, + codes: list[bytes] | None = None, + headers: list[bytes] | None = None, +) -> ExecutionWitness: + return ExecutionWitness( + state=[Bytes(b) for b in state or []], + codes=[Bytes(b) for b in codes or []], + headers=[Bytes(b) for b in headers or []], + ) + + +def test_matching_witnesses_pass() -> None: + """Byte-equal witnesses match.""" + w = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60"], headers=[b"\xf9"]) + assert_witness_matches(expected=w, actual=w) + + +def test_reordered_witness_matches() -> None: + """Set-equality ignores ordering — PR #773 does not mandate it.""" + expected = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60", b"\x70"]) + actual = _w(state=[b"\xbb", b"\xaa"], codes=[b"\x70", b"\x60"]) + assert_witness_matches(expected=expected, actual=actual) + + +def test_duplicates_reduced_to_set() -> None: + """Duplicate items on either side collapse to a single set element.""" + expected = _w(state=[b"\xaa"]) + actual = _w(state=[b"\xaa", b"\xaa"]) + assert_witness_matches(expected=expected, actual=actual) + + +def test_missing_state_node_fails() -> None: + """Client missing a state node gives a 'missing' diff line.""" + expected = _w(state=[b"\xaa", b"\xbb"]) + actual = _w(state=[b"\xaa"]) + with pytest.raises(WitnessMismatchError, match="state: 1 missing"): + assert_witness_matches(expected=expected, actual=actual) + + +def test_extra_code_fails() -> None: + """Client over-collecting a code gives an 'extra' diff line.""" + expected = _w(codes=[b"\x60"]) + actual = _w(codes=[b"\x60", b"\x70"]) + with pytest.raises(WitnessMismatchError, match=r"codes: 1 extra"): + assert_witness_matches(expected=expected, actual=actual) + + +def test_multi_field_mismatch_reports_all() -> None: + """All mismatching fields are reported in one exception.""" + expected = _w(state=[b"\xaa"], codes=[b"\x60"], headers=[b"\xf9"]) + actual = _w(state=[b"\xbb"], codes=[b"\x61"], headers=[]) + with pytest.raises(WitnessMismatchError) as excinfo: + assert_witness_matches(expected=expected, actual=actual) + msg = str(excinfo.value) + assert "state:" in msg + assert "codes:" in msg + assert "headers:" in msg diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py new file mode 100644 index 00000000000..3c4da69268c --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py @@ -0,0 +1,55 @@ +"""Set-based witness comparison helper for the engine-witness simulator.""" + +from typing import Iterable, List + +from execution_testing.base_types import Bytes +from execution_testing.test_types.execution_witness import ExecutionWitness + + +def _diff_sets( + field: str, expected: Iterable[Bytes], actual: Iterable[Bytes] +) -> List[str]: + """Return human-readable diff messages for a single witness field.""" + exp = {bytes(x) for x in expected} + act = {bytes(x) for x in actual} + missing = exp - act + extra = act - exp + messages: List[str] = [] + if missing: + preview = ", ".join(sorted("0x" + m.hex()[:16] for m in missing)[:5]) + messages.append( + f"{field}: {len(missing)} missing (not emitted by client): {preview}" + ) + if extra: + preview = ", ".join(sorted("0x" + e.hex()[:16] for e in extra)[:5]) + messages.append( + f"{field}: {len(extra)} extra (over-collected by client): {preview}" + ) + return messages + + +class WitnessMismatchError(AssertionError): + """Raised when a client-emitted witness does not match the fixture's.""" + + +def assert_witness_matches( + expected: ExecutionWitness, actual: ExecutionWitness +) -> None: + """ + Assert the client-emitted `actual` witness matches the fixture `expected` + witness under set-equality on each of `state`, `codes`, `headers`. + + Ordering is not mandated by execution-apis PR #773, so any permutation + the client produces is acceptable. Duplicate items on either side are + reduced to a single set element. + """ + messages: List[str] = [] + messages += _diff_sets("state", expected.state, actual.state) + messages += _diff_sets("codes", expected.codes, actual.codes) + messages += _diff_sets("headers", expected.headers, actual.headers) + + if messages: + raise WitnessMismatchError( + "client witness does not match fixture witness:\n " + + "\n ".join(messages) + ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py new file mode 100644 index 00000000000..694b1796385 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py @@ -0,0 +1,219 @@ +""" +A hive based simulator that executes blocks against clients using either: + +- `engine_newPayloadWithWitnessVX` JSON-RPC (geth-style, RLP witness) — default +- `POST /new-payload-with-witness` REST+SSZ (execution-apis PR #773) — `--ssz` + +Both paths converge on a common verification: status matches the fixture +expectation, and when VALID the client-emitted witness is set-equal to +`fixture.execution_witness` on state/codes/headers. +""" + +import pytest + +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.logging import get_logger +from execution_testing.rpc import ( + EngineRPC, + EngineWitnessEndpointNotImplementedError, + EngineWitnessRPC, + EthRPC, + ForkchoiceUpdateTimeoutError, +) +from execution_testing.rpc.rpc_types import ( + ForkchoiceState, + JSONRPCError, + NewPayloadWithWitnessResponse, + PayloadStatusEnum, +) + +from ..helpers.exceptions import ( + GenesisBlockMismatchExceptionError, + LoggedError, +) +from ..helpers.timing import TimingData +from ..helpers.witness_diff import ( + WitnessMismatchError, + assert_witness_matches, +) + +logger = get_logger(__name__) + + +def _call_endpoint( + *, + use_ssz: bool, + engine_rpc: EngineRPC, + engine_witness_rpc: EngineWitnessRPC, + payload_params: tuple, + version: int, +) -> NewPayloadWithWitnessResponse: + """Dispatch to the SSZ REST or RLP JSON-RPC witness endpoint.""" + if use_ssz: + return engine_witness_rpc.new_payload_with_witness(*payload_params) + return engine_rpc.new_payload_with_witness( + *payload_params, version=version + ) + + +def test_blockchain_via_engine_witness( + timing_data: TimingData, + eth_rpc: EthRPC, + engine_rpc: EngineRPC, + engine_witness_rpc: EngineWitnessRPC, + fixture: BlockchainEngineFixture, + genesis_header: FixtureHeader, + use_ssz_transport: bool, +) -> None: + """ + Execute blockchain-engine fixtures and assert the client-emitted witness + matches the fixture witness. + + Per payload: + 1. Call the witness-emitting endpoint (RLP JSON-RPC or REST+SSZ). + 2. Assert status matches fixture expectation. + 3. On VALID, compare the client-emitted witness to fixture.execution_witness + as set-equality per field (state, codes, headers). + 4. On VALID, issue an engine_forkchoiceUpdatedVX to advance the head. + + Skip the whole fixture if no payload carries an executionWitness, or if + the SSZ endpoint is missing (HTTP 404/405) when `--ssz` is requested. + """ + if not any(p.execution_witness is not None for p in fixture.payloads): + pytest.skip("fixture has no executionWitness on any payload") + + transport_label = "REST+SSZ" if use_ssz_transport else "JSON-RPC+RLP" + logger.info(f"Using {transport_label} witness transport") + + with timing_data.time("Initial forkchoice update"): + logger.info("Sending initial forkchoice update to genesis block...") + try: + response = engine_rpc.forkchoice_updated_with_retry( + forkchoice_state=ForkchoiceState( + head_block_hash=fixture.genesis.block_hash, + ), + forkchoice_version=fixture.payloads[ + 0 + ].forkchoice_updated_version, + max_attempts=30, + wait_fixed=1.0, + ) + if response.payload_status.status != PayloadStatusEnum.VALID: + raise LoggedError( + f"Unexpected status on forkchoice updated to genesis: " + f"{response.payload_status.status}" + ) + except ForkchoiceUpdateTimeoutError as e: + raise LoggedError( + f"Timed out waiting for forkchoice update to genesis: {e}" + ) from None + + with timing_data.time("Get genesis block"): + genesis_block = eth_rpc.get_block_by_number(0) + assert genesis_block is not None, "genesis_block is None" + if genesis_block["hash"] != str(genesis_header.block_hash): + raise GenesisBlockMismatchExceptionError( + expected_header=genesis_header, + got_genesis_block=genesis_block, + ) + + with timing_data.time("Payloads execution") as total_payload_timing: + logger.info( + f"Starting execution of {len(fixture.payloads)} payloads..." + ) + for i, payload in enumerate(fixture.payloads): + logger.info( + f"Processing payload {i + 1}/{len(fixture.payloads)}..." + ) + with total_payload_timing.time( + f"Payload {i + 1}" + ) as payload_timing: + timing_label = ( + "POST /new-payload-with-witness" + if use_ssz_transport + else f"engine_newPayloadWithWitnessV" + f"{payload.new_payload_version}" + ) + with payload_timing.time(timing_label): + try: + response = _call_endpoint( + use_ssz=use_ssz_transport, + engine_rpc=engine_rpc, + engine_witness_rpc=engine_witness_rpc, + payload_params=payload.params, + version=payload.new_payload_version, + ) + except EngineWitnessEndpointNotImplementedError as e: + pytest.skip(str(e)) + except JSONRPCError as e: + # geth returns -32601 Method not found when + # engine_newPayloadWithWitness is not registered. + if e.code == -32601: + pytest.skip( + "client does not support " + f"engine_newPayloadWithWitnessV" + f"{payload.new_payload_version}: {e.message}" + ) + raise + + expected_validity = ( + PayloadStatusEnum.VALID + if payload.valid() + else PayloadStatusEnum.INVALID + ) + if response.status != expected_validity: + raise LoggedError( + f"unexpected status: want {expected_validity}, " + f"got {response.status}" + ) + + if response.status == PayloadStatusEnum.VALID: + if payload.execution_witness is None: + logger.warning( + f"Payload {i + 1}: fixture has no " + "executionWitness; skipping witness diff" + ) + elif response.witness is None: + raise LoggedError( + f"Payload {i + 1}: VALID status but client " + "returned no witness" + ) + else: + with payload_timing.time("Witness diff"): + try: + assert_witness_matches( + expected=payload.execution_witness, + actual=response.witness, + ) + except WitnessMismatchError as e: + raise LoggedError(str(e)) from e + elif use_ssz_transport and response.witness is not None: + # PR #773 requires an empty witness when status != VALID. + # Geth's JSON-RPC does not mandate this, so only enforce + # in SSZ mode. + raise LoggedError( + f"Payload {i + 1}: {response.status} status but " + "client returned a non-empty witness (PR #773 " + "requires empty witness when not VALID)" + ) + + if payload.valid(): + with payload_timing.time( + f"engine_forkchoiceUpdatedV" + f"{payload.forkchoice_updated_version}" + ): + fcu_response = engine_rpc.forkchoice_updated( + forkchoice_state=ForkchoiceState( + head_block_hash=payload.params[0].block_hash, + ), + payload_attributes=None, + version=payload.forkchoice_updated_version, + ) + status = fcu_response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise LoggedError( + f"unexpected forkchoice status: want " + f"{PayloadStatusEnum.VALID}, got {status}" + ) + logger.info("All payloads processed successfully.") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py index 7862140380e..41600b44b9c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py @@ -129,6 +129,13 @@ def process_args(self, args: List[str]) -> List[str]: "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine.conftest", ] ) + elif self.command_name == "engine_witness": + modified_args.extend( + [ + "-p", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine_witness.conftest", + ] + ) elif self.command_name == "enginex": modified_args.extend( [ diff --git a/packages/testing/src/execution_testing/rpc/__init__.py b/packages/testing/src/execution_testing/rpc/__init__.py index 87c62607f1a..42ac537c5ce 100644 --- a/packages/testing/src/execution_testing/rpc/__init__.py +++ b/packages/testing/src/execution_testing/rpc/__init__.py @@ -8,6 +8,8 @@ BlockNumberType, DebugRPC, EngineRPC, + EngineWitnessEndpointNotImplementedError, + EngineWitnessRPC, EthRPC, ForkchoiceUpdateTimeoutError, NetRPC, @@ -23,6 +25,7 @@ ForkConfigBlobSchedule, JSONRPCRequest, JSONRPCResponse, + NewPayloadWithWitnessResponse, RPCCall, TransactionProtocol, ) @@ -35,6 +38,8 @@ "BlockNumberType", "DebugRPC", "EngineRPC", + "EngineWitnessEndpointNotImplementedError", + "EngineWitnessRPC", "EthConfigResponse", "EthRPC", "ForkConfig", @@ -43,6 +48,7 @@ "JSONRPCRequest", "JSONRPCResponse", "NetRPC", + "NewPayloadWithWitnessResponse", "RPCCall", "PeerConnectionTimeoutError", "SendTransactionExceptionError", diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index e7f75aabe7f..fcad0a72446 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -45,6 +45,7 @@ GetPayloadResponse, JSONRPCRequest, JSONRPCResponse, + NewPayloadWithWitnessResponse, PayloadAttributes, PayloadStatus, PayloadStatusEnum, @@ -141,6 +142,21 @@ def __init__( super().__init__(msg) +class EngineWitnessEndpointNotImplementedError(Exception): + """ + Raised when the client does not implement the REST + ``/new-payload-with-witness`` endpoint (HTTP 404 or 405). + """ + + def __init__(self, url: str, http_status: int): + """Initialize with the URL and the HTTP status that signalled absence.""" + self.url = url + self.http_status = http_status + super().__init__( + f"REST endpoint not implemented at {url} (HTTP {http_status})" + ) + + class PeerConnectionTimeoutError(Exception): """Raised when peer connection is not established within retry limits.""" @@ -1189,6 +1205,25 @@ def new_payload(self, *params: Any, version: int) -> PayloadStatus: context=self.response_validation_context, ) + def new_payload_with_witness( + self, + *params: Any, + version: int, + ) -> NewPayloadWithWitnessResponse: + """ + `engine_newPayloadWithWitnessVX`: execute the payload and return the + payload status plus an RLP-encoded execution witness (geth extension, + see go-ethereum PR #30069). + """ + method = f"newPayloadWithWitnessV{version}" + params_list = [to_json(param) for param in params] + + result = self.post_request( + request=RPCCall(method=method, params=params_list) + ).result_or_raise() + + return NewPayloadWithWitnessResponse.from_geth_json(result) + def forkchoice_updated( self, forkchoice_state: ForkchoiceState, @@ -1335,6 +1370,60 @@ def _do_forkchoice_update() -> ForkchoiceUpdateResponse: return _do_forkchoice_update() +class EngineWitnessRPC(BaseJwtRPC): + """ + REST client for `POST /new-payload-with-witness` (execution-apis PR #773). + + The endpoint uses a JSON request body and an SSZ response body; JWT auth + is inherited from `BaseJwtRPC`. + """ + + path: ClassVar[str] = "/new-payload-with-witness" + default_timeout: ClassVar[int] = 8 + + def new_payload_with_witness( + self, + *params: Any, + timeout: int | None = None, + ) -> NewPayloadWithWitnessResponse: + """ + `POST /new-payload-with-witness`: submit a payload and receive the + validation result together with the client-generated execution + witness. + + Raise `EngineWitnessEndpointNotImplementedError` on HTTP 404 or 405 + so the caller can skip clients without REST support. + """ + if timeout is None: + timeout = self.default_timeout + + url = self.url.rstrip("/") + self.path + body = [to_json(param) for param in params] + headers = { + "Content-Type": "application/json", + } | self.namespace_extra_headers() + + logger.debug(f"POST {url}, timeout={timeout}") + response = self.session.post( + url, json=body, headers=headers, timeout=timeout + ) + + if response.status_code in (404, 405): + raise EngineWitnessEndpointNotImplementedError( + url, response.status_code + ) + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "") + if "application/octet-stream" not in content_type: + raise ValueError( + f"Unexpected Content-Type from {url}: {content_type!r} " + f"(expected application/octet-stream)" + ) + + return NewPayloadWithWitnessResponse.from_ssz_bytes(response.content) + + class NetRPC(BaseRPC): """Represents a net RPC class for network-related RPC calls.""" diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index b0668583f0d..3df4049758f 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -2,11 +2,18 @@ import json from binascii import crc32 +from dataclasses import dataclass, field from enum import Enum from hashlib import sha256 from typing import Annotated, Any, Dict, List, Protocol, Self +import ethereum_rlp as eth_rlp from pydantic import AliasChoices, BaseModel, Field, model_validator +from remerkleable.basic import uint8 +from remerkleable.byte_arrays import ByteList, ByteVector +from remerkleable.complex import Container +from remerkleable.complex import List as SszList +from remerkleable.union import Union as SszUnion from execution_testing.base_types import ( Address, @@ -29,6 +36,7 @@ FixtureExecutionPayload, ) from execution_testing.test_types import EOA, Transaction, Withdrawal +from execution_testing.test_types.execution_witness import ExecutionWitness class JSONRPCError(Exception): @@ -324,6 +332,150 @@ class EthConfigResponse(CamelModel): last: ForkConfig | None = None +# SSZ schema for POST /new-payload-with-witness response per execution-apis +# PR #773 (https://github.com/ethereum/execution-apis/pull/773). + +VALIDATION_ERROR_MAX = 8192 +MAX_WITNESS_BYTES = 2**30 # 1 GiB +MAX_WITNESS_ITEMS = 2**20 +MAX_WITNESS_ITEM_BYTES = 2**20 + + +class _SszExecutionWitness(Container): + state: SszList[ + ByteList[MAX_WITNESS_ITEM_BYTES], MAX_WITNESS_ITEMS + ] + codes: SszList[ + ByteList[MAX_WITNESS_ITEM_BYTES], MAX_WITNESS_ITEMS + ] + headers: SszList[ + ByteList[MAX_WITNESS_ITEM_BYTES], MAX_WITNESS_ITEMS + ] + + +class _SszNewPayloadWithWitnessResponse(Container): + status: uint8 + latest_valid_hash: SszUnion[None, ByteVector[32]] # type: ignore[valid-type] + validation_error: SszUnion[None, ByteList[VALIDATION_ERROR_MAX]] # type: ignore[valid-type] + witness: ByteList[MAX_WITNESS_BYTES] + + +_SSZ_STATUS_TO_ENUM: Dict[int, PayloadStatusEnum] = { + 0: PayloadStatusEnum.VALID, + 1: PayloadStatusEnum.INVALID, + 2: PayloadStatusEnum.SYNCING, + 3: PayloadStatusEnum.ACCEPTED, + 4: PayloadStatusEnum.INVALID_BLOCK_HASH, +} + + +@dataclass +class NewPayloadWithWitnessResponse: + """ + Decoded response of POST /new-payload-with-witness (PR #773). + + The witness field is ``None`` whenever status is not ``VALID`` (the spec + mandates an empty SSZ witness in that case). + """ + + status: PayloadStatusEnum + latest_valid_hash: Hash | None + validation_error: str | None + witness: ExecutionWitness | None = field(default=None) + + @classmethod + def from_ssz_bytes(cls, data: bytes) -> Self: + """Decode an SSZ-encoded NewPayloadWithWitnessResponseV1 body.""" + resp = _SszNewPayloadWithWitnessResponse.decode_bytes(data) + + status_int = int(resp.status) + try: + status = _SSZ_STATUS_TO_ENUM[status_int] + except KeyError as e: + raise ValueError( + f"Unknown SSZ status byte: {status_int}" + ) from e + + latest_valid_hash: Hash | None = None + if resp.latest_valid_hash.selector() == 1: + latest_valid_hash = Hash(bytes(resp.latest_valid_hash.value())) + + validation_error: str | None = None + if resp.validation_error.selector() == 1: + raw = bytes(resp.validation_error.value()) + validation_error = raw.decode("utf-8", errors="replace") + + witness: ExecutionWitness | None = None + witness_bytes = bytes(resp.witness) + if witness_bytes: + inner = _SszExecutionWitness.decode_bytes(witness_bytes) + witness = ExecutionWitness( + state=[Bytes(bytes(x)) for x in inner.state], + codes=[Bytes(bytes(x)) for x in inner.codes], + headers=[Bytes(bytes(x)) for x in inner.headers], + ) + + return cls( + status=status, + latest_valid_hash=latest_valid_hash, + validation_error=validation_error, + witness=witness, + ) + + @classmethod + def from_geth_json(cls, data: Dict[str, Any]) -> Self: + """ + Decode the response of geth's JSON-RPC `engine_newPayloadWithWitnessVX`. + + The `witness` field is a hex-encoded RLP list + `[Headers, Codes, State, Keys]` where Headers are RLP-encoded header + structures. Re-encode each header to RLP bytes so the resulting + ExecutionWitness has the same `headers: List[Bytes]` shape as the + fixture. + """ + status = PayloadStatusEnum(data["status"]) + + raw_hash = data.get("latestValidHash") + latest_valid_hash: Hash | None = ( + Hash(raw_hash) if raw_hash is not None else None + ) + + raw_err = data.get("validationError") + validation_error: str | None = ( + raw_err if raw_err is not None else None + ) + + witness: ExecutionWitness | None = None + raw_witness = data.get("witness") + if raw_witness is not None: + witness_bytes = ( + bytes.fromhex(raw_witness[2:]) + if isinstance(raw_witness, str) + else bytes(raw_witness) + ) + if witness_bytes: + parsed = eth_rlp.decode(witness_bytes) + if not isinstance(parsed, list) or len(parsed) < 3: + raise ValueError( + "Unexpected geth ExtWitness RLP structure: " + f"{type(parsed).__name__} of length " + f"{len(parsed) if isinstance(parsed, list) else 0}" + ) + headers_raw, codes_raw, state_raw = parsed[0:3] + witness = ExecutionWitness( + state=[Bytes(bytes(x)) for x in state_raw], + codes=[Bytes(bytes(x)) for x in codes_raw], + headers=[Bytes(eth_rlp.encode(h)) for h in headers_raw], + ) + + return cls( + status=status, + latest_valid_hash=latest_valid_hash, + validation_error=validation_error, + witness=witness, + ) + + class TransactionProtocol(Protocol): """Protocol for a transaction that can be sent to the client.""" diff --git a/packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py b/packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py new file mode 100644 index 00000000000..0ff27389499 --- /dev/null +++ b/packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py @@ -0,0 +1,213 @@ +"""Tests for `NewPayloadWithWitnessResponse` SSZ and RLP decoding.""" + +import ethereum_rlp as eth_rlp +import pytest +from remerkleable.basic import uint8 +from remerkleable.byte_arrays import ByteList, ByteVector + +from execution_testing.rpc.rpc_types import ( + MAX_WITNESS_BYTES, + MAX_WITNESS_ITEM_BYTES, + NewPayloadWithWitnessResponse, + PayloadStatusEnum, + VALIDATION_ERROR_MAX, + _SszExecutionWitness, + _SszNewPayloadWithWitnessResponse, +) + + +def _build_inner_witness( + state: list[bytes], codes: list[bytes], headers: list[bytes] +) -> bytes: + inner = _SszExecutionWitness( + state=[ByteList[MAX_WITNESS_ITEM_BYTES](b) for b in state], + codes=[ByteList[MAX_WITNESS_ITEM_BYTES](b) for b in codes], + headers=[ByteList[MAX_WITNESS_ITEM_BYTES](b) for b in headers], + ) + return inner.encode_bytes() + + +def _build_response( + status: int, + latest_valid_hash: bytes | None, + validation_error: str | None, + witness_bytes: bytes, +) -> bytes: + fields = _SszNewPayloadWithWitnessResponse.fields() + lvh_type = fields["latest_valid_hash"] + ve_type = fields["validation_error"] + + if latest_valid_hash is None: + lvh = lvh_type(selector=0, value=None) + else: + lvh = lvh_type(selector=1, value=ByteVector[32](latest_valid_hash)) + + if validation_error is None: + ve = ve_type(selector=0, value=None) + else: + ve = ve_type( + selector=1, + value=ByteList[VALIDATION_ERROR_MAX]( + validation_error.encode("utf-8") + ), + ) + + resp = _SszNewPayloadWithWitnessResponse( + status=uint8(status), + latest_valid_hash=lvh, + validation_error=ve, + witness=ByteList[MAX_WITNESS_BYTES](witness_bytes), + ) + return resp.encode_bytes() + + +def test_decode_valid_with_witness() -> None: + """A VALID response carries latestValidHash and a non-empty witness.""" + witness_bytes = _build_inner_witness( + state=[b"\xaa\xaa", b"\xbb\xbb\xbb"], + codes=[b"\x60\x01"], + headers=[b"\xf9\x02"], + ) + raw = _build_response( + status=0, + latest_valid_hash=b"\x11" * 32, + validation_error=None, + witness_bytes=witness_bytes, + ) + + decoded = NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + assert decoded.status == PayloadStatusEnum.VALID + assert decoded.latest_valid_hash is not None + assert bytes(decoded.latest_valid_hash) == b"\x11" * 32 + assert decoded.validation_error is None + assert decoded.witness is not None + assert [bytes(x) for x in decoded.witness.state] == [ + b"\xaa\xaa", + b"\xbb\xbb\xbb", + ] + assert [bytes(x) for x in decoded.witness.codes] == [b"\x60\x01"] + assert [bytes(x) for x in decoded.witness.headers] == [b"\xf9\x02"] + + +def test_decode_invalid_with_validation_error() -> None: + """An INVALID response carries a validation_error string and no witness.""" + raw = _build_response( + status=1, + latest_valid_hash=None, + validation_error="invalid state root", + witness_bytes=b"", + ) + + decoded = NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + assert decoded.status == PayloadStatusEnum.INVALID + assert decoded.latest_valid_hash is None + assert decoded.validation_error == "invalid state root" + assert decoded.witness is None + + +def test_decode_syncing_empty_witness() -> None: + """A SYNCING response has no witness per PR #773.""" + raw = _build_response( + status=2, + latest_valid_hash=None, + validation_error=None, + witness_bytes=b"", + ) + + decoded = NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + assert decoded.status == PayloadStatusEnum.SYNCING + assert decoded.latest_valid_hash is None + assert decoded.validation_error is None + assert decoded.witness is None + + +def test_decode_unknown_status_byte_raises() -> None: + """An unknown status uint8 raises a descriptive error.""" + raw = _build_response( + status=99, + latest_valid_hash=None, + validation_error=None, + witness_bytes=b"", + ) + + with pytest.raises(ValueError, match="Unknown SSZ status byte: 99"): + NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + +# --- Geth JSON-RPC (RLP witness) decode --- + + +def _geth_witness_rlp( + headers: list[list], codes: list[bytes], state: list[bytes] +) -> bytes: + """Build a geth ExtWitness-shaped RLP payload: [Headers, Codes, State, Keys].""" + return eth_rlp.encode([headers, codes, state, []]) + + +def test_decode_geth_json_valid() -> None: + """Round-trip a geth VALID response with RLP witness.""" + # A minimal "header" RLP list with two short fields. + header_list = [b"\x01" * 4, b"\x02" * 4] + witness_hex = "0x" + _geth_witness_rlp( + headers=[header_list], + codes=[b"\x60\x01"], + state=[b"\xaa\xaa", b"\xbb"], + ).hex() + + response_json = { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": witness_hex, + } + + decoded = NewPayloadWithWitnessResponse.from_geth_json(response_json) + + assert decoded.status == PayloadStatusEnum.VALID + assert decoded.latest_valid_hash is not None + assert bytes(decoded.latest_valid_hash) == b"\x11" * 32 + assert decoded.validation_error is None + assert decoded.witness is not None + assert [bytes(c) for c in decoded.witness.codes] == [b"\x60\x01"] + assert sorted(bytes(s) for s in decoded.witness.state) == sorted( + [b"\xaa\xaa", b"\xbb"] + ) + # Headers must come back as re-encoded RLP bytes (matching the fixture + # format), so encoding the decoded header recovers the original list. + assert len(decoded.witness.headers) == 1 + assert eth_rlp.decode(bytes(decoded.witness.headers[0])) == header_list + + +def test_decode_geth_json_invalid_no_witness() -> None: + """An INVALID geth response has no witness payload.""" + response_json = { + "status": "INVALID", + "latestValidHash": None, + "validationError": "block root mismatch", + # geth omits the witness field on INVALID + } + + decoded = NewPayloadWithWitnessResponse.from_geth_json(response_json) + + assert decoded.status == PayloadStatusEnum.INVALID + assert decoded.latest_valid_hash is None + assert decoded.validation_error == "block root mismatch" + assert decoded.witness is None + + +def test_decode_geth_json_empty_witness_hex() -> None: + """Geth may emit witness='0x' on non-VALID statuses; parse as no witness.""" + response_json = { + "status": "SYNCING", + "latestValidHash": None, + "validationError": None, + "witness": "0x", + } + + decoded = NewPayloadWithWitnessResponse.from_geth_json(response_json) + + assert decoded.status == PayloadStatusEnum.SYNCING + assert decoded.witness is None