From cc82819f277c014ef900250f9954c964011389e8 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 10 Dec 2025 13:23:10 +0100 Subject: [PATCH 001/265] docs(testing-cli-consume): fix hive simulator names post-weld (#1842) Co-authored-by: spencer --- docs/filling_tests/test_ids.md | 4 +-- docs/running_tests/consume/exceptions.md | 2 +- docs/running_tests/execute/hive.md | 2 +- docs/running_tests/hive/client_config.md | 6 ++-- docs/running_tests/hive/common_options.md | 18 +++++----- docs/running_tests/hive/dev_mode.md | 43 +++++++++++------------ docs/running_tests/hive/index.md | 2 +- docs/running_tests/releases.md | 6 ++-- docs/running_tests/running.md | 22 ++++++------ 9 files changed, 52 insertions(+), 53 deletions(-) diff --git a/docs/filling_tests/test_ids.md b/docs/filling_tests/test_ids.md index 7005ab639be..069518bb067 100644 --- a/docs/filling_tests/test_ids.md +++ b/docs/filling_tests/test_ids.md @@ -55,8 +55,8 @@ The test framework can also generate blockchain tests containing blocks that spa Each Python test case is also typically parametrized by test type, respectively fixture format. For example, if the test is implemented as a `state_test`, the test framework will additionally generate the following blockchain test fixtures (consisting of a single block with a single transaction): -- a `blockchain_test` which can be tested via the Hive `eest/consume-rlp` simulator (or directly via a dedicated client interface). -- a `blockchain_engine_test` (for post-merge forks) which can be tested via the Hive `eest/consume-engine` simulator. +- a `blockchain_test` which can be tested via the Hive `eels/consume-rlp` simulator (or directly via a dedicated client interface). +- a `blockchain_engine_test` (for post-merge forks) which can be tested via the Hive `eels/consume-engine` simulator. ### Example: The Test IDs generated for `test_chainid` diff --git a/docs/running_tests/consume/exceptions.md b/docs/running_tests/consume/exceptions.md index 453284aedf4..a5a82c499ab 100644 --- a/docs/running_tests/consume/exceptions.md +++ b/docs/running_tests/consume/exceptions.md @@ -98,7 +98,7 @@ uv run consume engine --disable-strict-exception-matching=nimbus-el Enable verbose client output: ```bash -./hive --sim ethereum/eest/consume-engine \ +./hive --sim ethereum/eels/consume-engine \ --docker.output \ --sim.loglevel 5 ``` diff --git a/docs/running_tests/execute/hive.md b/docs/running_tests/execute/hive.md index 6af210f9090..c4d89e48c0d 100644 --- a/docs/running_tests/execute/hive.md +++ b/docs/running_tests/execute/hive.md @@ -2,7 +2,7 @@ Tests can be executed on a local hive-controlled single-client network by running the `execute hive` command. -## The `eest/execute-blobs` Simulator +## The `eels/execute-blobs` Simulator The `blob_transaction_test` execute test spec sends blob transactions to a running client. Blob transactions are fully supported in execute mode: diff --git a/docs/running_tests/hive/client_config.md b/docs/running_tests/hive/client_config.md index be04107f113..e2713780e94 100644 --- a/docs/running_tests/hive/client_config.md +++ b/docs/running_tests/hive/client_config.md @@ -102,17 +102,17 @@ cp -r /path/to/your/go-ethereum ./clients/go-ethereum/go-ethereum-local Force rebuild base images: ```bash -./hive --docker.pull --sim ethereum/eest/consume-engine +./hive --docker.pull --sim ethereum/eels/consume-engine ``` Force rebuild specific client: ```bash -./hive --docker.nocache "clients/go-ethereum" --sim ethereum/eest/consume-engine +./hive --docker.nocache "clients/go-ethereum" --sim ethereum/eels/consume-engine ``` Show the docker container build output: ```bash -./hive --docker.buildoutput --sim ethereum/eest/consume-engine +./hive --docker.buildoutput --sim ethereum/eels/consume-engine ``` diff --git a/docs/running_tests/hive/common_options.md b/docs/running_tests/hive/common_options.md index ff3d3132526..2272e7d058e 100644 --- a/docs/running_tests/hive/common_options.md +++ b/docs/running_tests/hive/common_options.md @@ -1,15 +1,15 @@ # Common Simulator Options -All EEST Hive simulators share common command-line options and patterns. +All execution-specs (EELS) Hive simulators share common command-line options and patterns. ## Basic Usage -While they may be omitted, it's recommended to specify the `fixtures` and `branch` simulator build arguments when running EEST simulators. +While they may be omitted, it's recommended to specify the `fixtures` and `branch` simulator build arguments when running execution-specs simulators. For example, this runs "stable" fixtures from the v4.3.0 [latest stable release](../releases.md#standard-releases) and builds the simulator at the v4.3.0 tag: ```bash -./hive --sim ethereum/eest/consume-engine \ +./hive --sim ethereum/eels/consume-engine \ --sim.buildarg fixtures=stable@v4.3.0 \ --sim.buildarg branch=v4.3.0 \ --client go-ethereum @@ -20,7 +20,7 @@ For example, this runs "stable" fixtures from the v4.3.0 [latest stable release] Run a subset of tests by filtering tests using `--sim.limit=` to perform a regular expression match against test IDs: ```bash -./hive --sim ethereum/eest/consume-engine --sim.limit ".*eip4844.*" +./hive --sim ethereum/eels/consume-engine --sim.limit ".*eip4844.*" ``` ### Collect Only/Dry-Run @@ -28,7 +28,7 @@ Run a subset of tests by filtering tests using `--sim.limit=` to perform The `collectonly:` prefix can be used to inspect which tests would match an expression (dry-run), `--docker.output` must be specified to see the simulator's collection result: ```bash -./hive --sim ethereum/eest/consume-engine \ +./hive --sim ethereum/eels/consume-engine \ --sim.buildarg fixtures=stable@v4.3.0 \ --sim.buildarg branch=v4.3.0 \ --docker.output \ @@ -40,7 +40,7 @@ The `collectonly:` prefix can be used to inspect which tests would match an expr The `id:` prefix can be used to select a single test via its ID (this will automatically escape any special characters in the test case ID): ```console -./hive --sim ethereum/eest/consume-engine \ +./hive --sim ethereum/eels/consume-engine \ --sim.buildarg fixtures=stable@v4.3.0 \ --sim.buildarg branch=v4.3.0 \ --docker.output \ @@ -52,7 +52,7 @@ The `id:` prefix can be used to select a single test via its ID (this will autom To run multiple tests in parallel, use `--sim.parallelism`: ```bash -./hive --sim ethereum/eest/consume-rlp --sim.parallelism 4 +./hive --sim ethereum/eels/consume-rlp --sim.parallelism 4 ``` ### Output Options @@ -60,7 +60,7 @@ To run multiple tests in parallel, use `--sim.parallelism`: See hive log output in the console: ```bash -./hive --sim ethereum/eest/consume-engine --sim.loglevel 5 +./hive --sim ethereum/eels/consume-engine --sim.loglevel 5 ``` ### Container Issues @@ -68,5 +68,5 @@ See hive log output in the console: Increase client timeout: ```bash -./hive --client.checktimelimit=180s --sim ethereum/eest/consume-engine +./hive --client.checktimelimit=180s --sim ethereum/eels/consume-engine ``` diff --git a/docs/running_tests/hive/dev_mode.md b/docs/running_tests/hive/dev_mode.md index 61b382ff440..965a7e18553 100644 --- a/docs/running_tests/hive/dev_mode.md +++ b/docs/running_tests/hive/dev_mode.md @@ -1,6 +1,6 @@ # Hive Development Mode -This section explains how to run EEST simulators using their EEST commands, e.g., `uv run consume engine`, against a Hive "development" server as apposed to using the standalone `./hive` command. +This section explains how to run EELS simulators using their Python-based commands, e.g., `uv run consume engine`, against a Hive "development" server as apposed to using the standalone `./hive` command. This avoids running the simulator in a dockerized environment and has several advantages: @@ -18,7 +18,7 @@ This avoids running the simulator in a dockerized environment and has several ad ### Prerequisites -- EEST is installed, see [Installation](../../getting_started/installation.md) +- The execution-specs repo is setup in development mode, see [Installation](../../getting_started/installation.md) - Hive is built, see [Hive](../hive/index.md#quick-start). ## Hive Dev Setup on Linux @@ -29,7 +29,7 @@ This avoids running the simulator in a dockerized environment and has several ad ./hive --dev --client go-ethereum --client-file clients.yaml --docker.output ``` -2. In a separate shell, configure environment for EEST: +2. In a separate shell, configure environment for execution-specs: === "bash/zsh" @@ -43,7 +43,7 @@ This avoids running the simulator in a dockerized environment and has several ad set -x HIVE_SIMULATOR http://127.0.0.1:3000 ``` -3. Run EEST consume commands +3. Run execution-specs `consume` commands ```bash uv run consume engine --input ./fixtures -k "test_chainid" @@ -56,37 +56,36 @@ Due to Docker running within a VM on macOS, the host machine and Docker containe 1. Linux VM: Run a Linux virtual machine on your macOS and execute the standard development workflow above from within the VM. 2. Remote Linux: SSH into a remote Linux environment (server or cloud instance) and run development mode there. -3. **Docker Development Image**: Create a containerized EEST environment that runs within Docker's network namespace (recommended). +3. **Docker Development Image**: Create a containerized execution-specs environment that runs within Docker's network namespace (recommended). The following section details the setup and usage of option 3. -### EEST Docker Development Image +### EELS Docker Development Image -Within the [`eest/`](https://github.com/ethereum/hive/tree/master/simulators/ethereum/eest) directory of hive, a new dockerfile must be created: `Dockerfile.dev`, with the following contents: +Within the [`eels/`](https://github.com/ethereum/hive/tree/master/simulators/ethereum/eels) directory of hive, a new dockerfile must be created: `Dockerfile.dev`, with the following contents: ```docker FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim -ARG branch=main -ENV GIT_REF=${branch} +ARG branch="" RUN apt-get update && apt-get install -y git -RUN git init execution-spec-tests && \ - cd execution-spec-tests && \ - git remote add origin https://github.com/ethereum/execution-spec-tests.git && \ - git fetch --depth 1 origin $GIT_REF && \ - git checkout FETCH_HEAD; - -WORKDIR /execution-spec-tests +RUN git clone --depth 1 https://github.com/ethereum/execution-specs.git && \ + cd execution-specs && \ + if [ -n "$branch" ]; then \ + git fetch --depth 1 origin "$branch" && \ + git checkout FETCH_HEAD; \ + fi +WORKDIR /execution-specs/packages/testing RUN uv sync ENTRYPOINT ["/bin/bash"] ``` -This dockerfile will be our entry point for running EEST commands. +This dockerfile will be our entry point for running simulator commands. -### `eest/` Hive Directory Structure +### `eels/` Hive Directory Structure ```tree -├── eest +├── eels │ ├── Dockerfile.dev │ ├── consume-block-rlp │ │ └── Dockerfile @@ -108,10 +107,10 @@ This dockerfile will be our entry point for running EEST commands. ./hive --dev --dev.addr :3000 --client go-ethereum --client-file clients.yaml ``` -3. In a separate terminal session, build the EEST development image: +3. In a separate terminal session, build the EELS development image: ```bash - cd simulators/ethereum/eest/ + cd simulators/ethereum/eels/ docker build -t macos-consume-dev -f Dockerfile.dev . ``` @@ -136,7 +135,7 @@ When Hive runs in dev mode: 3. Keeps the Hive Proxy container running between test executions. 4. Waits for external simulator connections via the API. -This allows EEST's consume commands to connect to the running Hive instance and execute tests interactively. +This allows the EELS's consume commands to connect to the running Hive instance and execute tests interactively. ## More Options Available diff --git a/docs/running_tests/hive/index.md b/docs/running_tests/hive/index.md index 81f75be5eec..aba902eb499 100644 --- a/docs/running_tests/hive/index.md +++ b/docs/running_tests/hive/index.md @@ -1,6 +1,6 @@ # Hive -@ethereum/hive is a containerized testing framework that helps orchestrate test execution against Ethereum clients. Hive is incredibly extensible; new test suites can be implemented in a module manner as "simulators" that interact with clients to test certain aspects of their behavior. EEST implements several simulators, see [Running Tests](../running.md) for an overview. +@ethereum/hive is a containerized testing framework that helps orchestrate test execution against Ethereum clients. Hive is incredibly extensible; new test suites can be implemented in a module manner as "simulators" that interact with clients to test certain aspects of their behavior. The execution-specs `testing` package implements several simulators, see [Running Tests](../running.md) for an overview. ## Quick Start diff --git a/docs/running_tests/releases.md b/docs/running_tests/releases.md index 2136f0401a9..7f7ea06cef1 100644 --- a/docs/running_tests/releases.md +++ b/docs/running_tests/releases.md @@ -7,10 +7,10 @@ | Format | Consumed by the client | Location in `.tar.gz` release | | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | [State Tests](./test_formats/state_test.md) | - directly via a `statetest`-like command
(e.g., [go-ethereum/cmd/evm/staterunner.go](https://github.com/ethereum/go-ethereum/blob/4bb097b7ffc32256791e55ff16ca50ef83c4609b/cmd/evm/staterunner.go)) | `./fixtures/state_tests/` | -| [Blockchain Tests](./test_formats/blockchain_test.md) | - directly via a `blocktest`-like command
(e.g., [go-ethereum/cmd/evm/blockrunner.go](https://github.com/ethereum/go-ethereum/blob/4bb097b7ffc32256791e55ff16ca50ef83c4609b/cmd/evm/blockrunner.go))
- using the [RLPeest/consume-rlp Simulator](./running.md#rlp) via block import | `./fixtures/blockchain_tests/` | -| [Blockchain Engine Tests](./test_formats/blockchain_test_engine.md) | - using the [eest/consume-engine Simulator](./running.md#engine) and the Engine API | `./fixtures/blockchain_tests_engine/` | +| [Blockchain Tests](./test_formats/blockchain_test.md) | - directly via a `blocktest`-like command
(e.g., [go-ethereum/cmd/evm/blockrunner.go](https://github.com/ethereum/go-ethereum/blob/4bb097b7ffc32256791e55ff16ca50ef83c4609b/cmd/evm/blockrunner.go))
- using the [eels/consume-rlp Simulator](./running.md#rlp) via block import | `./fixtures/blockchain_tests/` | +| [Blockchain Engine Tests](./test_formats/blockchain_test_engine.md) | - using the [eels/consume-engine Simulator](./running.md#engine) and the Engine API | `./fixtures/blockchain_tests_engine/` | | [Transaction Tests](./test_formats/transaction_test.md) | - using a new simulator coming soon | None; executed directly from Python source,
using a release tag | -| Blob Transaction Tests | - using the [eest/execute-blobs Simulator](./execute/hive.md#the-eestexecute-blobs-simulator) and | None; executed directly from Python source,
using a release tag | +| Blob Transaction Tests | - using the [eels/execute-blobs Simulator](./execute/hive.md#the-eelsexecute-blobs-simulator) and | None; executed directly from Python source,
using a release tag | ## Release URLs and Tarballs diff --git a/docs/running_tests/running.md b/docs/running_tests/running.md index 94272d7d4e0..efd98c340d3 100644 --- a/docs/running_tests/running.md +++ b/docs/running_tests/running.md @@ -23,9 +23,9 @@ Both `consume` and `execute` provide sub-commands which correspond to different The following sections describe the different methods in more detail. -!!! note "`./hive --sim=eest/consume-engine` vs `consume engine`" +!!! note "`./hive --sim=eels/consume-engine` vs `consume engine`" - EEST simulators can be ran either standalone using the `./hive` command or via an EEST command against a `./hive --dev` backend, more details are [provided below](#two-methods-to-run-eest-simulators). + The execution-specs simulators can be ran either standalone using the `./hive` command or via a `uv`/Python-based command against a `./hive --dev` backend, more details are [provided below](#two-methods-to-run-eels-simulators). ## Direct @@ -48,7 +48,7 @@ The EEST `consume direct` command is a small wrapper around client direct interf | Nomenclature | | | -------------- | ------------------------ | | Command | `consume engine` | -| Simulator | `eest/consume-engine` | +| Simulator | `eels/consume-engine` | | Fixture format | `blockchain_test_engine` | The consume engine method tests execution clients via the Engine API by sending block payloads and verifying the response (post-merge forks only). This method provides the most realistic testing environment for production Ethereum client behavior, covering consensus integration, payload validation, and state synchronization. @@ -67,7 +67,7 @@ The `consume engine` command: | Nomenclature | | | -------------- | ------------------ | | Command | `consume rlp` | -| Simulator | `eest/consume-rlp` | +| Simulator | `eels/consume-rlp` | | Fixture format | `blockchain_test` | The RLP consumption method tests execution clients by providing them with RLP-encoded blocks to load upon startup, similar to the block import process during historical synchronization. This method tests the client's core block processing logic without the overhead of network protocols. @@ -103,15 +103,15 @@ The `consume sync` command: ## Engine vs RLP Simulator -The RLP Simulator (`eest/consume-rlp`) and the Engine Simulator (`eest/consume-engine`) should be seen as complimentary to one another. Although they execute the same underlying EVM test cases, the block validation logic is executed via different client code paths (using different [fixture formats](./test_formats/index.md)). Therefore, ideally, **both simulators should be executed for full coverage**. +The RLP Simulator (`eels/consume-rlp`) and the Engine Simulator (`eels/consume-engine`) should be seen as complimentary to one another. Although they execute the same underlying EVM test cases, the block validation logic is executed via different client code paths (using different [fixture formats](./test_formats/index.md)). Therefore, ideally, **both simulators should be executed for full coverage**. ### Code Path Choices -Clients consume fixtures in the `eest/consume-engine` simulator via the Engine API's `EngineNewPayloadv*` endpoint; a natural way to validate, respectively invalidate, block payloads. In this case, there is no flexibility in the choice of code path - it directly harnesses mainnet client functionality. The `eest/consume-rlp` Simulator, however, allows clients more freedom, as the rlp-encoded blocks are imported upon client startup. Clients are recommended to try and hook the block import into the code path used for historical syncing. +Clients consume fixtures in the `eels/consume-engine` simulator via the Engine API's `EngineNewPayloadv*` endpoint; a natural way to validate, respectively invalidate, block payloads. In this case, there is no flexibility in the choice of code path - it directly harnesses mainnet client functionality. The `eels/consume-rlp` Simulator, however, allows clients more freedom, as the rlp-encoded blocks are imported upon client startup. Clients are recommended to try and hook the block import into the code path used for historical syncing. ### Differences -| | `eest/consume-rlp` | `eest/consume-engine` | +| | `eels/consume-rlp` | `eels/consume-engine` | | ----------------------- | ----------------------------------------------------- | ------------------------------------------------------------------ | | **Fixture Format Used** | [`BlockchainTest`](./test_formats/blockchain_test.md) | [`BlockchainTestEngine`](./test_formats/blockchain_test_engine.md) | | **Fork support** | All forks (including pre-merge) | Post-merge forks only (Paris+) | @@ -128,9 +128,9 @@ Clients consume fixtures in the `eest/consume-engine` simulator via the Engine A See [Execute Command](./execute/index.md). -## Two Methods to Run EEST Simulators +## Two Methods to Run EELS Simulators -Many of the methods use the Hive Testing Environment to interact with clients and run tests against them. These methods are also called Hive simulators. While Hive is always necessary to run simulators, they can be called in two different ways. Both of these commands execute the same simulator code, but in different environments, we take the example of the `eest/consume-engine` simulator: +Many of the methods use the Hive Testing Environment to interact with clients and run tests against them. These methods are also called Hive simulators. While Hive is always necessary to run simulators, they can be called in two different ways. Both of these commands execute the same simulator code, but in different environments, we take the example of the `eels/consume-engine` simulator: -1. `./hive --sim=eest/consume-engine` is a standalone command that installs EEST and the `consume` command in a dockerized container managed by Hive. This is the standard method to execute EEST [fixture releases](./releases.md) against clients in CI environments and is the method to generate the results at [hive.ethpandaops.io](https://hive.ethpandaops.io). See [Hive](./hive/index.md) and its [Common Options](./hive/common_options.md) for help with this method. -2. `uv run consume engine` requires the user to clone and [install EEST](../getting_started/installation.md) and start a Hive server in [development mode](./hive/dev_mode.md). In this case, the simulator runs on the native system and communicate to the client via the Hive API. This is particularly useful during test development as fixtures on the local disk can be specified via `--input=fixtures/`. As the simulator runs natively, it is easy to drop into a debugger and inspect the simulator or client container state. See [Hive Developer Mode](./hive/dev_mode.md) for help with this method. +1. `./hive --sim=eels/consume-engine` is a standalone command that installs and configures execution-specs and its `consume` command in a dockerized container managed by Hive. This is the standard method to execute EEST [fixture releases](./releases.md) against clients in CI environments and is the method to generate the results at [hive.ethpandaops.io](https://hive.ethpandaops.io). See [Hive](./hive/index.md) and its [Common Options](./hive/common_options.md) for help with this method. +2. `uv run consume engine` requires the user to clone and [configure execution-specs](../getting_started/installation.md) and start a Hive server in [development mode](./hive/dev_mode.md). In this case, the simulator runs on the native system and communicate to the client via the Hive API. This is particularly useful during test development as fixtures on the local disk can be specified via `--input=fixtures/`. As the simulator runs natively, it is easy to drop into a debugger and inspect the simulator or client container state. See [Hive Developer Mode](./hive/dev_mode.md) for help with this method. From 9fadfac8dbef6ea007b4f4237d752fb3d7cb5b8b Mon Sep 17 00:00:00 2001 From: kevaundray Date: Wed, 18 Feb 2026 15:49:25 +0000 Subject: [PATCH 002/265] feat: Add changes needed to implement `ExecutionEngine` and `StatelessValidation` (#2213) * initial commit * make fcu.py a full stub -- main focus is newPayload * fix comment * move ExecutionRequests to types.py * fix comment * fix comment: be clearer on whats missing * lint * docc cant resolve cross module references * reference type directly * use bal Co-authored-by: Guru * removed --------- Co-authored-by: Guru --- .../amsterdam/execution_engine/__init__.py | 52 ++++++ .../execution_engine/forkchoice_update.py | 26 +++ .../amsterdam/execution_engine/get_payload.py | 16 ++ .../amsterdam/execution_engine/new_payload.py | 150 ++++++++++++++++ .../forks/amsterdam/execution_engine/types.py | 121 +++++++++++++ .../execution_engine/validation_helpers.py | 101 +++++++++++ src/ethereum/forks/amsterdam/stateless.py | 166 ++++++++++++++++++ 7 files changed, 632 insertions(+) create mode 100644 src/ethereum/forks/amsterdam/execution_engine/__init__.py create mode 100644 src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py create mode 100644 src/ethereum/forks/amsterdam/execution_engine/get_payload.py create mode 100644 src/ethereum/forks/amsterdam/execution_engine/new_payload.py create mode 100644 src/ethereum/forks/amsterdam/execution_engine/types.py create mode 100644 src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py create mode 100644 src/ethereum/forks/amsterdam/stateless.py diff --git a/src/ethereum/forks/amsterdam/execution_engine/__init__.py b/src/ethereum/forks/amsterdam/execution_engine/__init__.py new file mode 100644 index 00000000000..ca662fe9c68 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/__init__.py @@ -0,0 +1,52 @@ +""" +Execution Engine. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +The execution engine is the interface defined by the consensus layer +in the consensus-specs for calling the execution layer. +It provides methods for the consensus-specs to verify and apply +new payloads to the execution layer state. + +These methods correspond to the ``ExecutionEngine`` abstraction in the +consensus-specs and can change over forks. +""" + +from .forkchoice_update import notify_forkchoice_updated +from .get_payload import get_payload +from .new_payload import ( + is_valid_block_hash, + is_valid_versioned_hashes, + notify_new_payload, + verify_and_notify_new_payload, +) +from .types import ( + BlobsBundle, + ExecutionEngine, + ExecutionPayload, + GetPayloadResponse, + NewPayloadRequest, + PayloadAttributes, + PayloadId, +) + +__all__ = [ + "BlobsBundle", + "ExecutionEngine", + "ExecutionPayload", + "GetPayloadResponse", + "NewPayloadRequest", + "PayloadAttributes", + "PayloadId", + "get_payload", + "is_valid_block_hash", + "is_valid_versioned_hashes", + "notify_forkchoice_updated", + "notify_new_payload", + "verify_and_notify_new_payload", +] diff --git a/src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py b/src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py new file mode 100644 index 00000000000..2f96bf4e9a3 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/forkchoice_update.py @@ -0,0 +1,26 @@ +""" +Forkchoice update and payload build signal. +""" + +from typing import Optional + +from ethereum.crypto.hash import Hash32 + +from .types import ( + ExecutionEngine, + PayloadAttributes, + PayloadId, +) + + +def notify_forkchoice_updated( + _chain: ExecutionEngine, + _head_block_hash: Hash32, + _safe_block_hash: Hash32, + _finalized_block_hash: Hash32, + _payload_attributes: Optional[PayloadAttributes], +) -> Optional[PayloadId]: + """ + Notify the execution engine about the latest fork-choice state. + """ + raise NotImplementedError diff --git a/src/ethereum/forks/amsterdam/execution_engine/get_payload.py b/src/ethereum/forks/amsterdam/execution_engine/get_payload.py new file mode 100644 index 00000000000..8dc2541a3d7 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/get_payload.py @@ -0,0 +1,16 @@ +""" +Payload build storage and retrieval helpers. +""" + +from .types import ( + GetPayloadResponse, + PayloadId, +) + + +def get_payload(payload_id: PayloadId) -> GetPayloadResponse: + """ + Return a prepared payload response for a previously returned + ``PayloadId``. + """ + raise NotImplementedError diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py new file mode 100644 index 00000000000..a1da219d742 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -0,0 +1,150 @@ +""" +Payload verification. +""" + +from typing import Sequence, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes + +from ethereum.crypto.hash import keccak256 + +from ..fork import state_transition +from ..fork_types import Root, VersionedHash +from ..transactions import BlobTransaction, decode_transaction +from ..trie import copy_trie +from .types import ExecutionEngine, ExecutionPayload, NewPayloadRequest +from .validation_helpers import _payload_block, _payload_header + + +def is_valid_block_hash( + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests_list: Sequence[bytes], +) -> bool: + """ + Return ``True`` if and only if ``execution_payload.block_hash`` is + computed correctly. + """ + try: + header = _payload_header( + execution_payload, + parent_beacon_block_root, + tuple(execution_requests_list), + ) + except Exception: + # Any decoding or conversion failure means the payload + # cannot produce a valid header. + return False + return keccak256(rlp.encode(header)) == execution_payload.block_hash + + +def is_valid_versioned_hashes( + new_payload_request: NewPayloadRequest, +) -> bool: + """ + Return ``True`` if and only if the versioned hashes computed by blob + transactions in ``new_payload_request.execution_payload`` match + ``new_payload_request.versioned_hashes``. + """ + computed_versioned_hashes: list[VersionedHash] = [] + + try: + for encoded_tx in new_payload_request.execution_payload.transactions: + tx = decode_transaction(encoded_tx) + if isinstance(tx, BlobTransaction): + computed_versioned_hashes.extend(tx.blob_versioned_hashes) + except Exception: + # Any decoding failure means versioned hashes cannot be + # verified. + return False + + return tuple(computed_versioned_hashes) == ( + new_payload_request.versioned_hashes + ) + + +def notify_new_payload( + chain: ExecutionEngine, + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests: Tuple[Bytes, ...], +) -> bool: + """ + Execute a payload by converting it to an execution-layer block + and applying the fork's canonical ``state_transition``. + + On success, keep the applied state and appended block. + On failure, restore state and block history snapshots and return ``False``. + """ + # TODO: Create snapshots, so we can rollback. + # This is inefficient. Check if we can remove this + # or how the rest of the code handles partial mutation + # when the block is invalid. + state_main_trie_snapshot = copy_trie(chain.state._main_trie) + state_storage_tries_snapshot = { + address: copy_trie(storage_trie) + for address, storage_trie in chain.state._storage_tries.items() + } + created_accounts_snapshot = chain.state.created_accounts.copy() + blocks_snapshot = list(chain.blocks) + + try: + block = _payload_block( + execution_payload, + parent_beacon_block_root, + # Note: `verify_and_notify_new_payload` calls + # `is_valid_block_hash` which also recomputes + # the `execution_requests_root`. We do this to + # stay conformant with the API from the consensus-specs. + execution_requests, + ) + # Note: it seems `state_transition` does not check + # the `block_hash` in the header is correct value. + # This is currently done in with `is_valid_block_hash` + state_transition(chain, block) + except Exception: + # Any failure during state transition invalidates the + # payload; restore pre-execution snapshots. + chain.state._main_trie = state_main_trie_snapshot + chain.state._storage_tries = state_storage_tries_snapshot + chain.state.created_accounts = created_accounts_snapshot + chain.blocks = blocks_snapshot + return False + + return True + + +def verify_and_notify_new_payload( + chain: ExecutionEngine, + new_payload_request: NewPayloadRequest, +) -> bool: + """ + Validate the payload and, if valid, apply it to the chain. + """ + payload = new_payload_request.execution_payload + parent_beacon_block_root = new_payload_request.parent_beacon_block_root + execution_requests_list = new_payload_request.execution_requests + + if b"" in payload.transactions: + return False + + if not is_valid_block_hash( + payload, + parent_beacon_block_root, + execution_requests_list, + ): + return False + + if not is_valid_versioned_hashes(new_payload_request): + return False + + if not notify_new_payload( + chain, + payload, + parent_beacon_block_root, + tuple(execution_requests_list), + ): + return False + + return True diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py new file mode 100644 index 00000000000..de8a4541458 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -0,0 +1,121 @@ +""" +Execution engine data structures and aliases. +""" + +from dataclasses import dataclass +from typing import Tuple + +from ethereum_types.bytes import Bytes, Bytes8, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32 + +from ..blocks import Withdrawal +from ..fork import BlockChain +from ..fork_types import Address, Bloom, Root, VersionedHash +from ..transactions import LegacyTransaction + +# In this module, the execution engine is the chain/state container used by +# the fork's transition functions. +ExecutionEngine = BlockChain +PayloadId = Bytes8 +_ZERO_HASH32 = Hash32(b"\x00" * 32) +ExecutionRequests = Tuple[Bytes, ...] + + +@slotted_freezable +@dataclass +class ExecutionPayload: + """ + Represent a new block to be processed by the execution layer. + + The consensus layer constructs this from a beacon block body and + passes it to the execution engine for validation. + + Note: execution_request_hash is not a direct field in ExecutionPayload + but it is indirectly committed to via `block_hash` since `request_hash` + is in the EL-block header. + """ + + parent_hash: Hash32 + fee_recipient: Address + state_root: Root + receipts_root: Root + logs_bloom: Bloom + prev_randao: Bytes32 + block_number: Uint + gas_limit: Uint + gas_used: Uint + timestamp: U256 + extra_data: Bytes + base_fee_per_gas: Uint + block_hash: Hash32 + transactions: Tuple[LegacyTransaction | Bytes, ...] + withdrawals: Tuple[Withdrawal, ...] + blob_gas_used: U64 + excess_blob_gas: U64 + block_access_list: Bytes + + +@slotted_freezable +@dataclass +class NewPayloadRequest: + """ + Contains an execution payload along with versioned hashes, the + parent beacon block root, and execution requests for the + ``verify_and_notify_new_payload`` entry point. + + This corresponds to the consensus-layer `NewPayloadRequest` + container used for Engine API calls. + + [Bellatrix `NewPayloadRequest`]: + https://ethereum.github.io/consensus-specs/specs/bellatrix/beacon-chain/#newpayloadrequest + [Electra modified `NewPayloadRequest`]: + https://ethereum.github.io/consensus-specs/specs/electra/beacon-chain/#modified-newpayloadrequest + """ + + execution_payload: ExecutionPayload + versioned_hashes: Tuple[VersionedHash, ...] + parent_beacon_block_root: Root + execution_requests: ExecutionRequests + + +@slotted_freezable +@dataclass +class PayloadAttributes: + """ + Carry the parameters that the consensus layer supplies when it + requests the execution layer to build a new block. + """ + + timestamp: U256 + prev_randao: Bytes32 + suggested_fee_recipient: Address + withdrawals: Tuple[Withdrawal, ...] + parent_beacon_block_root: Root + + +@slotted_freezable +@dataclass +class BlobsBundle: + """ + Bundle of blobs data associated with a built payload. + """ + + commitments: Tuple[Bytes, ...] + proofs: Tuple[Bytes, ...] + blobs: Tuple[Bytes, ...] + + +@slotted_freezable +@dataclass +class GetPayloadResponse: + """ + Response returned by ``get_payload`` for a prepared payload build. + """ + + execution_payload: ExecutionPayload + block_value: U256 + blobs_bundle: BlobsBundle + execution_requests: ExecutionRequests diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py new file mode 100644 index 00000000000..845a3c3515f --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -0,0 +1,101 @@ +""" +Shared execution-engine conversion helpers. +""" + +from typing import Optional, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8 +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import Hash32, keccak256 + +from ..blocks import Block, Header +from ..fork import EMPTY_OMMER_HASH +from ..fork_types import Root +from ..requests import compute_requests_hash +from ..transactions import LegacyTransaction +from ..trie import Trie, root, trie_set +from .types import ExecutionPayload + + +def _payload_header( + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests: Tuple[Bytes, ...], +) -> Header: + """ + Build the execution header implied by a payload request. + """ + transactions_trie: Trie[Bytes, Optional[LegacyTransaction | Bytes]] = Trie( + secured=False, default=None + ) + for i, encoded_tx in enumerate(execution_payload.transactions): + trie_set( + transactions_trie, + rlp.encode(Uint(i)), + encoded_tx, + ) + transactions_root = root(transactions_trie) + + withdrawals_trie: Trie[Bytes, Optional[Bytes]] = Trie( + secured=False, default=None + ) + for i, withdrawal in enumerate(execution_payload.withdrawals): + trie_set( + withdrawals_trie, + rlp.encode(Uint(i)), + rlp.encode(withdrawal), + ) + withdrawals_root = root(withdrawals_trie) + + requests_hash = Hash32(compute_requests_hash(list(execution_requests))) + + return Header( + parent_hash=execution_payload.parent_hash, + ommers_hash=EMPTY_OMMER_HASH, + coinbase=execution_payload.fee_recipient, + state_root=execution_payload.state_root, + transactions_root=transactions_root, + receipt_root=execution_payload.receipts_root, + bloom=execution_payload.logs_bloom, + difficulty=Uint(0), + number=execution_payload.block_number, + gas_limit=execution_payload.gas_limit, + gas_used=execution_payload.gas_used, + timestamp=execution_payload.timestamp, + extra_data=execution_payload.extra_data, + prev_randao=execution_payload.prev_randao, + nonce=Bytes8(b"\x00\x00\x00\x00\x00\x00\x00\x00"), + base_fee_per_gas=execution_payload.base_fee_per_gas, + withdrawals_root=withdrawals_root, + blob_gas_used=execution_payload.blob_gas_used, + excess_blob_gas=execution_payload.excess_blob_gas, + parent_beacon_block_root=parent_beacon_block_root, + requests_hash=requests_hash, + block_access_list_hash=Hash32( + keccak256(execution_payload.block_access_list) + ), + ) + + +def _payload_block( + execution_payload: ExecutionPayload, + parent_beacon_block_root: Root, + execution_requests: Tuple[Bytes, ...], +) -> Block: + """ + Convert an execution payload request into an execution-layer block. + """ + header = _payload_header( + execution_payload, + parent_beacon_block_root, + execution_requests, + ) + + return Block( + header=header, + transactions=execution_payload.transactions, + ommers=(), + withdrawals=execution_payload.withdrawals, + ) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py new file mode 100644 index 00000000000..f72db9ad658 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -0,0 +1,166 @@ +""" +Stateless validation interfaces. +""" + +from dataclasses import dataclass +from typing import Sequence, Tuple + +from ethereum_types.bytes import Bytes +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64 + +from ethereum.crypto.hash import Hash32 + +from .blocks import Block +from .execution_engine.types import NewPayloadRequest +from .fork_types import Root, VersionedHash + +# Amsterdam currently carries execution requests as raw bytes in order. +ExecutionRequests = Tuple[Bytes, ...] + + +@slotted_freezable +@dataclass +class ExecutionPayloadHeader: + """ + Execution payload header for stateless input scaffolding. + + TODO: Replace with the fork-specific execution payload header container. + """ + + +@slotted_freezable +@dataclass +class NewPayloadRequestHeader: + """ + Header-only form of ``NewPayloadRequest`` for stateless flows. + + We expect ``hash_tree_root(execution_payload_header)`` equals + ``hash_tree_root(execution_payload)``. + """ + + execution_payload_header: ExecutionPayloadHeader + versioned_hashes: Sequence[VersionedHash] + parent_beacon_block_root: Root + execution_requests: ExecutionRequests + + +@slotted_freezable +@dataclass +class ExecutionWitness: + """ + Execution witness data for stateless validation. + """ + + state: Tuple[Bytes, ...] + """ + Hashed trie-node preimages needed during execution and state-root + recomputation. + """ + + codes: Tuple[Bytes, ...] + """ + Contract-code preimages (created or accessed) needed during execution. + """ + + headers: Tuple[Bytes, ...] + """ + RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness + proofs. This may trend toward empty with EIP-2935 and EIP-7709. + """ + + +@slotted_freezable +@dataclass +class ChainConfig: + """ + Chain configuration needed for stateless validation. + + TODO: Since we do not want the client to hold all possible chains, + we may want to add more to the chain config, like a genesis file. + """ + + chain_id: U64 + + +@slotted_freezable +@dataclass +class StatelessInput: + """ + Input to stateless validation. + """ + + new_payload_request: NewPayloadRequest + """ + Consensus-layer payload request to validate statelessly. See + ``execution_engine.NewPayloadRequest`` for structure and links to + consensus-specs. + """ + + witness: ExecutionWitness + """ + Execution witness material required to re-execute the core + state transition function statelessly. + """ + + chain_config: ChainConfig + """ + Chain configuration values needed during stateless validation. + """ + + public_keys: Tuple[Bytes, ...] + """ + Recovered transaction public keys, in transaction order. + """ + + +@slotted_freezable +@dataclass +class StatelessValidationResult: + """ + Result returned by stateless validation. + """ + + new_payload_request_root: Hash32 + successful_validation: bool + + +def compute_new_payload_request_root( + stateless_input: StatelessInput, +) -> Hash32: + """ + Compute the request root for a stateless input. + + TODO: Replace this with ``new_payload_request.tree_hash_root``. + + # For readability, we can convert to NewPayloadRequestHeader and + # then the payload request root. + """ + raise NotImplementedError + + +def new_payload_request_to_block( + new_payload_request: NewPayloadRequest, +) -> Block: + """ + Convert a NewPayloadRequest into a block. + """ + raise NotImplementedError + + +def verify_stateless_new_payload( + stateless_input: StatelessInput, +) -> StatelessValidationResult: + """ + Statelessly validate the execution payload. + """ + # TODO: We can fill this in properly once the pre-state PR + # TODO: and state change PRs are completed. + # TODO: We would effectively call `verify_and_notify_new_payload` + + return StatelessValidationResult( + new_payload_request_root=compute_new_payload_request_root( + stateless_input + ), + successful_validation=True, + ) From a1808b7cdf6bb39936bd34a3661f7411aefb504c Mon Sep 17 00:00:00 2001 From: kevaundray Date: Sat, 21 Feb 2026 21:13:33 +0000 Subject: [PATCH 003/265] fix(zkevm): Fix linting errors (#2269) * fix * tox --- src/ethereum/forks/amsterdam/execution_engine/new_payload.py | 5 ++--- src/ethereum/forks/amsterdam/execution_engine/types.py | 3 ++- .../forks/amsterdam/execution_engine/validation_helpers.py | 2 +- src/ethereum/forks/amsterdam/stateless.py | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py index a1da219d742..d75e9d35dd1 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -8,9 +8,10 @@ from ethereum_types.bytes import Bytes from ethereum.crypto.hash import keccak256 +from ethereum.state import Root from ..fork import state_transition -from ..fork_types import Root, VersionedHash +from ..fork_types import VersionedHash from ..transactions import BlobTransaction, decode_transaction from ..trie import copy_trie from .types import ExecutionEngine, ExecutionPayload, NewPayloadRequest @@ -86,7 +87,6 @@ def notify_new_payload( address: copy_trie(storage_trie) for address, storage_trie in chain.state._storage_tries.items() } - created_accounts_snapshot = chain.state.created_accounts.copy() blocks_snapshot = list(chain.blocks) try: @@ -108,7 +108,6 @@ def notify_new_payload( # payload; restore pre-execution snapshots. chain.state._main_trie = state_main_trie_snapshot chain.state._storage_tries = state_storage_tries_snapshot - chain.state.created_accounts = created_accounts_snapshot chain.blocks = blocks_snapshot return False diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py index de8a4541458..87180d28367 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/types.py +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -10,10 +10,11 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32 +from ethereum.state import Address, Root from ..blocks import Withdrawal from ..fork import BlockChain -from ..fork_types import Address, Bloom, Root, VersionedHash +from ..fork_types import Bloom, VersionedHash from ..transactions import LegacyTransaction # In this module, the execution engine is the chain/state container used by diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py index 845a3c3515f..05896f78e06 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -9,10 +9,10 @@ from ethereum_types.numeric import Uint from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.state import Root from ..blocks import Block, Header from ..fork import EMPTY_OMMER_HASH -from ..fork_types import Root from ..requests import compute_requests_hash from ..transactions import LegacyTransaction from ..trie import Trie, root, trie_set diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index f72db9ad658..0547a2ed26e 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -10,10 +10,11 @@ from ethereum_types.numeric import U64 from ethereum.crypto.hash import Hash32 +from ethereum.state import Root from .blocks import Block from .execution_engine.types import NewPayloadRequest -from .fork_types import Root, VersionedHash +from .fork_types import VersionedHash # Amsterdam currently carries execution requests as raw bytes in order. ExecutionRequests = Tuple[Bytes, ...] From 406aabbf76c623ccea2ab91f8eafb81314e04b89 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 09:14:08 -0300 Subject: [PATCH 004/265] feat(zkevm): add ExecutionWitness to the specs and testing framework (#2259) * wire ExecutionWitness into the testing framework Signed-off-by: jsign * lints Signed-off-by: jsign * lints Signed-off-by: jsign * rebase and rename Signed-off-by: jsign * lint Signed-off-by: jsign --------- Signed-off-by: jsign --- .../client_clis/cli_types.py | 2 + .../execution_testing/fixtures/blockchain.py | 2 + .../src/execution_testing/specs/blockchain.py | 8 +++ .../execution_testing/test_types/__init__.py | 4 +- .../test_types/execution_witness.py | 18 +++++ src/ethereum/forks/amsterdam/fork.py | 6 ++ src/ethereum/forks/amsterdam/stateless.py | 26 +------ .../forks/amsterdam/stateless_types.py | 67 +++++++++++++++++++ src/ethereum/forks/amsterdam/vm/__init__.py | 5 ++ .../evm_tools/loaders/fork_loader.py | 14 ++++ .../evm_tools/t8n/__init__.py | 9 +++ .../evm_tools/t8n/t8n_types.py | 12 ++++ 12 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 packages/testing/src/execution_testing/test_types/execution_witness.py create mode 100644 src/ethereum/forks/amsterdam/stateless_types.py diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index 3a39479eca6..46e3d688af4 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -32,6 +32,7 @@ Alloc, BlockAccessList, Environment, + ExecutionWitness, Transaction, TransactionReceipt, ) @@ -289,6 +290,7 @@ class Result(CamelModel): requests: List[Bytes] | None = None block_access_list: BlockAccessList | None = None block_access_list_hash: Hash | None = None + execution_witness: ExecutionWitness | None = None block_exception: Annotated[ BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index a653e4d97e9..9492be88459 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -52,6 +52,7 @@ from execution_testing.test_types import ( BlockAccessList, Environment, + ExecutionWitness, Requests, Transaction, Withdrawal, @@ -624,6 +625,7 @@ def strip_block_number_computed_field(cls, data: Any) -> Any: ) withdrawals: List[FixtureWithdrawal] | None = None receipts: List[FixtureTransactionReceipt] | None = None + execution_witness: ExecutionWitness | None = None block_access_list: BlockAccessList | None = Field( None, description="EIP-7928 Block Access List" ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index deadb5bc109..9532d0b14db 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -61,6 +61,7 @@ LabeledFixtureFormat, ) from execution_testing.fixtures.blockchain import ( + ExecutionWitness, FixtureBlock, FixtureBlockBase, FixtureConfig, @@ -381,6 +382,7 @@ class BuiltBlock(CamelModel): engine_api_error_code: EngineAPIError | None = None fork: Fork block_access_list: BlockAccessList | None + execution_witness: ExecutionWitness | None = None def get_fixture_block( self, *, include_receipts: bool = True @@ -410,6 +412,9 @@ def get_fixture_block( block_access_list=self.block_access_list if self.block_access_list else None, + execution_witness=self.execution_witness + if self.execution_witness + else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -773,6 +778,9 @@ def generate_block_data( engine_api_error_code=block.engine_api_error_code, fork=self.fork, block_access_list=bal, + execution_witness=( + transition_tool_output.result.execution_witness + ), ) try: diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index 979a76d49ba..b1215377b4d 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -19,6 +19,7 @@ Withdrawal, ) from .chain_config_types import ChainConfig, ChainConfigDefaults +from .execution_witness import ExecutionWitness from .helpers import ( DETERMINISTIC_FACTORY_ADDRESS, DETERMINISTIC_FACTORY_BYTECODE, @@ -68,9 +69,10 @@ "ChainConfigDefaults", "ConsolidationRequest", "DepositRequest", + "EOA", "Environment", "EnvironmentDefaults", - "EOA", + "ExecutionWitness", "NetworkWrappedTransaction", "Removable", "Requests", diff --git a/packages/testing/src/execution_testing/test_types/execution_witness.py b/packages/testing/src/execution_testing/test_types/execution_witness.py new file mode 100644 index 00000000000..4a0a698ba89 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness.py @@ -0,0 +1,18 @@ +"""Execution witness types.""" + +from typing import List + +from pydantic import Field + +from execution_testing.base_types import ( + Bytes, + CamelModel, +) + + +class ExecutionWitness(CamelModel): + """Execution witness for stateless validation.""" + + state: List[Bytes] = Field(default_factory=list) + codes: List[Bytes] = Field(default_factory=list) + headers: List[Bytes] = Field(default_factory=list) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 3fccd54f218..2cfd3c5cff3 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -73,6 +73,7 @@ increment_nonce, set_account_balance, ) +from .stateless_types import ExecutionWitnessBuilder, build_execution_witness from .transactions import ( AccessListTransaction, BlobTransaction, @@ -250,6 +251,7 @@ def state_transition(chain: BlockChain, block: Block) -> None: excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), + execution_witness=ExecutionWitnessBuilder(), ) block_output = apply_body( @@ -814,6 +816,10 @@ def apply_body( block_env.block_access_list_builder, block_env.state ) + block_output.execution_witness = build_execution_witness( + block_env.execution_witness + ) + return block_output diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 0547a2ed26e..005a0020fb9 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -15,6 +15,7 @@ from .blocks import Block from .execution_engine.types import NewPayloadRequest from .fork_types import VersionedHash +from .stateless_types import ExecutionWitness # Amsterdam currently carries execution requests as raw bytes in order. ExecutionRequests = Tuple[Bytes, ...] @@ -46,31 +47,6 @@ class NewPayloadRequestHeader: execution_requests: ExecutionRequests -@slotted_freezable -@dataclass -class ExecutionWitness: - """ - Execution witness data for stateless validation. - """ - - state: Tuple[Bytes, ...] - """ - Hashed trie-node preimages needed during execution and state-root - recomputation. - """ - - codes: Tuple[Bytes, ...] - """ - Contract-code preimages (created or accessed) needed during execution. - """ - - headers: Tuple[Bytes, ...] - """ - RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness - proofs. This may trend toward empty with EIP-2935 and EIP-7709. - """ - - @slotted_freezable @dataclass class ChainConfig: diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py new file mode 100644 index 00000000000..b9ad03f7ac4 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -0,0 +1,67 @@ +""" +Stateless validation types. +""" + +from dataclasses import dataclass, field +from typing import List, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.frozen import slotted_freezable + +from .blocks import Header + + +@slotted_freezable +@dataclass +class ExecutionWitness: + """ + Execution witness data for stateless validation. + """ + + state: Tuple[Bytes, ...] + """ + Hashed trie-node preimages needed during execution and state-root + recomputation. + """ + + codes: Tuple[Bytes, ...] + """ + Contract-code preimages (created or accessed) needed during execution. + """ + + headers: Tuple[Bytes, ...] + """ + RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness + proofs. This may trend toward empty EIP-7709. + """ + + +@dataclass +class ExecutionWitnessBuilder: + """ + Mutable accumulator for execution witness data during block execution. + """ + + state: List[Bytes] = field(default_factory=list) + codes: List[Bytes] = field(default_factory=list) + headers: List[Header] = field(default_factory=list) + + +def build_execution_witness( + builder: ExecutionWitnessBuilder, +) -> ExecutionWitness: + """ + Build the execution witness from the accumulated builder data. + + Sort state and codes lexicographically, headers by block number + ascending. + """ + return ExecutionWitness( + state=tuple(sorted(builder.state)), + codes=tuple(sorted(builder.codes)), + headers=tuple( + rlp.encode(h) + for h in sorted(builder.headers, key=lambda h: h.number) + ), + ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 5d6b2fbfb72..a806e6a580e 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -26,6 +26,7 @@ from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Authorization, VersionedHash from ..state_tracker import BlockState, TransactionState +from ..stateless_types import ExecutionWitness, ExecutionWitnessBuilder from ..transactions import LegacyTransaction from ..trie import Trie @@ -50,6 +51,7 @@ class BlockEnvironment: excess_blob_gas: U64 parent_beacon_block_root: Hash32 block_access_list_builder: BlockAccessListBuilder + execution_witness: ExecutionWitnessBuilder @dataclass @@ -78,6 +80,8 @@ class BlockOutput: Hash of all the requests in the block. block_access_list: `BlockAccessList` The block access list for the block. + execution_witness : `ExecutionWitness` + Execution witness data for stateless validation. """ block_gas_used: Uint = Uint(0) @@ -95,6 +99,7 @@ class BlockOutput: blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) block_access_list: BlockAccessList = field(default_factory=list) + execution_witness: Optional[ExecutionWitness] = None @dataclass diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 7ca898371c0..54ac7f03f78 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -140,6 +140,20 @@ def has_hash_block_access_list(self) -> bool: return False return hasattr(module, "hash_block_access_list") + @property + def has_execution_witness(self) -> bool: + """Check if the fork has an `ExecutionWitness` type.""" + try: + module = self._module("stateless_types") + except ModuleNotFoundError: + return False + return hasattr(module, "ExecutionWitness") + + @property + def build_execution_witness(self) -> Any: + """Build function of the fork.""" + return self._module("stateless_types").build_execution_witness + @property def signing_hash_2930(self) -> Any: """signing_hash_2930 function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 59a5a2f77c0..a92c4f87555 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -23,6 +23,7 @@ BlockAccessIndex, BlockAccessListBuilder, ) +from ethereum.forks.amsterdam.stateless_types import ExecutionWitnessBuilder from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork from ..loaders.fixture_loader import Load @@ -327,6 +328,9 @@ def block_environment(self) -> Any: BlockAccessListBuilder() ) + if self.fork.has_execution_witness: + kw_arguments["execution_witness"] = ExecutionWitnessBuilder() + return block_environment(**kw_arguments) def backup_state(self) -> None: @@ -465,6 +469,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_env.block_access_list_builder, block_env.state ) + if self.fork.has_execution_witness: + block_output.execution_witness = self.fork.build_execution_witness( + block_env.execution_witness + ) + def run_blockchain_test(self) -> None: """ Apply a block on the pre-state. Also includes system operations. diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 3cbe578a937..ffbaa6ad6d9 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -277,6 +277,7 @@ class Result: block_exception: Optional[str] = None block_access_list: Optional[Any] = None block_access_list_hash: Optional[Hash32] = None + execution_witness: Optional[Any] = None def get_receipts_from_output( self, @@ -363,6 +364,9 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) + if hasattr(block_output, "execution_witness"): + self.execution_witness = block_output.execution_witness + @staticmethod def _block_access_list_to_json(account_changes: Any) -> Any: """ @@ -520,4 +524,12 @@ def to_json(self) -> Any: self.block_access_list_hash ) + if self.execution_witness is not None: + ew = self.execution_witness + data["executionWitness"] = { + "state": ["0x" + s.hex() for s in ew.state], + "codes": ["0x" + c.hex() for c in ew.codes], + "headers": ["0x" + h.hex() for h in ew.headers], + } + return data From d73af60c86c2258725aa3497a6caa400bcfe8574 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 11:01:50 -0300 Subject: [PATCH 005/265] feat(zkevm): Add IncrementalMPT (#2241) * add IncrementalMPT Signed-off-by: jsign * only mark as _dirty if there were sideeffects Signed-off-by: jsign * lints Signed-off-by: jsign * fix Signed-off-by: jsign * rebase and lints Signed-off-by: Ignacio Hagopian --------- Signed-off-by: jsign Signed-off-by: Ignacio Hagopian --- .../forks/amsterdam/incremental_mpt.py | 818 ++++++++++++++++++ src/ethereum/forks/amsterdam/trie.py | 52 +- 2 files changed, 860 insertions(+), 10 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/incremental_mpt.py diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py new file mode 100644 index 00000000000..bfc7b2be242 --- /dev/null +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -0,0 +1,818 @@ +""" +Incremental Merkle Patricia Trie. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Provide a mutable MPT that supports incremental updates and +witness tracking. The tree structure is updated in-place rather +than rebuilt from scratch on each root calculation. +""" + +from dataclasses import dataclass, field +from typing import ( + Callable, + Dict, + Generic, + List, + Mapping, + MutableMapping, + Optional, + Tuple, + Union, +) + +from ethereum_rlp import Extended, rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import keccak256 +from ethereum.state import Account, Address, Root + +from .trie import ( + EMPTY_TRIE_ROOT, + K, + V, + _prepare_data, + bytes_to_nibble_list, + common_prefix_length, + encode_node, + nibble_list_to_compact, +) + + +@dataclass +class MutableLeafNode: + """Mutable leaf node in the Merkle Trie for in-place updates.""" + + rest_of_key: Bytes + value: Bytes + _hash: Optional[Bytes] = None + _rlp: Optional[Bytes] = None + _dirty: bool = False + + +@dataclass +class MutableExtensionNode: + """Mutable extension node in the Merkle Trie for in-place updates.""" + + key_segment: Bytes + child: "MutableNode" + _hash: Optional[Bytes] = None + _rlp: Optional[Bytes] = None + _dirty: bool = False + + +@dataclass +class MutableBranchNode: + """Mutable branch node in the Merkle Trie for in-place updates.""" + + children: List[Optional["MutableNode"]] + value: Bytes + _hash: Optional[Bytes] = None + _rlp: Optional[Bytes] = None + _dirty: bool = False + + +MutableNode = Union[ + MutableLeafNode, MutableExtensionNode, MutableBranchNode, None +] + + +@dataclass +class Witness: + """Track nodes accessed during trie operations for witness generation.""" + + accessed_nodes: Dict[Bytes, Bytes] = field(default_factory=dict) + + +@dataclass +class IncrementalMPT(Generic[K, V]): + """ + An MPT that supports incremental updates and witness tracking. + + Maintain an actual tree structure that can be updated in-place, + rather than rebuilding the entire tree on each root calculation. + """ + + secured: bool + default: V + root_node: MutableNode = None + witness: Witness = field(default_factory=Witness) + _data: Dict[K, V] = field(default_factory=dict) + + +def _build_mutable_tree( + obj: Mapping[Bytes, Bytes], level: Uint +) -> MutableNode: + """ + Build a mutable tree structure from a prepared key-value mapping. + + Similar to ``patricialize()`` but create mutable nodes for + in-place updates. + + Parameters + ---------- + obj : + Underlying trie key-value pairs, with keys in + nibble-list format. + level : + Current trie level. + + Returns + ------- + node : `MutableNode` + Root node of the mutable tree. + + """ + if len(obj) == 0: + return None + + arbitrary_key = next(iter(obj)) + + if len(obj) == 1: + return MutableLeafNode( + rest_of_key=arbitrary_key[level:], + value=obj[arbitrary_key], + ) + + substring = arbitrary_key[level:] + prefix_length = len(substring) + for key in obj: + prefix_length = min( + prefix_length, + common_prefix_length(substring, key[level:]), + ) + if prefix_length == 0: + break + + if prefix_length > 0: + prefix = arbitrary_key[int(level) : int(level) + prefix_length] + child = _build_mutable_tree(obj, level + Uint(prefix_length)) + return MutableExtensionNode(key_segment=prefix, child=child) + + branches: List[MutableMapping[Bytes, Bytes]] = [{} for _ in range(16)] + value = b"" + + for key in obj: + if len(key) == level: + value = obj[key] + else: + branches[key[level]][key] = obj[key] + + children: List[Optional[MutableNode]] = [ + _build_mutable_tree(branches[k], level + Uint(1)) for k in range(16) + ] + + return MutableBranchNode(children=children, value=value) + + +def build_mpt( + data: Mapping[K, V], + secured: bool, + default: V, + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> IncrementalMPT[K, V]: + """ + Build an IncrementalMPT from key-value data. + + Call with the pre-execution state to create a mutable tree + structure that can be updated in-place during execution. + + Parameters + ---------- + data : + The source key-value data to build from. + secured : + Whether to hash keys before insertion. + default : + Default value for missing keys. + get_storage_root : + Function to get the storage root of an account. + + Returns + ------- + mpt : `IncrementalMPT[K, V]` + An incremental MPT with the same data. + + """ + prepared = _prepare_data(data, secured, get_storage_root) + root_node = _build_mutable_tree(prepared, Uint(0)) + + return IncrementalMPT( + secured=secured, + default=default, + root_node=root_node, + _data=dict(data), + ) + + +def _invalidate_hash(node: MutableNode) -> None: + """Invalidate the cached hash of a node.""" + if node is not None: + node._hash = None + node._rlp = None + + +def _record_witness( + witness: Witness, + node: MutableNode, +) -> None: + """Record a node access in the witness.""" + if node is None: + return + + if node._dirty: + return + + node_hash, node_rlp = _compute_node_hash_and_rlp(node) + if node_hash is not None and node_hash not in witness.accessed_nodes: + witness.accessed_nodes[node_hash] = node_rlp + + +def _encode_mutable_node(node: MutableNode) -> Extended: + """ + Encode a mutable node to its RLP form. + + Similar to ``encode_internal_node`` but for mutable nodes. + """ + if node is None: + return b"" + elif isinstance(node, MutableLeafNode): + return ( + nibble_list_to_compact(node.rest_of_key, True), + node.value, + ) + elif isinstance(node, MutableExtensionNode): + child_encoded = _encode_mutable_node_to_extended(node.child) + return ( + nibble_list_to_compact(node.key_segment, False), + child_encoded, + ) + elif isinstance(node, MutableBranchNode): + children_encoded = [ + _encode_mutable_node_to_extended(child) for child in node.children + ] + return children_encoded + [node.value] + else: + raise AssertionError(f"Invalid mutable node type {type(node)}!") + + +def _encode_mutable_node_to_extended( + node: MutableNode, +) -> Extended: + """ + Encode a mutable node for embedding in parent. + + Return the hash if RLP >= 32 bytes, otherwise return + unencoded form. + """ + if node is None: + return b"" + + unencoded = _encode_mutable_node(node) + encoded = rlp.encode(unencoded) + + if len(encoded) < 32: + return unencoded + else: + return keccak256(encoded) + + +def _compute_node_hash_and_rlp( + node: MutableNode, +) -> Tuple[Optional[Bytes], Bytes]: + """ + Compute the hash and RLP encoding of a node. + + Return (hash, rlp) where hash may be None for small nodes. + """ + if node is None: + return None, b"" + + if node._rlp is not None: + if node._hash is not None: + return node._hash, node._rlp + elif len(node._rlp) >= 32: + return keccak256(node._rlp), node._rlp + + unencoded = _encode_mutable_node(node) + encoded = rlp.encode(unencoded) + + node._rlp = encoded + + if len(encoded) >= 32: + node._hash = keccak256(encoded) + return node._hash, encoded + else: + return None, encoded + + +def mpt_get(mpt: IncrementalMPT[K, V], key: K) -> V: + """ + Get a value from the incremental MPT. + + Traverse the tree and record accessed nodes in the witness + for execution witness generation. + + Parameters + ---------- + mpt : + The incremental MPT to get from. + key : + Key to lookup. + + Returns + ------- + value : `V` + Value at the key, or the default value if not found. + + """ + value = mpt._data.get(key, mpt.default) + + if mpt.secured: + nibble_key = bytes_to_nibble_list(keccak256(key)) + else: + nibble_key = bytes_to_nibble_list(key) + + _mpt_traverse_for_witness(mpt, mpt.root_node, nibble_key, Uint(0)) + + return value + + +def _mpt_traverse_for_witness( + mpt: IncrementalMPT, + node: MutableNode, + key: Bytes, + level: Uint, +) -> None: + """Traverse the tree recording nodes in the witness.""" + if node is None: + return + + _record_witness(mpt.witness, node) + + if isinstance(node, MutableLeafNode): + pass + elif isinstance(node, MutableExtensionNode): + segment_len = len(node.key_segment) + lvl = int(level) + if key[lvl : lvl + segment_len] == node.key_segment: + _mpt_traverse_for_witness( + mpt, + node.child, + key, + Uint(lvl + segment_len), + ) + elif isinstance(node, MutableBranchNode): + lvl = int(level) + if lvl < len(key): + child_idx = key[lvl] + _mpt_traverse_for_witness( + mpt, + node.children[child_idx], + key, + Uint(lvl + 1), + ) + + +def mpt_set( + mpt: IncrementalMPT[K, V], + key: K, + value: V, + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> None: + """ + Set a value in the incremental MPT. + + Update the tree in-place and invalidate cached hashes along + the path. + + Parameters + ---------- + mpt : + The incremental MPT to update. + key : + Key to set. + value : + Value to set at the key. + get_storage_root : + Function to get storage root (for Account values). + + """ + if value == mpt.default: + if key in mpt._data: + del mpt._data[key] + else: + mpt._data[key] = value + + if mpt.secured: + nibble_key = bytes_to_nibble_list(keccak256(key)) + else: + nibble_key = bytes_to_nibble_list(key) + + if value == mpt.default: + encoded_value = b"" + elif isinstance(value, Account): + assert get_storage_root is not None + address = Address(key) + encoded_value = encode_node(value, get_storage_root(address)) + else: + encoded_value = encode_node(value) + + if encoded_value == b"": + mpt.root_node = _mpt_delete_node( + mpt, mpt.root_node, nibble_key, Uint(0) + ) + else: + mpt.root_node = _mpt_insert_node( + mpt, mpt.root_node, nibble_key, encoded_value, Uint(0) + ) + + +def _mpt_insert_node( + mpt: IncrementalMPT, + node: MutableNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """ + Insert or update a value in the mutable tree. + + Return the new/updated node for this position. + """ + if node is None: + return MutableLeafNode( + rest_of_key=key[level:], value=value, _dirty=True + ) + + _invalidate_hash(node) + + if isinstance(node, MutableLeafNode): + return _insert_into_leaf(mpt, node, key, value, level) + elif isinstance(node, MutableExtensionNode): + return _insert_into_extension(mpt, node, key, value, level) + elif isinstance(node, MutableBranchNode): + return _insert_into_branch(mpt, node, key, value, level) + else: + raise AssertionError(f"Invalid node type {type(node)}") + + +def _insert_into_leaf( + _mpt: IncrementalMPT, + node: MutableLeafNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """Handle insertion when current node is a leaf.""" + existing_key = node.rest_of_key + remaining_key = key[level:] + + if existing_key == remaining_key: + node.value = value + node._dirty = True + return node + + prefix_len = common_prefix_length(existing_key, remaining_key) + + if prefix_len > 0: + branch = _create_branch_from_two_leaves( + existing_key[prefix_len:], + node.value, + remaining_key[prefix_len:], + value, + ) + return MutableExtensionNode( + key_segment=existing_key[:prefix_len], + child=branch, + _dirty=True, + ) + else: + return _create_branch_from_two_leaves( + existing_key, node.value, remaining_key, value + ) + + +def _create_branch_from_two_leaves( + key1: Bytes, + value1: Bytes, + key2: Bytes, + value2: Bytes, +) -> MutableBranchNode: + """Create a branch node from two key-value pairs.""" + children: List[Optional[MutableNode]] = [None] * 16 + branch_value = b"" + + if len(key1) == 0: + branch_value = value1 + else: + idx1 = key1[0] + children[idx1] = MutableLeafNode( + rest_of_key=key1[1:], + value=value1, + _dirty=True, + ) + + if len(key2) == 0: + branch_value = value2 + else: + idx2 = key2[0] + children[idx2] = MutableLeafNode( + rest_of_key=key2[1:], + value=value2, + _dirty=True, + ) + + return MutableBranchNode( + children=children, + value=branch_value, + _dirty=True, + ) + + +def _insert_into_extension( + mpt: IncrementalMPT, + node: MutableExtensionNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """Handle insertion when current node is an extension.""" + remaining_key = key[level:] + segment = node.key_segment + prefix_len = common_prefix_length(segment, remaining_key) + + if prefix_len == len(segment): + node.child = _mpt_insert_node( + mpt, + node.child, + key, + value, + level + Uint(prefix_len), + ) + node._dirty = True + return node + + if prefix_len > 0: + new_child = _split_extension(node, remaining_key, value, prefix_len) + return MutableExtensionNode( + key_segment=segment[:prefix_len], + child=new_child, + _dirty=True, + ) + else: + return _split_extension(node, remaining_key, value, 0) + + +def _split_extension( + node: MutableExtensionNode, + remaining_key: Bytes, + value: Bytes, + prefix_len: int, +) -> MutableNode: + """Split an extension node when keys diverge.""" + segment = node.key_segment + children: List[Optional[MutableNode]] = [None] * 16 + branch_value = b"" + + segment_after_prefix = segment[prefix_len:] + if len(segment_after_prefix) == 1: + idx = segment_after_prefix[0] + children[idx] = node.child + elif len(segment_after_prefix) > 1: + idx = segment_after_prefix[0] + children[idx] = MutableExtensionNode( + key_segment=segment_after_prefix[1:], + child=node.child, + _dirty=True, + ) + + key_after_prefix = remaining_key[prefix_len:] + if len(key_after_prefix) == 0: + branch_value = value + else: + idx = key_after_prefix[0] + if children[idx] is None: + children[idx] = MutableLeafNode( + rest_of_key=key_after_prefix[1:], + value=value, + _dirty=True, + ) + else: + raise AssertionError("Unexpected collision during split") + + return MutableBranchNode( + children=children, + value=branch_value, + _dirty=True, + ) + + +def _insert_into_branch( + mpt: IncrementalMPT, + node: MutableBranchNode, + key: Bytes, + value: Bytes, + level: Uint, +) -> MutableNode: + """Handle insertion when current node is a branch.""" + remaining_key = key[level:] + + if len(remaining_key) == 0: + node.value = value + node._dirty = True + return node + + child_idx = remaining_key[0] + node.children[child_idx] = _mpt_insert_node( + mpt, + node.children[child_idx], + key, + value, + level + Uint(1), + ) + node._dirty = True + return node + + +def _mpt_delete_node( + mpt: IncrementalMPT, + node: MutableNode, + key: Bytes, + level: Uint, +) -> MutableNode: + """ + Delete a key from the mutable tree. + + Return the updated node (may be different type or None). + """ + if node is None: + return None + + _invalidate_hash(node) + + if isinstance(node, MutableLeafNode): + if node.rest_of_key == key[level:]: + return None + return node + elif isinstance(node, MutableExtensionNode): + return _delete_from_extension(mpt, node, key, level) + elif isinstance(node, MutableBranchNode): + return _delete_from_branch(mpt, node, key, level) + else: + raise AssertionError(f"Invalid node type {type(node)}") + + +def _delete_from_extension( + mpt: IncrementalMPT, + node: MutableExtensionNode, + key: Bytes, + level: Uint, +) -> MutableNode: + """Handle deletion when current node is an extension.""" + segment = node.key_segment + remaining_key = key[level:] + prefix_len = common_prefix_length(segment, remaining_key) + + if prefix_len < len(segment): + return node + + old_child = node.child + new_child = _mpt_delete_node( + mpt, old_child, key, level + Uint(len(segment)) + ) + + if new_child is None: + return None + + if isinstance(new_child, MutableExtensionNode): + return MutableExtensionNode( + key_segment=segment + new_child.key_segment, + child=new_child.child, + _dirty=True, + ) + elif isinstance(new_child, MutableLeafNode): + return MutableLeafNode( + rest_of_key=segment + new_child.rest_of_key, + value=new_child.value, + _dirty=True, + ) + + child_changed = new_child is not old_child or ( + new_child is not None and new_child._dirty + ) + if not child_changed: + return node + + node.child = new_child + node._dirty = True + return node + + +def _delete_from_branch( + mpt: IncrementalMPT, + node: MutableBranchNode, + key: Bytes, + level: Uint, +) -> MutableNode: + """Handle deletion when current node is a branch.""" + remaining_key = key[level:] + + if len(remaining_key) == 0: + if node.value == b"": + return node + node.value = b"" + else: + child_idx = remaining_key[0] + old_child = node.children[child_idx] + new_child = _mpt_delete_node( + mpt, + old_child, + key, + level + Uint(1), + ) + child_changed = new_child is not old_child or ( + new_child is not None and new_child._dirty + ) + if not child_changed: + return node + node.children[child_idx] = new_child + + node._dirty = True + return _collapse_branch(mpt, node) + + +def _collapse_branch( + mpt: IncrementalMPT, node: MutableBranchNode +) -> MutableNode: + """Collapse a branch node if it has only one child.""" + non_empty = [(i, c) for i, c in enumerate(node.children) if c is not None] + + assert len(non_empty) > 0 or node.value != b"" + + if len(non_empty) == 1 and node.value == b"": + idx, child = non_empty[0] + _record_witness(mpt.witness, child) + nibble = Bytes([idx]) + + if isinstance(child, MutableLeafNode): + return MutableLeafNode( + rest_of_key=nibble + child.rest_of_key, + value=child.value, + _dirty=True, + ) + elif isinstance(child, MutableExtensionNode): + return MutableExtensionNode( + key_segment=nibble + child.key_segment, + child=child.child, + _dirty=True, + ) + else: + return MutableExtensionNode( + key_segment=nibble, + child=child, + _dirty=True, + ) + + if len(non_empty) == 0 and node.value != b"": + return MutableLeafNode( + rest_of_key=b"", + value=node.value, + _dirty=True, + ) + + return node + + +def mpt_root(mpt: IncrementalMPT) -> Root: + """ + Compute the root hash of the incremental MPT. + + Use cached hashes where available for efficiency. + + Parameters + ---------- + mpt : + The incremental MPT. + + Returns + ------- + root : `Root` + The MPT root hash. + + """ + if mpt.root_node is None: + return EMPTY_TRIE_ROOT + + root_encoded = _encode_mutable_node_to_extended(mpt.root_node) + + if isinstance(root_encoded, Bytes): + return Root(root_encoded) + else: + return keccak256(rlp.encode(root_encoded)) diff --git a/src/ethereum/forks/amsterdam/trie.py b/src/ethereum/forks/amsterdam/trie.py index 0f89d295832..8f2b68037f4 100644 --- a/src/ethereum/forks/amsterdam/trie.py +++ b/src/ethereum/forks/amsterdam/trie.py @@ -313,21 +313,26 @@ def bytes_to_nibble_list(bytes_: Bytes) -> Bytes: return Bytes(nibble_list) -def _prepare_trie( - trie: Trie[K, V], +def _prepare_data( + data: Mapping[K, V], + secured: bool, get_storage_root: Optional[Callable[[Address], Root]] = None, ) -> Mapping[Bytes, Bytes]: """ - Prepares the trie for root calculation. Removes values that are empty, - hashes the keys (if `secured == True`) and encodes all the nodes. + Prepare data for trie root calculation. + + Remove values that are empty, hash the keys (if ``secured`` + is ``True``) and encode all the nodes. Parameters ---------- - trie : - The `Trie` to prepare. + data : + The key-value data to prepare. + secured : + Whether keys should be hashed. get_storage_root : - Function to get the storage root of an account. Needed to encode - `Account` objects. + Function to get the storage root of an account. Needed + to encode ``Account`` objects. Returns ------- @@ -337,7 +342,7 @@ def _prepare_trie( """ mapped: MutableMapping[Bytes, Bytes] = {} - for preimage, value in trie._data.items(): + for preimage, value in data.items(): if isinstance(value, Account): assert get_storage_root is not None address = Address(preimage) @@ -347,7 +352,7 @@ def _prepare_trie( if encoded_value == b"": raise AssertionError key: Bytes - if trie.secured: + if secured: # "secure" tries hash keys once before construction key = keccak256(preimage) else: @@ -357,6 +362,33 @@ def _prepare_trie( return mapped +def _prepare_trie( + trie: Trie[K, V], + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> Mapping[Bytes, Bytes]: + """ + Prepare the trie for root calculation. + + Remove values that are empty, hash the keys (if + ``secured == True``) and encode all the nodes. + + Parameters + ---------- + trie : + The ``Trie`` to prepare. + get_storage_root : + Function to get the storage root of an account. Needed + to encode ``Account`` objects. + + Returns + ------- + out : `Mapping[ethereum.base_types.Bytes, Node]` + Object with keys mapped to nibble-byte form. + + """ + return _prepare_data(trie._data, trie.secured, get_storage_root) + + def root( trie: Trie[K, V], get_storage_root: Optional[Callable[[Address], Root]] = None, From 97845ea78f6801a21720e1cbc9004a76687c51b7 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 20 Feb 2026 22:31:57 -0300 Subject: [PATCH 006/265] add ancestor header tracking Signed-off-by: jsign --- .../src/execution_testing/specs/blockchain.py | 4 ++ .../test_types/block_types.py | 1 + src/ethereum/forks/amsterdam/fork.py | 15 ++++++ src/ethereum/forks/amsterdam/state_tracker.py | 50 ++++++++++++++++++- src/ethereum/forks/amsterdam/vm/__init__.py | 1 + .../forks/amsterdam/vm/instructions/block.py | 5 ++ .../evm_tools/loaders/fork_loader.py | 19 +++++++ .../evm_tools/t8n/__init__.py | 14 ++++++ src/ethereum_spec_tools/evm_tools/t8n/env.py | 24 +++++++++ 9 files changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 9532d0b14db..6c9c78c6499 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -106,6 +106,7 @@ def environment_from_parent_header(parent: "FixtureHeader") -> "Environment": parent_gas_limit=parent.gas_limit, parent_ommers_hash=parent.ommers_hash, block_hashes={parent.number: parent.block_hash}, + block_headers={parent.number: parent.rlp}, ) @@ -124,7 +125,10 @@ def apply_new_parent( updated["parent_ommers_hash"] = new_parent.ommers_hash block_hashes = env.block_hashes.copy() block_hashes[new_parent.number] = new_parent.block_hash + block_headers = env.block_headers.copy() + block_headers[new_parent.number] = new_parent.rlp updated["block_hashes"] = block_hashes + updated["block_headers"] = block_headers return env.copy(**updated) diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index 2c73a558b98..3f5077a8189 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -137,6 +137,7 @@ def strip_computed_fields(cls, data: Any) -> Any: parent_beacon_block_root: Hash | None = Field(None) block_hashes: Dict[ZeroPaddedHexNumber, Hash] = Field(default_factory=dict) + block_headers: Dict[ZeroPaddedHexNumber, Bytes] = Field(default_factory=dict) ommers: List[Hash] = Field(default_factory=list) withdrawals: List[Withdrawal] | None = Field(None) extra_data: Bytes = Field(Bytes(b"\x00"), exclude=True) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 2cfd3c5cff3..72ec4059b2b 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -72,6 +72,7 @@ incorporate_tx_into_block, increment_nonce, set_account_balance, + get_witness_ancestors, ) from .stateless_types import ExecutionWitnessBuilder, build_execution_witness from .transactions import ( @@ -252,6 +253,9 @@ def state_transition(chain: BlockChain, block: Block) -> None: parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), execution_witness=ExecutionWitnessBuilder(), + block_headers=[ + rlp.encode(blk.header) for blk in chain.blocks + ], ) block_output = apply_body( @@ -796,6 +800,10 @@ def apply_body( target_address=HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash ) + track_ancestor_access( + block_env.state, + Uint(1), + ) for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) @@ -819,6 +827,13 @@ def apply_body( block_output.execution_witness = build_execution_witness( block_env.execution_witness ) + # TODO(zkevm): whenever https://github.com/ethereum/execution-specs/pull/2259 is merged, + # rebase on top of it and make the following code add the ancestor in the + # block_output.execution_witness + ancestor_headers = get_witness_ancestors( + block_env.block_headers, + block_env.state.oldest_ancestor_offset, + ) return block_output diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 1f75556162b..be03b310826 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -19,7 +19,7 @@ """ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify @@ -59,6 +59,7 @@ class BlockState: default_factory=dict ) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) + oldest_ancestor_offset: Optional[Uint] = None @dataclass @@ -775,3 +776,50 @@ def extract_block_diffs( block_state.storage_writes, block_state.code_writes, ) + +def get_witness_ancestors( + block_headers: List[Bytes], + oldest_ancestor_offset: Optional[Uint], +) -> List[Bytes]: + """ + Collect RLP-encoded ancestor headers from ``oldest_ancestor_offset`` + blocks back onward. + + Parameters + ---------- + block_headers : + RLP-encoded headers. + oldest_ancestor_offset : + Offset from the current block to the oldest ancestor accessed + during execution, or ``None`` if no ancestor was accessed. + + """ + if oldest_ancestor_offset is None: + return [] + return list(block_headers[-int(oldest_ancestor_offset) :]) + + +def track_ancestor_access( + block_state: BlockState, offset: Uint +) -> None: + """ + Record that an ancestor block was accessed. + + Update ``oldest_ancestor_offset`` if ``offset`` is further back (larger) + than the current value. Called by the BLOCKHASH opcode (when + returning a valid hash) and the EIP-2935 system contract call. + + Parameters + ---------- + block_state : + The block state. + offset : + Offset from the current block to the ancestor that was + accessed. + + """ + if ( + block_state.oldest_ancestor_offset is None + or offset > block_state.oldest_ancestor_offset + ): + block_state.oldest_ancestor_offset = offset diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index a806e6a580e..88eb885bdd9 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -52,6 +52,7 @@ class BlockEnvironment: parent_beacon_block_root: Hash32 block_access_list_builder: BlockAccessListBuilder execution_witness: ExecutionWitnessBuilder + block_headers: List[Bytes] @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/instructions/block.py b/src/ethereum/forks/amsterdam/vm/instructions/block.py index e563a2e96e8..074efdd1fcc 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/block.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/block.py @@ -13,6 +13,7 @@ from ethereum_types.numeric import U256, Uint +from ...state_tracker import track_ancestor_access from .. import Evm from ..gas import GAS_BASE, GAS_BLOCK_HASH, charge_gas from ..stack import pop, push @@ -57,6 +58,10 @@ def block_hash(evm: Evm) -> None: current_block_hash = evm.message.block_env.block_hashes[ -(current_block_number - block_number) ] + track_ancestor_access( + evm.message.block_env.state, + current_block_number - block_number, + ) push(evm.stack, U256.from_be_bytes(current_block_hash)) diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 54ac7f03f78..4b472e83bff 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -302,6 +302,25 @@ def has_block_state(self) -> bool: return False return hasattr(module, "BlockState") + @property + def has_track_ancestor_access(self) -> bool: + """Check if the fork has ancestor tracking.""" + try: + module = self._module("state_tracker") + except ModuleNotFoundError: + return False + return hasattr(module, "track_ancestor_access") + + @property + def track_ancestor_access(self) -> Any: + """track_ancestor_access function of the fork.""" + return self._module("state_tracker").track_ancestor_access + + @property + def get_witness_ancestors(self) -> Any: + """get_witness_ancestors function of the fork.""" + return self._module("state_tracker").get_witness_ancestors + @property def State(self) -> Any: """State class of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index a92c4f87555..584e80379c8 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -330,6 +330,10 @@ def block_environment(self) -> Any: if self.fork.has_execution_witness: kw_arguments["execution_witness"] = ExecutionWitnessBuilder() + if self.fork.has_track_ancestor_access: + kw_arguments["block_headers"] = ( + self.env.block_headers + ) return block_environment(**kw_arguments) @@ -412,6 +416,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: target_address=self.fork.HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash ) + if self.fork.has_track_ancestor_access: + self.fork.track_ancestor_access( + block_env.state, + Uint(1), + ) if self.fork.has_beacon_roots_address: self.fork.process_unchecked_system_transaction( @@ -473,6 +482,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_output.execution_witness = self.fork.build_execution_witness( block_env.execution_witness ) + if self.fork.has_track_ancestor_access: + ancestor_headers = self.fork.get_witness_ancestors( + block_env.block_headers, + block_env.state.oldest_ancestor_offset, + ) def run_blockchain_test(self) -> None: """ diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index f76c0caaf57..f19bb136db5 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -47,6 +47,7 @@ class Env: parent_gas_limit: Optional[Uint] parent_base_fee_per_gas: Optional[Uint] block_hashes: Optional[List[Any]] + block_headers: List[Any] parent_ommers_hash: Optional[Hash32] ommers: Any parent_beacon_block_root: Optional[Hash32] @@ -72,6 +73,7 @@ def __init__(self, t8n: "T8N", stdin: Optional[Dict] = None): self.read_base_fee_per_gas(data, t8n) self.read_randao(data, t8n) self.read_block_hashes(data) + self.read_block_headers(data) self.read_ommers(data, t8n) self.read_withdrawals(data, t8n) @@ -301,6 +303,28 @@ def read_block_hashes(self, data: Any) -> None: self.block_hashes = block_hashes + def read_block_headers(self, data: Any) -> None: + """ + Read RLP-encoded block headers as an ordered list without gaps. + """ + if "blockHeaders" not in data: + self.block_headers = [] + return + + block_headers = [ + hex_to_bytes(value) + for value in data["blockHeaders"] + ] + + expected_count = min(Uint(256), self.block_number) + if len(block_headers) != expected_count: + raise ValueError( + f"expected {expected_count} block headers," + f" got {len(block_headers)}" + ) + + self.block_headers = block_headers + def read_ommers(self, data: Any, t8n: "T8N") -> None: """ Read the ommers. The ommers data might not have all the details From b0007e8e467f64c71efa54264e92c0f53eb53cef Mon Sep 17 00:00:00 2001 From: jsign Date: Sat, 21 Feb 2026 22:39:24 -0300 Subject: [PATCH 007/265] lints Signed-off-by: jsign --- .../src/execution_testing/test_types/block_types.py | 4 +++- src/ethereum/forks/amsterdam/fork.py | 8 ++++---- src/ethereum_spec_tools/evm_tools/t8n/__init__.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index 3f5077a8189..a7ad164f653 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -137,7 +137,9 @@ def strip_computed_fields(cls, data: Any) -> Any: parent_beacon_block_root: Hash | None = Field(None) block_hashes: Dict[ZeroPaddedHexNumber, Hash] = Field(default_factory=dict) - block_headers: Dict[ZeroPaddedHexNumber, Bytes] = Field(default_factory=dict) + block_headers: Dict[ZeroPaddedHexNumber, Bytes] = Field( + default_factory=dict + ) ommers: List[Hash] = Field(default_factory=list) withdrawals: List[Withdrawal] | None = Field(None) extra_data: Bytes = Field(Bytes(b"\x00"), exclude=True) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 72ec4059b2b..75c53b48cd5 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -827,10 +827,10 @@ def apply_body( block_output.execution_witness = build_execution_witness( block_env.execution_witness ) - # TODO(zkevm): whenever https://github.com/ethereum/execution-specs/pull/2259 is merged, - # rebase on top of it and make the following code add the ancestor in the - # block_output.execution_witness - ancestor_headers = get_witness_ancestors( + # TODO(zkevm): whenever PR #2259 is merged, rebase on + # top of it and make the following code add the ancestor + # in the block_output.execution_witness + ancestor_headers = get_witness_ancestors( # noqa: F841 block_env.block_headers, block_env.state.oldest_ancestor_offset, ) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 584e80379c8..7011496995e 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -483,7 +483,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_env.execution_witness ) if self.fork.has_track_ancestor_access: - ancestor_headers = self.fork.get_witness_ancestors( + ancestor_headers = self.fork.get_witness_ancestors( # noqa: F841 block_env.block_headers, block_env.state.oldest_ancestor_offset, ) From 683f983755eb9785d3e4823f6eb7a49b8c1ce1da Mon Sep 17 00:00:00 2001 From: jsign Date: Sat, 21 Feb 2026 22:42:03 -0300 Subject: [PATCH 008/265] ruff fixes Signed-off-by: jsign --- src/ethereum/forks/amsterdam/fork.py | 4 +--- src/ethereum/forks/amsterdam/state_tracker.py | 4 +--- src/ethereum_spec_tools/evm_tools/t8n/__init__.py | 4 +--- src/ethereum_spec_tools/evm_tools/t8n/env.py | 5 +---- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 75c53b48cd5..5f6d8c07081 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -253,9 +253,7 @@ def state_transition(chain: BlockChain, block: Block) -> None: parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), execution_witness=ExecutionWitnessBuilder(), - block_headers=[ - rlp.encode(blk.header) for blk in chain.blocks - ], + block_headers=[rlp.encode(blk.header) for blk in chain.blocks], ) block_output = apply_body( diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index be03b310826..9ddee300c85 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -799,9 +799,7 @@ def get_witness_ancestors( return list(block_headers[-int(oldest_ancestor_offset) :]) -def track_ancestor_access( - block_state: BlockState, offset: Uint -) -> None: +def track_ancestor_access(block_state: BlockState, offset: Uint) -> None: """ Record that an ancestor block was accessed. diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 7011496995e..e25e485e41d 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -331,9 +331,7 @@ def block_environment(self) -> Any: if self.fork.has_execution_witness: kw_arguments["execution_witness"] = ExecutionWitnessBuilder() if self.fork.has_track_ancestor_access: - kw_arguments["block_headers"] = ( - self.env.block_headers - ) + kw_arguments["block_headers"] = self.env.block_headers return block_environment(**kw_arguments) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index f19bb136db5..3817d2516be 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -311,10 +311,7 @@ def read_block_headers(self, data: Any) -> None: self.block_headers = [] return - block_headers = [ - hex_to_bytes(value) - for value in data["blockHeaders"] - ] + block_headers = [hex_to_bytes(value) for value in data["blockHeaders"]] expected_count = min(Uint(256), self.block_number) if len(block_headers) != expected_count: From 7c7586313444281c2ea1ed86b863745497afe507 Mon Sep 17 00:00:00 2001 From: jsign Date: Sat, 21 Feb 2026 23:15:45 -0300 Subject: [PATCH 009/265] fixes Signed-off-by: jsign --- .../test_types/tests/test_types.py | 2 ++ src/ethereum_spec_tools/evm_tools/t8n/env.py | 31 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_types.py b/packages/testing/src/execution_testing/test_types/tests/test_types.py index 83ba6dc3011..393b1316caf 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_types.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_types.py @@ -475,6 +475,7 @@ def test_account_merge( "currentNumber": "0x01", "currentTimestamp": "0x03e8", "blockHashes": {}, + "blockHeaders": {}, "ommers": [], "parentUncleHash": ( "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" @@ -540,6 +541,7 @@ def test_account_merge( "0x01": "0x0000000000000000000000000000000000000000000000000000000000000002", # noqa: E501 "0x03": "0x0000000000000000000000000000000000000000000000000000000000000004", # noqa: E501 }, + "blockHeaders": {}, "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000004", # noqa: E501 "ommers": [], }, diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 3817d2516be..0cdf80a10f7 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -73,7 +73,7 @@ def __init__(self, t8n: "T8N", stdin: Optional[Dict] = None): self.read_base_fee_per_gas(data, t8n) self.read_randao(data, t8n) self.read_block_hashes(data) - self.read_block_headers(data) + self.read_block_headers(data, t8n) self.read_ommers(data, t8n) self.read_withdrawals(data, t8n) @@ -303,22 +303,31 @@ def read_block_hashes(self, data: Any) -> None: self.block_hashes = block_hashes - def read_block_headers(self, data: Any) -> None: + def read_block_headers(self, data: Any, t8n: "T8N") -> None: """ Read RLP-encoded block headers as an ordered list without gaps. """ - if "blockHeaders" not in data: - self.block_headers = [] + self.block_headers = [] + + if not t8n.fork.has_track_ancestor_access: return - block_headers = [hex_to_bytes(value) for value in data["blockHeaders"]] + if not data.get("blockHeaders"): + return - expected_count = min(Uint(256), self.block_number) - if len(block_headers) != expected_count: - raise ValueError( - f"expected {expected_count} block headers," - f" got {len(block_headers)}" - ) + raw = data["blockHeaders"] + + # blockHeaders is a dict mapping hex block number to hex RLP. + clean: Dict[int, bytes] = {} + for key, value in raw.items(): + clean[int(key, 16)] = hex_to_bytes(value) + + max_count = min(Uint(256), self.block_number) + block_headers: List[Any] = [] + for number in range(self.block_number - max_count, self.block_number): + if number not in clean: + raise ValueError(f"missing block header for block {number}") + block_headers.append(clean[number]) self.block_headers = block_headers From 4f7eb8c47b5005d05c0ed7ee898f771a15a7faea Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 10:31:01 -0300 Subject: [PATCH 010/265] integrate into execution witness --- src/ethereum/forks/amsterdam/fork.py | 12 +----- .../forks/amsterdam/stateless_types.py | 37 ++++++++++++++++--- .../evm_tools/loaders/fork_loader.py | 4 -- .../evm_tools/t8n/__init__.py | 7 +--- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 5f6d8c07081..6839db3e240 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -72,7 +72,6 @@ incorporate_tx_into_block, increment_nonce, set_account_balance, - get_witness_ancestors, ) from .stateless_types import ExecutionWitnessBuilder, build_execution_witness from .transactions import ( @@ -823,15 +822,8 @@ def apply_body( ) block_output.execution_witness = build_execution_witness( - block_env.execution_witness - ) - # TODO(zkevm): whenever PR #2259 is merged, rebase on - # top of it and make the following code add the ancestor - # in the block_output.execution_witness - ancestor_headers = get_witness_ancestors( # noqa: F841 - block_env.block_headers, - block_env.state.oldest_ancestor_offset, - ) + block_env.execution_witness, block_env.state, block_env.block_headers + ) return block_output diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index b9ad03f7ac4..60df7e1d986 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -3,11 +3,14 @@ """ from dataclasses import dataclass, field -from typing import List, Tuple +from typing import List, Optional, Tuple from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import Uint + +from ethereum.forks.amsterdam.state_tracker import BlockState from .blocks import Header @@ -50,6 +53,8 @@ class ExecutionWitnessBuilder: def build_execution_witness( builder: ExecutionWitnessBuilder, + block_state : BlockState, + block_headers: List[Bytes], ) -> ExecutionWitness: """ Build the execution witness from the accumulated builder data. @@ -57,11 +62,33 @@ def build_execution_witness( Sort state and codes lexicographically, headers by block number ascending. """ + ancestor_headers = get_witness_ancestors( + block_headers, + block_state.oldest_ancestor_offset, + ) return ExecutionWitness( state=tuple(sorted(builder.state)), codes=tuple(sorted(builder.codes)), - headers=tuple( - rlp.encode(h) - for h in sorted(builder.headers, key=lambda h: h.number) - ), + headers=ancestor_headers, ) + +def get_witness_ancestors( + block_headers: List[Bytes], + oldest_ancestor_offset: Optional[Uint], +) -> List[Bytes]: + """ + Collect RLP-encoded ancestor headers from ``oldest_ancestor_offset`` + blocks back onward. + + Parameters + ---------- + block_headers : + RLP-encoded headers. + oldest_ancestor_offset : + Offset from the current block to the oldest ancestor accessed + during execution, or ``None`` if no ancestor was accessed. + + """ + if oldest_ancestor_offset is None: + return [] + return list(block_headers[-int(oldest_ancestor_offset) :]) \ No newline at end of file diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 4b472e83bff..c35c905e55e 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -316,10 +316,6 @@ def track_ancestor_access(self) -> Any: """track_ancestor_access function of the fork.""" return self._module("state_tracker").track_ancestor_access - @property - def get_witness_ancestors(self) -> Any: - """get_witness_ancestors function of the fork.""" - return self._module("state_tracker").get_witness_ancestors @property def State(self) -> Any: diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index e25e485e41d..7368acc5e14 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -478,12 +478,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.has_execution_witness: block_output.execution_witness = self.fork.build_execution_witness( - block_env.execution_witness - ) - if self.fork.has_track_ancestor_access: - ancestor_headers = self.fork.get_witness_ancestors( # noqa: F841 + block_env.execution_witness, + block_env.state, block_env.block_headers, - block_env.state.oldest_ancestor_offset, ) def run_blockchain_test(self) -> None: From 153349fac4b4f31faced6649bf8ff2acc01e6467 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 10:33:40 -0300 Subject: [PATCH 011/265] lints --- src/ethereum/forks/amsterdam/fork.py | 2 +- src/ethereum/forks/amsterdam/state_tracker.py | 2 +- src/ethereum/forks/amsterdam/stateless_types.py | 13 ++++++------- .../evm_tools/loaders/fork_loader.py | 1 - 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 6839db3e240..28c400fff48 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -823,7 +823,7 @@ def apply_body( block_output.execution_witness = build_execution_witness( block_env.execution_witness, block_env.state, block_env.block_headers - ) + ) return block_output diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 9ddee300c85..c2b52fb8501 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -19,7 +19,7 @@ """ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index 60df7e1d986..2f8e8028b3e 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -5,14 +5,12 @@ from dataclasses import dataclass, field from typing import List, Optional, Tuple -from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import Uint -from ethereum.forks.amsterdam.state_tracker import BlockState - from .blocks import Header +from .state_tracker import BlockState @slotted_freezable @@ -53,7 +51,7 @@ class ExecutionWitnessBuilder: def build_execution_witness( builder: ExecutionWitnessBuilder, - block_state : BlockState, + block_state: BlockState, block_headers: List[Bytes], ) -> ExecutionWitness: """ @@ -62,16 +60,17 @@ def build_execution_witness( Sort state and codes lexicographically, headers by block number ascending. """ - ancestor_headers = get_witness_ancestors( + ancestor_headers = get_witness_ancestors( block_headers, block_state.oldest_ancestor_offset, ) return ExecutionWitness( state=tuple(sorted(builder.state)), codes=tuple(sorted(builder.codes)), - headers=ancestor_headers, + headers=tuple(ancestor_headers), ) + def get_witness_ancestors( block_headers: List[Bytes], oldest_ancestor_offset: Optional[Uint], @@ -91,4 +90,4 @@ def get_witness_ancestors( """ if oldest_ancestor_offset is None: return [] - return list(block_headers[-int(oldest_ancestor_offset) :]) \ No newline at end of file + return list(block_headers[-int(oldest_ancestor_offset) :]) diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index c35c905e55e..878018e757f 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -316,7 +316,6 @@ def track_ancestor_access(self) -> Any: """track_ancestor_access function of the fork.""" return self._module("state_tracker").track_ancestor_access - @property def State(self) -> Any: """State class of the fork.""" From 2f4d3c8615697be51edff4958adf6400d2e0b9ff Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 10:55:33 -0300 Subject: [PATCH 012/265] simplify block env and lints Signed-off-by: Ignacio Hagopian --- src/ethereum/forks/amsterdam/fork.py | 7 ++++--- src/ethereum/forks/amsterdam/stateless_types.py | 6 ++---- src/ethereum/forks/amsterdam/vm/__init__.py | 1 - src/ethereum_spec_tools/evm_tools/t8n/__init__.py | 7 +++---- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 28c400fff48..65481d93583 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -251,8 +251,9 @@ def state_transition(chain: BlockChain, block: Block) -> None: excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), - execution_witness=ExecutionWitnessBuilder(), - block_headers=[rlp.encode(blk.header) for blk in chain.blocks], + execution_witness=ExecutionWitnessBuilder( + blockchain_headers=[rlp.encode(blk.header) for blk in chain.blocks] + ), ) block_output = apply_body( @@ -822,7 +823,7 @@ def apply_body( ) block_output.execution_witness = build_execution_witness( - block_env.execution_witness, block_env.state, block_env.block_headers + block_env.execution_witness, block_env.state ) return block_output diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index 2f8e8028b3e..e53d300a6b1 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -9,7 +9,6 @@ from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import Uint -from .blocks import Header from .state_tracker import BlockState @@ -44,15 +43,14 @@ class ExecutionWitnessBuilder: Mutable accumulator for execution witness data during block execution. """ + blockchain_headers: List[Bytes] = field(default_factory=list) state: List[Bytes] = field(default_factory=list) codes: List[Bytes] = field(default_factory=list) - headers: List[Header] = field(default_factory=list) def build_execution_witness( builder: ExecutionWitnessBuilder, block_state: BlockState, - block_headers: List[Bytes], ) -> ExecutionWitness: """ Build the execution witness from the accumulated builder data. @@ -61,7 +59,7 @@ def build_execution_witness( ascending. """ ancestor_headers = get_witness_ancestors( - block_headers, + builder.blockchain_headers, block_state.oldest_ancestor_offset, ) return ExecutionWitness( diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 88eb885bdd9..a806e6a580e 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -52,7 +52,6 @@ class BlockEnvironment: parent_beacon_block_root: Hash32 block_access_list_builder: BlockAccessListBuilder execution_witness: ExecutionWitnessBuilder - block_headers: List[Bytes] @dataclass diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 7368acc5e14..772446f5b44 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -329,9 +329,9 @@ def block_environment(self) -> Any: ) if self.fork.has_execution_witness: - kw_arguments["execution_witness"] = ExecutionWitnessBuilder() - if self.fork.has_track_ancestor_access: - kw_arguments["block_headers"] = self.env.block_headers + kw_arguments["execution_witness"] = ExecutionWitnessBuilder( + blockchain_headers=self.env.block_headers + ) return block_environment(**kw_arguments) @@ -480,7 +480,6 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_output.execution_witness = self.fork.build_execution_witness( block_env.execution_witness, block_env.state, - block_env.block_headers, ) def run_blockchain_test(self) -> None: From fb44a5793291b490ec26e54e5c03340b17fa76df Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 11:08:51 -0300 Subject: [PATCH 013/265] nit improvements Signed-off-by: Ignacio Hagopian --- src/ethereum_spec_tools/evm_tools/t8n/env.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 0cdf80a10f7..63cf2b5d27c 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -312,22 +312,22 @@ def read_block_headers(self, data: Any, t8n: "T8N") -> None: if not t8n.fork.has_track_ancestor_access: return - if not data.get("blockHeaders"): - return - - raw = data["blockHeaders"] - + assert data.get("blockHeaders"), ( + "blockHeaders is required for track ancestor access" + ) # blockHeaders is a dict mapping hex block number to hex RLP. - clean: Dict[int, bytes] = {} - for key, value in raw.items(): - clean[int(key, 16)] = hex_to_bytes(value) + dic_block_headers = data["blockHeaders"] + + headers_by_number: Dict[int, bytes] = {} + for key, value in dic_block_headers.items(): + headers_by_number[int(key, 16)] = hex_to_bytes(value) max_count = min(Uint(256), self.block_number) block_headers: List[Any] = [] for number in range(self.block_number - max_count, self.block_number): - if number not in clean: + if number not in headers_by_number: raise ValueError(f"missing block header for block {number}") - block_headers.append(clean[number]) + block_headers.append(headers_by_number[number]) self.block_headers = block_headers From 57785d12c9fbbe59375aa7e2a2adee5c42bf0bae Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 23 Feb 2026 12:03:13 -0300 Subject: [PATCH 014/265] use same policy check as read_block_hashes Signed-off-by: Ignacio Hagopian --- src/ethereum_spec_tools/evm_tools/t8n/env.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 63cf2b5d27c..8478e06a024 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -312,9 +312,8 @@ def read_block_headers(self, data: Any, t8n: "T8N") -> None: if not t8n.fork.has_track_ancestor_access: return - assert data.get("blockHeaders"), ( - "blockHeaders is required for track ancestor access" - ) + if not data.get("blockHeaders"): + return # blockHeaders is a dict mapping hex block number to hex RLP. dic_block_headers = data["blockHeaders"] From e6322c443bee01bec8b2ed96377e485927f4df39 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Feb 2026 15:21:21 -0300 Subject: [PATCH 015/265] rebase fixes and lints Signed-off-by: jsign --- src/ethereum/forks/amsterdam/fork.py | 1 + src/ethereum/forks/amsterdam/state_tracker.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 65481d93583..5c109e05662 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -72,6 +72,7 @@ incorporate_tx_into_block, increment_nonce, set_account_balance, + track_ancestor_access, ) from .stateless_types import ExecutionWitnessBuilder, build_execution_witness from .transactions import ( diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index c2b52fb8501..ed850bc7b19 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -19,7 +19,7 @@ """ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify @@ -777,6 +777,7 @@ def extract_block_diffs( block_state.code_writes, ) + def get_witness_ancestors( block_headers: List[Bytes], oldest_ancestor_offset: Optional[Uint], From 6823fab83bb74d22b76b1db3b59939914733738c Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Feb 2026 15:54:49 -0300 Subject: [PATCH 016/265] track code_reads --- src/ethereum/forks/amsterdam/state_tracker.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index ed850bc7b19..1290351d79d 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -45,8 +45,8 @@ class BlockState: Read chain: block writes -> pre_state. - ``account_reads`` and ``storage_reads`` accumulate across all - transactions for BAL generation. + ``account_reads``, ``storage_reads``, and ``code_reads`` accumulate + across all transactions for BAL generation. """ pre_state: PreState @@ -58,6 +58,7 @@ class BlockState: storage_writes: Dict[Address, Dict[Bytes32, U256]] = field( default_factory=dict ) + code_reads: Set[Hash32] = field(default_factory=set) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) oldest_ancestor_offset: Optional[Uint] = None @@ -69,9 +70,9 @@ class TransactionState: Read chain: tx writes -> block writes -> pre_state. - ``storage_reads`` and ``account_reads`` are shared references - that survive rollback (reads from failed calls still appear in the - Block Access List). + ``storage_reads``, ``account_reads``, and ``code_reads`` are shared + references that survive rollback (reads from failed calls still + appear in the Block Access List). """ parent: BlockState @@ -83,6 +84,7 @@ class TransactionState: storage_writes: Dict[Address, Dict[Bytes32, U256]] = field( default_factory=dict ) + code_reads: Set[Hash32] = field(default_factory=set) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( @@ -167,6 +169,7 @@ def get_code(tx_state: TransactionState, code_hash: Hash32) -> Bytes: """ if code_hash == EMPTY_CODE_HASH: return b"" + tx_state.code_reads.add(code_hash) if code_hash in tx_state.code_writes: return tx_state.code_writes[code_hash] if code_hash in tx_state.parent.code_writes: @@ -644,8 +647,8 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: Create a snapshot of the transaction state for rollback. Deep-copy writes and transient storage. The parent reference, - ``created_accounts``, ``storage_reads``, and ``account_reads`` - are shared (not rolled back). + ``created_accounts``, ``storage_reads``, ``account_reads``, and + ``code_reads`` are shared (not rolled back). Parameters ---------- @@ -665,6 +668,7 @@ def copy_tx_state(tx_state: TransactionState) -> TransactionState: addr: dict(slots) for addr, slots in tx_state.storage_writes.items() }, + code_reads=tx_state.code_reads, code_writes=dict(tx_state.code_writes), created_accounts=tx_state.created_accounts, transient_storage=dict(tx_state.transient_storage), @@ -725,6 +729,7 @@ def incorporate_tx_into_block( # Merge reads and touches into block-level sets block.storage_reads.update(tx_state.storage_reads) block.account_reads.update(tx_state.account_reads) + block.code_reads.update(tx_state.code_reads) # Merge cumulative writes for address, account in tx_state.account_writes.items(): @@ -744,6 +749,7 @@ def incorporate_tx_into_block( tx_state.transient_storage.clear() tx_state.storage_reads = set() tx_state.account_reads = set() + tx_state.code_reads = set() def extract_block_diffs( From aa5fa7c4ef6e44e0c3cf214b75ab27ae78596e59 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Feb 2026 15:54:59 -0300 Subject: [PATCH 017/265] wire code_reads in the pre-state into the execution witness --- .../forks/amsterdam/stateless_types.py | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index e53d300a6b1..e30b745879e 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -3,12 +3,15 @@ """ from dataclasses import dataclass, field -from typing import List, Optional, Tuple +from typing import List, Optional, Set, Tuple from ethereum_types.bytes import Bytes from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import Uint +from ethereum.crypto.hash import Hash32 +from ethereum.state import PreState + from .state_tracker import BlockState @@ -45,7 +48,6 @@ class ExecutionWitnessBuilder: blockchain_headers: List[Bytes] = field(default_factory=list) state: List[Bytes] = field(default_factory=list) - codes: List[Bytes] = field(default_factory=list) def build_execution_witness( @@ -62,13 +64,43 @@ def build_execution_witness( builder.blockchain_headers, block_state.oldest_ancestor_offset, ) + codes = get_witness_codes(block_state.code_reads, block_state.pre_state) + return ExecutionWitness( state=tuple(sorted(builder.state)), - codes=tuple(sorted(builder.codes)), + codes=tuple(codes), headers=tuple(ancestor_headers), ) +def get_witness_codes( + code_reads: Set[Hash32], + pre_state: PreState, +) -> List[Bytes]: + """ + Collect bytecodes from the pre-state for all code hashes read + during execution. + + Skip hashes that do not exist in the pre-state (e.g. code deployed + within the same block). + + Parameters + ---------- + code_reads : + Code hashes accessed during block execution. + pre_state : + The pre-execution state. + + """ + codes: List[Bytes] = [] + for code_hash in code_reads: + try: + codes.append(pre_state.get_code(code_hash)) + except KeyError: + pass + return sorted(codes) + + def get_witness_ancestors( block_headers: List[Bytes], oldest_ancestor_offset: Optional[Uint], From 88d7c82ae8cb773cc25f0891b9db99fa36c817e5 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Feb 2026 11:38:29 -0300 Subject: [PATCH 018/265] state tracker: fix code tracking and witness inclusion --- .../forks/amsterdam/block_access_lists.py | 6 ++++- src/ethereum/forks/amsterdam/fork.py | 8 ++++++- src/ethereum/forks/amsterdam/state_tracker.py | 23 ++++++++++++++----- .../forks/amsterdam/stateless_types.py | 23 ++++++++++++------- src/ethereum/forks/amsterdam/utils/message.py | 4 +++- .../forks/amsterdam/vm/eoa_delegation.py | 13 +++++++++-- .../amsterdam/vm/instructions/environment.py | 4 ++-- .../forks/amsterdam/vm/instructions/system.py | 8 +++---- .../forks/amsterdam/vm/interpreter.py | 1 + 9 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists.py b/src/ethereum/forks/amsterdam/block_access_lists.py index 08c18ce9656..19ecc43d114 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists.py +++ b/src/ethereum/forks/amsterdam/block_access_lists.py @@ -682,7 +682,11 @@ def update_builder_from_tx( post_account.code_hash if post_account else EMPTY_CODE_HASH ) if pre_code_hash != post_code_hash: - post_code = get_code(tx_state, post_code_hash) + post_code = get_code( + tx_state, + post_code_hash, + address, + ) add_code_change(builder, address, idx, post_code) # Compare storage writes against block cumulative state diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 5c109e05662..d795b1b04b9 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -573,7 +573,11 @@ def check_transaction( if Uint(sender_account.balance) < max_gas_fee + Uint(tx.value): raise InsufficientBalanceError("insufficient sender balance") - sender_code = get_code(tx_state, sender_account.code_hash) + sender_code = get_code( + tx_state, + sender_account.code_hash, + sender_address, + ) if sender_account.code_hash != EMPTY_CODE_HASH and not is_valid_delegation( sender_code ): @@ -660,6 +664,7 @@ def process_checked_system_transaction( system_contract_code = get_code( untracked_state, get_account(untracked_state, target_address).code_hash, + target_address, ) if len(system_contract_code) == 0: @@ -711,6 +716,7 @@ def process_unchecked_system_transaction( system_contract_code = get_code( system_tx_state, get_account(system_tx_state, target_address).code_hash, + target_address, ) tx_env = vm.TransactionEnvironment( diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 1290351d79d..0ddb17c2c92 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -38,6 +38,10 @@ from .block_access_lists import BlockAccessListBuilder +CodeRead = Tuple[Address, Hash32] +"""Code read keyed by account address and code hash.""" + + @dataclass class BlockState: """ @@ -45,8 +49,9 @@ class BlockState: Read chain: block writes -> pre_state. - ``account_reads``, ``storage_reads``, and ``code_reads`` accumulate - across all transactions for BAL generation. + ``account_reads`` and ``storage_reads`` accumulate across all transactions + for BAL generation. ``code_reads`` accumulates code accesses used for + execution witness generation. """ pre_state: PreState @@ -58,7 +63,7 @@ class BlockState: storage_writes: Dict[Address, Dict[Bytes32, U256]] = field( default_factory=dict ) - code_reads: Set[Hash32] = field(default_factory=set) + code_reads: Set[CodeRead] = field(default_factory=set) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) oldest_ancestor_offset: Optional[Uint] = None @@ -84,7 +89,7 @@ class TransactionState: storage_writes: Dict[Address, Dict[Bytes32, U256]] = field( default_factory=dict ) - code_reads: Set[Hash32] = field(default_factory=set) + code_reads: Set[CodeRead] = field(default_factory=set) code_writes: Dict[Hash32, Bytes] = field(default_factory=dict) created_accounts: Set[Address] = field(default_factory=set) transient_storage: Dict[Tuple[Address, Bytes32], U256] = field( @@ -148,7 +153,11 @@ def get_account(tx_state: TransactionState, address: Address) -> Account: return EMPTY_ACCOUNT -def get_code(tx_state: TransactionState, code_hash: Hash32) -> Bytes: +def get_code( + tx_state: TransactionState, + code_hash: Hash32, + address: Address, +) -> Bytes: """ Get the bytecode for a given code hash. @@ -160,6 +169,8 @@ def get_code(tx_state: TransactionState, code_hash: Hash32) -> Bytes: The transaction state. code_hash : Hash of the code to look up. + address : + Address whose code is being accessed. Returns ------- @@ -169,7 +180,7 @@ def get_code(tx_state: TransactionState, code_hash: Hash32) -> Bytes: """ if code_hash == EMPTY_CODE_HASH: return b"" - tx_state.code_reads.add(code_hash) + tx_state.code_reads.add((address, code_hash)) if code_hash in tx_state.code_writes: return tx_state.code_writes[code_hash] if code_hash in tx_state.parent.code_writes: diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index e30b745879e..56b097058df 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -10,7 +10,7 @@ from ethereum_types.numeric import Uint from ethereum.crypto.hash import Hash32 -from ethereum.state import PreState +from ethereum.state import Address, PreState from .state_tracker import BlockState @@ -74,26 +74,33 @@ def build_execution_witness( def get_witness_codes( - code_reads: Set[Hash32], + code_reads: Set[Tuple[Address, Hash32]], pre_state: PreState, ) -> List[Bytes]: """ - Collect bytecodes from the pre-state for all code hashes read - during execution. + Collect bytecodes from the pre-state for all code reads during execution. - Skip hashes that do not exist in the pre-state (e.g. code deployed - within the same block). + Include a code hash only when the same address already had that code in the + pre-state. This avoids accidentally including bytecode created during the + current block when the same hash already exists elsewhere. Parameters ---------- code_reads : - Code hashes accessed during block execution. + Code reads as ``(address, code_hash)`` during block execution. pre_state : The pre-execution state. """ + witness_code_hashes: Set[Hash32] = set() + for address, code_hash in code_reads: + pre_account = pre_state.get_account_optional(address) + if pre_account is None or pre_account.code_hash != code_hash: + continue + witness_code_hashes.add(code_hash) + codes: List[Bytes] = [] - for code_hash in code_reads: + for code_hash in witness_code_hashes: try: codes.append(pre_state.get_code(code_hash)) except KeyError: diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index d8fbf4f35a4..b65e0852ddc 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -64,7 +64,9 @@ def prepare_message( current_target = tx.to msg_data = tx.data code = get_code( - tx_env.state, get_account(tx_env.state, tx.to).code_hash + tx_env.state, + get_account(tx_env.state, tx.to).code_hash, + tx.to, ) code_address = tx.to else: diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 807c42c60d6..941fb206838 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -141,7 +141,11 @@ def calculate_delegation_cost( """ tx_state = evm.message.tx_env.state - code = get_code(tx_state, get_account(tx_state, address).code_hash) + code = get_code( + tx_state, + get_account(tx_state, address).code_hash, + address, + ) if not is_valid_delegation(code): return False, address, Uint(0) @@ -188,7 +192,11 @@ def set_delegation(message: Message) -> U256: message.accessed_addresses.add(authority) authority_account = get_account(tx_state, authority) - authority_code = get_code(tx_state, authority_account.code_hash) + authority_code = get_code( + tx_state, + authority_account.code_hash, + authority, + ) if authority_code and not is_valid_delegation(authority_code): continue @@ -214,6 +222,7 @@ def set_delegation(message: Message) -> U256: message.code = get_code( tx_state, get_account(tx_state, message.code_address).code_hash, + message.code_address, ) return refund_counter diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index f5f89bdfa59..15fd96143b0 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -354,7 +354,7 @@ def extcodesize(evm: Evm) -> None: # OPERATION tx_state = evm.message.tx_env.state code_hash = get_account(tx_state, address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -401,7 +401,7 @@ def extcodecopy(evm: Evm) -> None: evm.memory += b"\x00" * extend_memory.expand_by tx_state = evm.message.tx_env.state code_hash = get_account(tx_state, address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index ecbae2b65d3..0f485ef528e 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -425,7 +425,7 @@ def call(evm: Evm) -> None: evm.accessed_addresses.add(code_address) code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) message_call_gas = calculate_message_call_gas( value, @@ -528,7 +528,7 @@ def callcode(evm: Evm) -> None: evm.accessed_addresses.add(code_address) code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) message_call_gas = calculate_message_call_gas( value, @@ -685,7 +685,7 @@ def delegatecall(evm: Evm) -> None: evm.accessed_addresses.add(code_address) code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) message_call_gas = calculate_message_call_gas( U256(0), @@ -775,7 +775,7 @@ def staticcall(evm: Evm) -> None: evm.accessed_addresses.add(code_address) code_hash = get_account(tx_state, code_address).code_hash - code = get_code(tx_state, code_hash) + code = get_code(tx_state, code_hash, code_address) message_call_gas = calculate_message_call_gas( U256(0), diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 8022c8f7898..e810dde0cce 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -133,6 +133,7 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code = get_code( tx_state, get_account(tx_state, delegated_address).code_hash, + delegated_address, ) message.code_address = delegated_address From 3f702f74e8e486481523a35eb52e3d1029388f8a Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Feb 2026 11:55:01 -0300 Subject: [PATCH 019/265] tests: add 8025 bytecodes tests border cases --- .../test_witness_bytecodes.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py new file mode 100644 index 00000000000..d83f3bd2cd4 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py @@ -0,0 +1,100 @@ +"""Witness bytecode collection scenarios.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Initcode, + Op, + Transaction, + compute_create_address, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_excludes_bytecode_created_in_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Create a contract in a block without reading its code first. + + Manual witness check until execution witness assertions are supported: + - The deployed runtime code should not appear in + executionWitness.codes for this block. + """ + runtime_code = bytes.fromhex("deadbeef") + creator = pre.fund_eoa() + created_contract = compute_create_address(address=creator, nonce=0) + + create_tx = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[create_tx])], + post={ + creator: Account(nonce=1), + created_contract: Account( + nonce=1, + code=runtime_code, + ), + }, + ) + + +def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Tx1 reads pre-state code, tx2 later deploys the same runtime code hash. + + Manual witness check until execution witness assertions are supported: + - 0x60006000F3 should appear in executionWitness.codes because + tx1 CALLs an existing pre-state contract with that code. + """ + runtime_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) + + existing_contract = pre.deploy_contract(code=runtime_code) + + reader = pre.fund_eoa() + creator = pre.fund_eoa() + created_contract = compute_create_address(address=creator, nonce=0) + + tx1_read_existing_code = Transaction( + sender=reader, + to=existing_contract, + gas_limit=200_000, + ) + tx2_create_same_code_hash = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block(txs=[tx1_read_existing_code, tx2_create_same_code_hash]) + ], + post={ + reader: Account(nonce=1), + creator: Account(nonce=1), + created_contract: Account( + nonce=1, + code=runtime_code, + ), + }, + ) From 9911952685d7718e6117b8a9c7a7c9099af15c0d Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Feb 2026 23:23:21 -0300 Subject: [PATCH 020/265] add mpt node processing into execution witness --- src/ethereum/forks/amsterdam/fork.py | 2 + .../forks/amsterdam/stateless_types.py | 115 +++++++++++++++++- .../evm_tools/t8n/__init__.py | 4 +- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index d795b1b04b9..1a9f905b76d 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -253,6 +253,8 @@ def state_transition(chain: BlockChain, block: Block) -> None: parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), execution_witness=ExecutionWitnessBuilder( + pre_state_accounts_data = chain.state._main_trie, + pre_state_storages_data = chain.state._storage_tries, blockchain_headers=[rlp.encode(blk.header) for blk in chain.blocks] ), ) diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index 56b097058df..3d641861001 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -3,14 +3,16 @@ """ from dataclasses import dataclass, field -from typing import List, Optional, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple -from ethereum_types.bytes import Bytes +from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import slotted_freezable -from ethereum_types.numeric import Uint +from ethereum_types.numeric import U256, Uint from ethereum.crypto.hash import Hash32 -from ethereum.state import Address, PreState +from ethereum.forks.amsterdam.incremental_mpt import IncrementalMPT, build_mpt, mpt_get, mpt_root, mpt_set +from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT, Trie, trie_get +from ethereum.state import Account, Address, PreState, Root from .state_tracker import BlockState @@ -46,8 +48,9 @@ class ExecutionWitnessBuilder: Mutable accumulator for execution witness data during block execution. """ + pre_state_accounts_data: Trie[Address, Optional[Account]] + pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]] blockchain_headers: List[Bytes] = field(default_factory=list) - state: List[Bytes] = field(default_factory=list) def build_execution_witness( @@ -66,8 +69,108 @@ def build_execution_witness( ) codes = get_witness_codes(block_state.code_reads, block_state.pre_state) + # Build account and storages IncrementalMPTs from pre-state flat data. + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]] = {} + for address, data in builder.pre_state_storages_data.items(): + incr_storage_mpts[address] = build_mpt( + data._data, secured=True, default=U256(0) + ) + + def get_pre_storage_root(address: Address) -> Root: + if address in incr_storage_mpts: + return mpt_root(incr_storage_mpts[address]) + return EMPTY_TRIE_ROOT + + incr_account_mpt = build_mpt( + builder.pre_state_accounts_data._data, + secured=True, + default=None, + get_storage_root=get_pre_storage_root, + ) + + # 1. Traverse all accessed and dirty storage keys on the pre-state + # MPTs to capture pre-state trie nodes in the witness. This must + # happen before any writes since writes mutate the tree in-place. + all_storage_accesses: Dict[Address, Set[Bytes32]] = {} + for address, key in block_state.storage_reads: + all_storage_accesses.setdefault(address, set()).add(key) + for address, slots in block_state.storage_writes.items(): + all_storage_accesses.setdefault(address, set()).update(slots) + + for address, keys in all_storage_accesses.items(): + if address not in incr_storage_mpts: + continue + for key in keys: + mpt_get(incr_storage_mpts[address], key) + + # 2. Apply dirty storage to storages (writes) + for address, dirty_keys in block_state.storage_writes.items(): + if address not in incr_storage_mpts: + # New storage created during block + incr_storage_mpts[address] = build_mpt( + {}, secured=True, default=U256(0) + ) + + # We do two passes to ensure deletions are processed after + # inserts/updates to minimize the number of nodes touched + # in the MPT. + # First pass: inserts and updates + for key, value in dirty_keys.items(): + if value != 0: + mpt_set(incr_storage_mpts[address], key, value) + # Second pass: deletions + for key, value in dirty_keys.items(): + if value == 0: + mpt_set(incr_storage_mpts[address], key, value) + + # Accounts are "dirty" if: + # - Account fields changed (nonce/balance/code) - tracked in dirty_accounts + # - Storage changed (storage root changed) - tracked in dirty_storage + all_dirty_accounts = ( + set(block_state.account_writes.keys()) + | set(block_state.storage_writes.keys()) + ) + + # 3. Traverse all accessed and dirty accounts on the pre-state MPT + # to capture pre-state trie nodes before writes mutate the tree. + for address in block_state.account_reads | all_dirty_accounts: + mpt_get(incr_account_mpt, address) + + # 4. Apply dirty accounts + for address in all_dirty_accounts: + # Get post-state account data + if address in block_state.account_writes: + account = block_state.account_writes[address] + else: + account = ( + block_state.pre_state.get_account_optional(address) + ) + + # Get storage root for this account + if address in incr_storage_mpts: + addr_storage_root = mpt_root(incr_storage_mpts[address]) + else: + addr_storage_root = EMPTY_TRIE_ROOT + + def get_storage_root_fn( + _: Address, sr: Root = addr_storage_root + ) -> Root: + return sr + + mpt_set( + incr_account_mpt, + address, + account, + get_storage_root=get_storage_root_fn, + ) + + # Collect witness from all MPTs + accessed_nodes=dict(incr_account_mpt.witness.accessed_nodes) + for mpt in incr_storage_mpts.values(): + accessed_nodes.update(mpt.witness.accessed_nodes) + return ExecutionWitness( - state=tuple(sorted(builder.state)), + state=tuple(sorted(accessed_nodes.values())), codes=tuple(codes), headers=tuple(ancestor_headers), ) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 772446f5b44..4e303e6d546 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -330,7 +330,9 @@ def block_environment(self) -> Any: if self.fork.has_execution_witness: kw_arguments["execution_witness"] = ExecutionWitnessBuilder( - blockchain_headers=self.env.block_headers + pre_state_accounts_data=self.alloc.state._main_trie, + pre_state_storages_data=self.alloc.state._storage_tries, + blockchain_headers=self.env.block_headers, ) return block_environment(**kw_arguments) From 56b71a018723d556f131b10cb956838ec536f378 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Feb 2026 13:28:50 -0300 Subject: [PATCH 021/265] improvements and post-state root safety check --- src/ethereum/forks/amsterdam/fork.py | 9 +- .../forks/amsterdam/stateless_types.py | 196 +++++++++++++----- .../evm_tools/t8n/__init__.py | 23 +- 3 files changed, 170 insertions(+), 58 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 1a9f905b76d..4956b2cb9ca 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -270,6 +270,11 @@ def state_transition(chain: BlockChain, block: Block) -> None: block_state_root, _ = chain.state.compute_state_root_and_trie_changes( account_changes, storage_changes ) + block_output.execution_witness = build_execution_witness( + block_env.execution_witness, + block_env.state, + expected_post_state_root=block_state_root, + ) apply_changes_to_state( chain.state, account_changes, storage_changes, code_changes ) @@ -831,10 +836,6 @@ def apply_body( block_env.block_access_list_builder, block_env.state ) - block_output.execution_witness = build_execution_witness( - block_env.execution_witness, block_env.state - ) - return block_output diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index 3d641861001..c27e679bd23 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -10,8 +10,14 @@ from ethereum_types.numeric import U256, Uint from ethereum.crypto.hash import Hash32 -from ethereum.forks.amsterdam.incremental_mpt import IncrementalMPT, build_mpt, mpt_get, mpt_root, mpt_set -from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT, Trie, trie_get +from ethereum.forks.amsterdam.incremental_mpt import ( + IncrementalMPT, + build_mpt, + mpt_get, + mpt_root, + mpt_set, +) +from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT, Trie from ethereum.state import Account, Address, PreState, Root from .state_tracker import BlockState @@ -53,100 +59,117 @@ class ExecutionWitnessBuilder: blockchain_headers: List[Bytes] = field(default_factory=list) -def build_execution_witness( - builder: ExecutionWitnessBuilder, - block_state: BlockState, -) -> ExecutionWitness: - """ - Build the execution witness from the accumulated builder data. - - Sort state and codes lexicographically, headers by block number - ascending. - """ - ancestor_headers = get_witness_ancestors( - builder.blockchain_headers, - block_state.oldest_ancestor_offset, - ) - codes = get_witness_codes(block_state.code_reads, block_state.pre_state) - - # Build account and storages IncrementalMPTs from pre-state flat data. +def _build_pre_state_storage_mpts( + pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]], +) -> Dict[Address, IncrementalMPT[Bytes32, U256]]: + """Build incremental storage MPTs from pre-state flat storage data.""" incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]] = {} - for address, data in builder.pre_state_storages_data.items(): + for address, data in pre_state_storages_data.items(): incr_storage_mpts[address] = build_mpt( data._data, secured=True, default=U256(0) ) + return incr_storage_mpts + + +def _build_pre_state_account_mpt( + pre_state_accounts_data: Trie[Address, Optional[Account]], + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], +) -> IncrementalMPT[Address, Optional[Account]]: + """Build the incremental account MPT from pre-state flat account data.""" def get_pre_storage_root(address: Address) -> Root: if address in incr_storage_mpts: return mpt_root(incr_storage_mpts[address]) return EMPTY_TRIE_ROOT - incr_account_mpt = build_mpt( - builder.pre_state_accounts_data._data, + return build_mpt( + pre_state_accounts_data._data, secured=True, default=None, get_storage_root=get_pre_storage_root, ) - # 1. Traverse all accessed and dirty storage keys on the pre-state - # MPTs to capture pre-state trie nodes in the witness. This must - # happen before any writes since writes mutate the tree in-place. + +def _collect_storage_accesses( + block_state: BlockState, +) -> Dict[Address, Set[Bytes32]]: + """ + Collect all storage keys that must be traversed on pre-state tries. + + Includes both reads and writes so all needed pre-state nodes are captured + before in-place mutation. + """ all_storage_accesses: Dict[Address, Set[Bytes32]] = {} for address, key in block_state.storage_reads: all_storage_accesses.setdefault(address, set()).add(key) for address, slots in block_state.storage_writes.items(): all_storage_accesses.setdefault(address, set()).update(slots) + return all_storage_accesses + +def _capture_pre_state_storage_nodes( + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], + all_storage_accesses: Dict[Address, Set[Bytes32]], +) -> None: + """Traverse pre-state storage tries to record witness nodes.""" for address, keys in all_storage_accesses.items(): if address not in incr_storage_mpts: continue for key in keys: mpt_get(incr_storage_mpts[address], key) - # 2. Apply dirty storage to storages (writes) - for address, dirty_keys in block_state.storage_writes.items(): + +def _apply_storage_writes( + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], + storage_writes: Dict[Address, Dict[Bytes32, U256]], +) -> None: + """Apply block storage writes to incremental storage MPTs.""" + for address, dirty_keys in storage_writes.items(): if address not in incr_storage_mpts: - # New storage created during block + # New storage created during block. incr_storage_mpts[address] = build_mpt( {}, secured=True, default=U256(0) ) - # We do two passes to ensure deletions are processed after - # inserts/updates to minimize the number of nodes touched - # in the MPT. - # First pass: inserts and updates + # Two passes: insert/update first, deletions second. for key, value in dirty_keys.items(): if value != 0: mpt_set(incr_storage_mpts[address], key, value) - # Second pass: deletions for key, value in dirty_keys.items(): if value == 0: mpt_set(incr_storage_mpts[address], key, value) - # Accounts are "dirty" if: - # - Account fields changed (nonce/balance/code) - tracked in dirty_accounts - # - Storage changed (storage root changed) - tracked in dirty_storage - all_dirty_accounts = ( - set(block_state.account_writes.keys()) - | set(block_state.storage_writes.keys()) + +def _get_all_dirty_accounts(block_state: BlockState) -> Set[Address]: + """Return addresses whose account leaf may change in the state trie.""" + return set(block_state.account_writes.keys()) | set( + block_state.storage_writes.keys() ) - # 3. Traverse all accessed and dirty accounts on the pre-state MPT - # to capture pre-state trie nodes before writes mutate the tree. - for address in block_state.account_reads | all_dirty_accounts: + +def _capture_pre_state_account_nodes( + incr_account_mpt: IncrementalMPT[Address, Optional[Account]], + account_reads: Set[Address], + all_dirty_accounts: Set[Address], +) -> None: + """Traverse pre-state account trie to record witness nodes.""" + for address in account_reads | all_dirty_accounts: mpt_get(incr_account_mpt, address) - # 4. Apply dirty accounts + +def _apply_account_writes( + incr_account_mpt: IncrementalMPT[Address, Optional[Account]], + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], + block_state: BlockState, + all_dirty_accounts: Set[Address], +) -> None: + """Apply final account values and post-storage roots to account trie.""" for address in all_dirty_accounts: - # Get post-state account data if address in block_state.account_writes: account = block_state.account_writes[address] else: - account = ( - block_state.pre_state.get_account_optional(address) - ) + account = block_state.pre_state.get_account_optional(address) - # Get storage root for this account if address in incr_storage_mpts: addr_storage_root = mpt_root(incr_storage_mpts[address]) else: @@ -164,10 +187,81 @@ def get_storage_root_fn( get_storage_root=get_storage_root_fn, ) - # Collect witness from all MPTs - accessed_nodes=dict(incr_account_mpt.witness.accessed_nodes) + +def _collect_accessed_nodes( + incr_account_mpt: IncrementalMPT[Address, Optional[Account]], + incr_storage_mpts: Dict[Address, IncrementalMPT[Bytes32, U256]], +) -> Dict[Bytes, Bytes]: + """Merge accessed trie nodes from account and storage witnesses.""" + accessed_nodes = dict(incr_account_mpt.witness.accessed_nodes) for mpt in incr_storage_mpts.values(): - accessed_nodes.update(mpt.witness.accessed_nodes) + accessed_nodes.update(mpt.witness.accessed_nodes) + return accessed_nodes + + +def build_execution_witness( + builder: ExecutionWitnessBuilder, + block_state: BlockState, + expected_post_state_root: Root, +) -> ExecutionWitness: + """ + Build the execution witness from the accumulated builder data. + + Sort state and codes lexicographically, headers by block number + ascending. + """ + ancestor_headers = get_witness_ancestors( + builder.blockchain_headers, + block_state.oldest_ancestor_offset, + ) + codes = get_witness_codes(block_state.code_reads, block_state.pre_state) + + # Build account and storage IncrementalMPTs from pre-state flat data. + incr_storage_mpts = _build_pre_state_storage_mpts( + builder.pre_state_storages_data + ) + incr_account_mpt = _build_pre_state_account_mpt( + builder.pre_state_accounts_data, incr_storage_mpts + ) + + # 1. Traverse all accessed and dirty storage keys on the pre-state + # MPTs to capture pre-state trie nodes in the witness. This must + # happen before any writes since writes mutate the tree in-place. + all_storage_accesses = _collect_storage_accesses(block_state) + _capture_pre_state_storage_nodes(incr_storage_mpts, all_storage_accesses) + + # 2. Apply dirty storage to storages (writes) + _apply_storage_writes(incr_storage_mpts, block_state.storage_writes) + + # Accounts are "dirty" if: + # - Account fields changed (nonce/balance/code) - tracked in dirty_accounts + # - Storage changed (storage root changed) - tracked in dirty_storage + all_dirty_accounts = _get_all_dirty_accounts(block_state) + + # 3. Traverse all accessed and dirty accounts on the pre-state MPT + # to capture pre-state trie nodes before writes mutate the tree. + _capture_pre_state_account_nodes( + incr_account_mpt, + block_state.account_reads, + all_dirty_accounts, + ) + + # 4. Apply dirty accounts + _apply_account_writes( + incr_account_mpt, + incr_storage_mpts, + block_state, + all_dirty_accounts, + ) + + # Safety check: the post-state root implied by the witness construction + # must match the canonical state-root calculation. + assert mpt_root(incr_account_mpt) == expected_post_state_root + + # Collect witness from all MPTs + accessed_nodes = _collect_accessed_nodes( + incr_account_mpt, incr_storage_mpts + ) return ExecutionWitness( state=tuple(sorted(accessed_nodes.values())), diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 4e303e6d546..eb376f9de28 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -479,9 +479,26 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: ) if self.fork.has_execution_witness: - block_output.execution_witness = self.fork.build_execution_witness( - block_env.execution_witness, - block_env.state, + assert self.fork.has_block_state + # TODO(zkevm): since IncrementalMPT isn't the authoritative way to + # do post-state root calculation, we calculate the post-state root + # here with patricialize. Note this work will happen again later in + # t8n to provide the expected output. Although we could cache it, + # it might be too invasive and not worth it. Note also this is safe + # since `compute_state_root_and_trie_changes` does not mutate anything + # in state (i.e. it does transient copies of MPTs to do its work). + expected_post_state_root, _ = ( + self.alloc.state.compute_state_root_and_trie_changes( + block_env.state.account_writes, + block_env.state.storage_writes, + ) + ) + block_output.execution_witness = ( + self.fork.build_execution_witness( + block_env.execution_witness, + block_env.state, + expected_post_state_root=expected_post_state_root, + ) ) def run_blockchain_test(self) -> None: From 040911eeec63dfeb37ce0abef4ddf2e227996e3b Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Feb 2026 13:33:02 -0300 Subject: [PATCH 022/265] fix lints --- src/ethereum/forks/amsterdam/fork.py | 8 +++--- .../forks/amsterdam/stateless_types.py | 8 +++--- .../evm_tools/t8n/__init__.py | 25 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 4956b2cb9ca..d0be4a533d2 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -253,9 +253,11 @@ def state_transition(chain: BlockChain, block: Block) -> None: parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), execution_witness=ExecutionWitnessBuilder( - pre_state_accounts_data = chain.state._main_trie, - pre_state_storages_data = chain.state._storage_tries, - blockchain_headers=[rlp.encode(blk.header) for blk in chain.blocks] + pre_state_accounts_data=chain.state._main_trie, + pre_state_storages_data=chain.state._storage_tries, + blockchain_headers=[ + rlp.encode(blk.header) for blk in chain.blocks + ], ), ) diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index c27e679bd23..a899c915ba4 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -10,17 +10,17 @@ from ethereum_types.numeric import U256, Uint from ethereum.crypto.hash import Hash32 -from ethereum.forks.amsterdam.incremental_mpt import ( +from ethereum.state import Account, Address, PreState, Root + +from .incremental_mpt import ( IncrementalMPT, build_mpt, mpt_get, mpt_root, mpt_set, ) -from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT, Trie -from ethereum.state import Account, Address, PreState, Root - from .state_tracker import BlockState +from .trie import EMPTY_TRIE_ROOT, Trie @slotted_freezable diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index eb376f9de28..503230f94f9 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -480,25 +480,24 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.has_execution_witness: assert self.fork.has_block_state - # TODO(zkevm): since IncrementalMPT isn't the authoritative way to - # do post-state root calculation, we calculate the post-state root - # here with patricialize. Note this work will happen again later in - # t8n to provide the expected output. Although we could cache it, - # it might be too invasive and not worth it. Note also this is safe - # since `compute_state_root_and_trie_changes` does not mutate anything - # in state (i.e. it does transient copies of MPTs to do its work). + # TODO(zkevm): since IncrementalMPT isn't the authoritative way + # to do post-state root calculation, we calculate the post-state + # root here with patricialize. Note this work will happen again + # later in t8n to provide the expected output. Although we could + # cache it, it might be too invasive and not worth it. Note also + # this is safe since `compute_state_root_and_trie_changes` does + # not mutate anything in state (i.e. it does transient copies of + # MPTs to do its work). expected_post_state_root, _ = ( self.alloc.state.compute_state_root_and_trie_changes( block_env.state.account_writes, block_env.state.storage_writes, ) ) - block_output.execution_witness = ( - self.fork.build_execution_witness( - block_env.execution_witness, - block_env.state, - expected_post_state_root=expected_post_state_root, - ) + block_output.execution_witness = self.fork.build_execution_witness( + block_env.execution_witness, + block_env.state, + expected_post_state_root=expected_post_state_root, ) def run_blockchain_test(self) -> None: From 637a2d8aa5554a9e6a0774a6cabd4c7887aba3ea Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Wed, 25 Feb 2026 01:47:17 +0000 Subject: [PATCH 023/265] commit --- src/ethereum/forks/amsterdam/fork.py | 13 +++------ .../forks/amsterdam/stateless_types.py | 27 ++++++------------- src/ethereum/forks/amsterdam/vm/__init__.py | 3 +-- .../evm_tools/t8n/__init__.py | 12 +++------ 4 files changed, 16 insertions(+), 39 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index d0be4a533d2..cd8bdfcf0b3 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -74,7 +74,7 @@ set_account_balance, track_ancestor_access, ) -from .stateless_types import ExecutionWitnessBuilder, build_execution_witness +from .stateless_types import build_execution_witness from .transactions import ( AccessListTransaction, BlobTransaction, @@ -252,13 +252,6 @@ def state_transition(chain: BlockChain, block: Block) -> None: excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), - execution_witness=ExecutionWitnessBuilder( - pre_state_accounts_data=chain.state._main_trie, - pre_state_storages_data=chain.state._storage_tries, - blockchain_headers=[ - rlp.encode(blk.header) for blk in chain.blocks - ], - ), ) block_output = apply_body( @@ -273,9 +266,11 @@ def state_transition(chain: BlockChain, block: Block) -> None: account_changes, storage_changes ) block_output.execution_witness = build_execution_witness( - block_env.execution_witness, block_env.state, expected_post_state_root=block_state_root, + pre_state_accounts_data=chain.state._main_trie, + pre_state_storages_data=chain.state._storage_tries, + blockchain_headers=[rlp.encode(blk.header) for blk in chain.blocks], ) apply_changes_to_state( chain.state, account_changes, storage_changes, code_changes diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index a899c915ba4..30a3f8562c5 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -2,7 +2,7 @@ Stateless validation types. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Dict, List, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 @@ -48,17 +48,6 @@ class ExecutionWitness: """ -@dataclass -class ExecutionWitnessBuilder: - """ - Mutable accumulator for execution witness data during block execution. - """ - - pre_state_accounts_data: Trie[Address, Optional[Account]] - pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]] - blockchain_headers: List[Bytes] = field(default_factory=list) - - def _build_pre_state_storage_mpts( pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]], ) -> Dict[Address, IncrementalMPT[Bytes32, U256]]: @@ -200,28 +189,28 @@ def _collect_accessed_nodes( def build_execution_witness( - builder: ExecutionWitnessBuilder, block_state: BlockState, expected_post_state_root: Root, + pre_state_accounts_data: Trie[Address, Optional[Account]], + pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]], + blockchain_headers: Optional[List[Bytes]] = None, ) -> ExecutionWitness: """ - Build the execution witness from the accumulated builder data. + Build the execution witness from block state and pre-state trie data. Sort state and codes lexicographically, headers by block number ascending. """ ancestor_headers = get_witness_ancestors( - builder.blockchain_headers, + blockchain_headers if blockchain_headers is not None else [], block_state.oldest_ancestor_offset, ) codes = get_witness_codes(block_state.code_reads, block_state.pre_state) # Build account and storage IncrementalMPTs from pre-state flat data. - incr_storage_mpts = _build_pre_state_storage_mpts( - builder.pre_state_storages_data - ) + incr_storage_mpts = _build_pre_state_storage_mpts(pre_state_storages_data) incr_account_mpt = _build_pre_state_account_mpt( - builder.pre_state_accounts_data, incr_storage_mpts + pre_state_accounts_data, incr_storage_mpts ) # 1. Traverse all accessed and dirty storage keys on the pre-state diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index a806e6a580e..366c6048784 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -26,7 +26,7 @@ from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Authorization, VersionedHash from ..state_tracker import BlockState, TransactionState -from ..stateless_types import ExecutionWitness, ExecutionWitnessBuilder +from ..stateless_types import ExecutionWitness from ..transactions import LegacyTransaction from ..trie import Trie @@ -51,7 +51,6 @@ class BlockEnvironment: excess_blob_gas: U64 parent_beacon_block_root: Hash32 block_access_list_builder: BlockAccessListBuilder - execution_witness: ExecutionWitnessBuilder @dataclass diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 503230f94f9..b92c1a0cce2 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -23,7 +23,6 @@ BlockAccessIndex, BlockAccessListBuilder, ) -from ethereum.forks.amsterdam.stateless_types import ExecutionWitnessBuilder from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork from ..loaders.fixture_loader import Load @@ -328,13 +327,6 @@ def block_environment(self) -> Any: BlockAccessListBuilder() ) - if self.fork.has_execution_witness: - kw_arguments["execution_witness"] = ExecutionWitnessBuilder( - pre_state_accounts_data=self.alloc.state._main_trie, - pre_state_storages_data=self.alloc.state._storage_tries, - blockchain_headers=self.env.block_headers, - ) - return block_environment(**kw_arguments) def backup_state(self) -> None: @@ -495,9 +487,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: ) ) block_output.execution_witness = self.fork.build_execution_witness( - block_env.execution_witness, block_env.state, expected_post_state_root=expected_post_state_root, + pre_state_accounts_data=self.alloc.state._main_trie, + pre_state_storages_data=self.alloc.state._storage_tries, + blockchain_headers=self.env.block_headers, ) def run_blockchain_test(self) -> None: From c85272b2f9b4af660a7be000649385dd6e2602cc Mon Sep 17 00:00:00 2001 From: kevaundray Date: Wed, 25 Feb 2026 02:09:44 +0000 Subject: [PATCH 024/265] commit (#2312) --- tests/amsterdam/eip8025_optional_proofs/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/__init__.py diff --git a/tests/amsterdam/eip8025_optional_proofs/__init__.py b/tests/amsterdam/eip8025_optional_proofs/__init__.py new file mode 100644 index 00000000000..4438517433d --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/__init__.py @@ -0,0 +1 @@ +"""Tests for [EIP-8025: Optional Proofs](https://eips.ethereum.org/EIPS/eip-8025).""" From c3e02512318ab89846b24d5a3907c965404db303 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 25 Feb 2026 17:24:01 -0300 Subject: [PATCH 025/265] ci: hasher compare Signed-off-by: jsign --- .github/workflows/compare-fixtures.yaml | 94 +++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/workflows/compare-fixtures.yaml diff --git a/.github/workflows/compare-fixtures.yaml b/.github/workflows/compare-fixtures.yaml new file mode 100644 index 00000000000..59e80e75fb1 --- /dev/null +++ b/.github/workflows/compare-fixtures.yaml @@ -0,0 +1,94 @@ +name: Compare Fixtures + +on: + pull_request: + paths-ignore: + - "**.md" + - "LICENSE*" + - ".gitignore" + - ".vscode/**" + - "whitelist.txt" + - "docs/**" + - "mkdocs.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fill-base: + name: Fill Fixtures (base) + runs-on: [self-hosted-ghr, size-xl-x64] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ github.event.pull_request.base.sha }} + submodules: true + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 + with: + enable-cache: false + version: ${{ vars.UV_VERSION }} + - name: Install dependencies + run: uv sync --no-progress + - name: Fill fixtures + run: uv run fill --clean -m "blockchain_test" --fork Amsterdam ./tests -n auto --output=fixtures_base --no-html + - name: Upload fixtures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fixtures-base + path: fixtures_base/ + + fill-head: + name: Fill Fixtures (head) + runs-on: [self-hosted-ghr, size-xl-x64] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + submodules: true + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 + with: + enable-cache: false + version: ${{ vars.UV_VERSION }} + - name: Install dependencies + run: uv sync --no-progress + - name: Fill fixtures + run: uv run fill --clean -m "blockchain_test" --fork Amsterdam ./tests -n auto --output=fixtures_head --no-html + - name: Upload fixtures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fixtures-head + path: fixtures_head/ + + compare: + name: Compare Fixture Hashes + runs-on: ubuntu-latest + needs: [fill-base, fill-head] + if: always() && needs.fill-base.result == 'success' && needs.fill-head.result == 'success' + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + submodules: true + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + version: ${{ vars.UV_VERSION }} + - name: Install dependencies + run: uv sync --no-progress + - name: Download base fixtures + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e + with: + name: fixtures-base + path: fixtures_base/ + - name: Download head fixtures + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e + with: + name: fixtures-head + path: fixtures_head/ + - name: Compare fixture hashes + continue-on-error: true + run: uv run hasher compare fixtures_base fixtures_head From 4f5f2437378d883977bfe48dfe266ec5c2e175ee Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 25 Feb 2026 18:04:52 -0300 Subject: [PATCH 026/265] improvement Signed-off-by: jsign --- .github/workflows/compare-fixtures.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compare-fixtures.yaml b/.github/workflows/compare-fixtures.yaml index 59e80e75fb1..f93f70d863e 100644 --- a/.github/workflows/compare-fixtures.yaml +++ b/.github/workflows/compare-fixtures.yaml @@ -90,5 +90,8 @@ jobs: name: fixtures-head path: fixtures_head/ - name: Compare fixture hashes - continue-on-error: true - run: uv run hasher compare fixtures_base fixtures_head + run: | + if ! uv run hasher compare fixtures_base fixtures_head; then + echo "::warning::Fixture hashes differ between base and head" + exit 1 + fi From 9bbd0b38a1f2d1efbb194c5518a2b59fbdef1f9b Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 26 Feb 2026 10:27:53 -0300 Subject: [PATCH 027/265] nit Signed-off-by: Ignacio Hagopian --- .github/workflows/compare-fixtures.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/compare-fixtures.yaml b/.github/workflows/compare-fixtures.yaml index f93f70d863e..065b726fdf3 100644 --- a/.github/workflows/compare-fixtures.yaml +++ b/.github/workflows/compare-fixtures.yaml @@ -90,8 +90,4 @@ jobs: name: fixtures-head path: fixtures_head/ - name: Compare fixture hashes - run: | - if ! uv run hasher compare fixtures_base fixtures_head; then - echo "::warning::Fixture hashes differ between base and head" - exit 1 - fi + run: uv run hasher compare fixtures_base fixtures_head From e77bd4d694e1ace4bd7fab69401bf67dff1396d5 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 26 Feb 2026 17:59:11 +0000 Subject: [PATCH 028/265] commit --- src/ethereum/forks/amsterdam/stateless.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 005a0020fb9..330c45ec7b8 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -100,6 +100,7 @@ class StatelessValidationResult: new_payload_request_root: Hash32 successful_validation: bool + chain_config: ChainConfig def compute_new_payload_request_root( @@ -140,4 +141,5 @@ def verify_stateless_new_payload( stateless_input ), successful_validation=True, + chain_config=stateless_input.chain_config, ) From 30e74dc198b9759ac64676ba3f60190384fd9e48 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 26 Feb 2026 18:03:04 -0300 Subject: [PATCH 029/265] ci: add zkevm feature for releases (#2336) Signed-off-by: Ignacio Hagopian --- .github/configs/feature.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 5d23395586a..d1a869092b2 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -20,3 +20,8 @@ bal: evm-type: develop fill-params: --fork=Amsterdam --fill-static-tests feature_only: true + +zkevm: + evm-type: develop + fill-params: -m "blockchain_test" --fork=Amsterdam ./tests/ + feature_only: true From 101b394b851d25e1dd0668fe126fca8dc875eeb6 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 27 Feb 2026 20:44:05 -0300 Subject: [PATCH 030/265] rebase lints Signed-off-by: jsign --- src/ethereum_spec_tools/evm_tools/t8n/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 14bb06b0387..42704332e37 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -498,7 +498,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: pre_state_accounts_data=self.alloc.state._main_trie, pre_state_storages_data=self.alloc.state._storage_tries, blockchain_headers=self.env.block_headers, - ) + ) def run_blockchain_test(self) -> None: """ From c8c34ee7b22ce8b7fd6752e25cbc118ed69427af Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 03:15:35 +0000 Subject: [PATCH 031/265] feat(amsterdam): add stateless validation support - Partially decouple state_transition from BlockChain (BlockDiff, ChainContext, PreState protocol) - Add IncrementalMPT with HashedNode for sparse witness tries - Add WitnessState implementing PreState from execution witness data - Add stateless validation entry point (verify_stateless_new_payload) - Add stateless guest interface (entrypoint, deserialize/serialize) - Rename gas constants in Amsterdam fork for consistency - Add tests for IncrementalMPT and WitnessState --- .../amsterdam/execution_engine/__init__.py | 2 - .../amsterdam/execution_engine/new_payload.py | 132 ++-- .../forks/amsterdam/execution_engine/types.py | 3 +- .../execution_engine/validation_helpers.py | 3 +- src/ethereum/forks/amsterdam/fork.py | 109 +++- .../forks/amsterdam/incremental_mpt.py | 223 ++++++- src/ethereum/forks/amsterdam/state.py | 24 +- src/ethereum/forks/amsterdam/state_tracker.py | 27 +- src/ethereum/forks/amsterdam/stateless.py | 68 +- .../forks/amsterdam/stateless_guest.py | 68 ++ src/ethereum/forks/amsterdam/witness_state.py | 309 +++++++++ src/ethereum/state.py | 16 + .../evm_tools/t8n/t8n_types.py | 15 +- tests/amsterdam/test_incremental_mpt.py | 597 ++++++++++++++++++ tests/amsterdam/test_witness_state.py | 238 +++++++ 15 files changed, 1672 insertions(+), 162 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/stateless_guest.py create mode 100644 src/ethereum/forks/amsterdam/witness_state.py create mode 100644 tests/amsterdam/test_incremental_mpt.py create mode 100644 tests/amsterdam/test_witness_state.py diff --git a/src/ethereum/forks/amsterdam/execution_engine/__init__.py b/src/ethereum/forks/amsterdam/execution_engine/__init__.py index ca662fe9c68..8d410d1a65e 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/__init__.py +++ b/src/ethereum/forks/amsterdam/execution_engine/__init__.py @@ -22,7 +22,6 @@ from .new_payload import ( is_valid_block_hash, is_valid_versioned_hashes, - notify_new_payload, verify_and_notify_new_payload, ) from .types import ( @@ -47,6 +46,5 @@ "is_valid_block_hash", "is_valid_versioned_hashes", "notify_forkchoice_updated", - "notify_new_payload", "verify_and_notify_new_payload", ] diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py index d75e9d35dd1..a606bc275b0 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -5,15 +5,16 @@ from typing import Sequence, Tuple from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes from ethereum.crypto.hash import keccak256 -from ethereum.state import Root +from ethereum.exceptions import InvalidBlock +from ethereum.state import BlockDiff, PreState, Root -from ..fork import state_transition +from ..blocks import Block +from ..fork import ChainContext, execute_block, get_last_256_block_hashes from ..fork_types import VersionedHash +from ..state import apply_changes_to_state from ..transactions import BlobTransaction, decode_transaction -from ..trie import copy_trie from .types import ExecutionEngine, ExecutionPayload, NewPayloadRequest from .validation_helpers import _payload_block, _payload_header @@ -65,85 +66,90 @@ def is_valid_versioned_hashes( ) -def notify_new_payload( - chain: ExecutionEngine, - execution_payload: ExecutionPayload, - parent_beacon_block_root: Root, - execution_requests: Tuple[Bytes, ...], -) -> bool: - """ - Execute a payload by converting it to an execution-layer block - and applying the fork's canonical ``state_transition``. - - On success, keep the applied state and appended block. - On failure, restore state and block history snapshots and return ``False``. - """ - # TODO: Create snapshots, so we can rollback. - # This is inefficient. Check if we can remove this - # or how the rest of the code handles partial mutation - # when the block is invalid. - state_main_trie_snapshot = copy_trie(chain.state._main_trie) - state_storage_tries_snapshot = { - address: copy_trie(storage_trie) - for address, storage_trie in chain.state._storage_tries.items() - } - blocks_snapshot = list(chain.blocks) - - try: - block = _payload_block( - execution_payload, - parent_beacon_block_root, - # Note: `verify_and_notify_new_payload` calls - # `is_valid_block_hash` which also recomputes - # the `execution_requests_root`. We do this to - # stay conformant with the API from the consensus-specs. - execution_requests, - ) - # Note: it seems `state_transition` does not check - # the `block_hash` in the header is correct value. - # This is currently done in with `is_valid_block_hash` - state_transition(chain, block) - except Exception: - # Any failure during state transition invalidates the - # payload; restore pre-execution snapshots. - chain.state._main_trie = state_main_trie_snapshot - chain.state._storage_tries = state_storage_tries_snapshot - chain.blocks = blocks_snapshot - return False - - return True - - -def verify_and_notify_new_payload( - chain: ExecutionEngine, +def execute_new_payload_request( new_payload_request: NewPayloadRequest, -) -> bool: + pre_state: PreState, + chain_context: ChainContext, +) -> Tuple[BlockDiff, Block]: """ - Validate the payload and, if valid, apply it to the chain. + Validate and execute a payload against ``pre_state``. + + Note: This is conceptually similar to notify_new_payload. + We however do not return a boolean because we want the caller + to apply the diff and handle the case where they may need to + rollback state on an error. + + Parameters + ---------- + new_payload_request : + The payload request to validate and execute. + pre_state : + Pre-execution state provider. + chain_context : + Chain context needed for block execution. + + Returns + ------- + block_diff : `BlockDiff` + Account, storage, and code changes produced by execution. + block : `Block` + The block derived from the payload. + """ payload = new_payload_request.execution_payload parent_beacon_block_root = new_payload_request.parent_beacon_block_root execution_requests_list = new_payload_request.execution_requests if b"" in payload.transactions: - return False + raise InvalidBlock("Empty transaction in payload") if not is_valid_block_hash( payload, parent_beacon_block_root, execution_requests_list, ): - return False + raise InvalidBlock("Invalid block hash") if not is_valid_versioned_hashes(new_payload_request): - return False + raise InvalidBlock("Invalid versioned hashes") - if not notify_new_payload( - chain, + block = _payload_block( payload, parent_beacon_block_root, tuple(execution_requests_list), - ): + ) + block_diff = execute_block(block, pre_state, chain_context) + return block_diff, block + + +def verify_and_notify_new_payload( + chain: ExecutionEngine, + new_payload_request: NewPayloadRequest, +) -> bool: + """ + Validate the payload and, if valid, apply it to the chain. + """ + chain_context = ChainContext( + chain_id=chain.chain_id, + block_hashes=get_last_256_block_hashes(chain), + parent_header=chain.blocks[-1].header, + ) + + try: + # TODO: This returning a block is a bit weird + # We could not return the block and then convert + # the payload into a block below + block_diff, block = execute_new_payload_request( + new_payload_request, + chain.state, + chain_context, + ) + except InvalidBlock: return False + apply_changes_to_state(chain.state, block_diff) + chain.blocks.append(block) + if len(chain.blocks) > 255: + chain.blocks = chain.blocks[-255:] + return True diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py index 87180d28367..e136a28d427 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/types.py +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -15,7 +15,6 @@ from ..blocks import Withdrawal from ..fork import BlockChain from ..fork_types import Bloom, VersionedHash -from ..transactions import LegacyTransaction # In this module, the execution engine is the chain/state container used by # the fork's transition functions. @@ -52,7 +51,7 @@ class ExecutionPayload: extra_data: Bytes base_fee_per_gas: Uint block_hash: Hash32 - transactions: Tuple[LegacyTransaction | Bytes, ...] + transactions: Tuple[Bytes, ...] withdrawals: Tuple[Withdrawal, ...] blob_gas_used: U64 excess_blob_gas: U64 diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py index 05896f78e06..a4568ed9323 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -14,7 +14,6 @@ from ..blocks import Block, Header from ..fork import EMPTY_OMMER_HASH from ..requests import compute_requests_hash -from ..transactions import LegacyTransaction from ..trie import Trie, root, trie_set from .types import ExecutionPayload @@ -27,7 +26,7 @@ def _payload_header( """ Build the execution header implied by a payload request. """ - transactions_trie: Trie[Bytes, Optional[LegacyTransaction | Bytes]] = Trie( + transactions_trie: Trie[Bytes, Optional[Bytes]] = Trie( secured=False, default=None ) for i, encoded_tx in enumerate(execution_payload.transactions): diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index cd8bdfcf0b3..4051767344e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -16,6 +16,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes +from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 @@ -27,7 +28,7 @@ InvalidSenderError, NonceMismatchError, ) -from ethereum.state import EMPTY_CODE_HASH, Address +from ethereum.state import EMPTY_CODE_HASH, Address, BlockDiff, PreState from . import vm from .block_access_lists import ( @@ -66,7 +67,7 @@ TransactionState, account_exists_and_is_empty, destroy_account, - extract_block_diffs, + extract_block_diff, get_account, get_code, incorporate_tx_into_block, @@ -74,7 +75,6 @@ set_account_balance, track_ancestor_access, ) -from .stateless_types import build_execution_witness from .transactions import ( AccessListTransaction, BlobTransaction, @@ -132,6 +132,23 @@ BLOB_COUNT_LIMIT = 6 +@slotted_freezable +@dataclass +class ChainContext: + """ + Chain context needed for block execution. + """ + + chain_id: U64 + """Identify the chain for transaction signature recovery.""" + + block_hashes: List[Hash32] + """Recent ancestor hashes (up to 256) for the ``BLOCKHASH`` opcode.""" + + parent_header: Header + """Parent header used for header validation and system contracts.""" + + @dataclass class BlockChain: """ @@ -229,21 +246,64 @@ def state_transition(chain: BlockChain, block: Block) -> None: block : Block to apply to `chain`. + """ + chain_context = ChainContext( + chain_id=chain.chain_id, + block_hashes=get_last_256_block_hashes(chain), + parent_header=chain.blocks[-1].header, + ) + + block_diff = execute_block(block, chain.state, chain_context) + + apply_changes_to_state(chain.state, block_diff) + chain.blocks.append(block) + if len(chain.blocks) > 255: + # Real clients have to store more blocks to deal with reorgs, but the + # protocol only requires the last 255 + chain.blocks = chain.blocks[-255:] + + +def execute_block( + block: Block, + pre_state: PreState, + chain_context: ChainContext, +) -> BlockDiff: + """ + Execute a block and validate the resulting roots against the header. + + This method is idempotent. + + Parameters + ---------- + block : + Block to validate and execute. + pre_state : + Pre-execution state provider. + chain_context : + Chain context that the block may need during execution. + + Returns + ------- + block_diff : `BlockDiff` + Account, storage, and code changes produced by block execution. + """ if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE: raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE") - validate_header(chain, block.header) + parent_header = chain_context.parent_header + validate_header(parent_header, block.header) + if block.ommers != (): raise InvalidBlock - block_state = BlockState(pre_state=chain.state) + block_state = BlockState(pre_state=pre_state) block_env = vm.BlockEnvironment( - chain_id=chain.chain_id, + chain_id=chain_context.chain_id, state=block_state, block_gas_limit=block.header.gas_limit, - block_hashes=get_last_256_block_hashes(chain), + block_hashes=chain_context.block_hashes, coinbase=block.header.coinbase, number=block.header.number, base_fee_per_gas=block.header.base_fee_per_gas, @@ -259,21 +319,9 @@ def state_transition(chain: BlockChain, block: Block) -> None: transactions=block.transactions, withdrawals=block.withdrawals, ) - account_changes, storage_changes, code_changes = extract_block_diffs( - block_state - ) - block_state_root, _ = chain.state.compute_state_root_and_trie_changes( - account_changes, storage_changes - ) - block_output.execution_witness = build_execution_witness( - block_env.state, - expected_post_state_root=block_state_root, - pre_state_accounts_data=chain.state._main_trie, - pre_state_storages_data=chain.state._storage_tries, - blockchain_headers=[rlp.encode(blk.header) for blk in chain.blocks], - ) - apply_changes_to_state( - chain.state, account_changes, storage_changes, code_changes + block_diff = extract_block_diff(block_state) + block_state_root, _ = pre_state.compute_state_root_and_trie_changes( + block_diff.account_changes, block_diff.storage_changes ) transactions_root = root(block_output.transactions_trie) receipt_root = root(block_output.receipts_trie) @@ -305,11 +353,7 @@ def state_transition(chain: BlockChain, block: Block) -> None: if computed_block_access_list_hash != block.header.block_access_list_hash: raise InvalidBlock("Invalid block access list hash") - chain.blocks.append(block) - if len(chain.blocks) > 255: - # Real clients have to store more blocks to deal with reorgs, but the - # protocol only requires the last 255 - chain.blocks = chain.blocks[-255:] + return block_diff def calculate_base_fee_per_gas( @@ -375,9 +419,9 @@ def calculate_base_fee_per_gas( return Uint(expected_base_fee_per_gas) -def validate_header(chain: BlockChain, header: Header) -> None: +def validate_header(parent_header: Header, header: Header) -> None: """ - Verifies a block header. + Verify a block header against its parent. In order to consider a block's header valid, the logic for the quantities in the header should match the logic for the block itself. @@ -388,8 +432,8 @@ def validate_header(chain: BlockChain, header: Header) -> None: Parameters ---------- - chain : - History and current state. + parent_header : + Header of the parent block. header : Header to check for correctness. @@ -397,8 +441,6 @@ def validate_header(chain: BlockChain, header: Header) -> None: if header.number < Uint(1): raise InvalidBlock - parent_header = chain.blocks[-1].header - excess_blob_gas = calculate_excess_blob_gas(parent_header) if header.excess_blob_gas != excess_blob_gas: raise InvalidBlock @@ -809,6 +851,7 @@ def apply_body( target_address=HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash ) + # TODO: can we remove this? track_ancestor_access( block_env.state, Uint(1), diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index bfc7b2be242..c8f186388fd 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -78,8 +78,19 @@ class MutableBranchNode: _dirty: bool = False +@dataclass +class HashedNode: + """Placeholder for a trie subtree known only by its hash.""" + + _hash: Bytes + + MutableNode = Union[ - MutableLeafNode, MutableExtensionNode, MutableBranchNode, None + MutableLeafNode, + MutableExtensionNode, + MutableBranchNode, + HashedNode, + None, ] @@ -213,9 +224,10 @@ def build_mpt( def _invalidate_hash(node: MutableNode) -> None: """Invalidate the cached hash of a node.""" - if node is not None: - node._hash = None - node._rlp = None + if node is None or isinstance(node, HashedNode): + return + node._hash = None + node._rlp = None def _record_witness( @@ -223,7 +235,7 @@ def _record_witness( node: MutableNode, ) -> None: """Record a node access in the witness.""" - if node is None: + if node is None or isinstance(node, HashedNode): return if node._dirty: @@ -242,6 +254,8 @@ def _encode_mutable_node(node: MutableNode) -> Extended: """ if node is None: return b"" + elif isinstance(node, HashedNode): + raise AssertionError("HashedNode cannot be inline-encoded") elif isinstance(node, MutableLeafNode): return ( nibble_list_to_compact(node.rest_of_key, True), @@ -274,6 +288,12 @@ def _encode_mutable_node_to_extended( if node is None: return b"" + if isinstance(node, HashedNode): + return node._hash + + if not node._dirty and node._hash is not None: + return node._hash + unencoded = _encode_mutable_node(node) encoded = rlp.encode(unencoded) @@ -294,6 +314,9 @@ def _compute_node_hash_and_rlp( if node is None: return None, b"" + if isinstance(node, HashedNode): + return node._hash, b"" + if node._rlp is not None: if node._hash is not None: return node._hash, node._rlp @@ -351,7 +374,7 @@ def _mpt_traverse_for_witness( level: Uint, ) -> None: """Traverse the tree recording nodes in the witness.""" - if node is None: + if node is None or isinstance(node, HashedNode): return _record_witness(mpt.witness, node) @@ -451,6 +474,9 @@ def _mpt_insert_node( rest_of_key=key[level:], value=value, _dirty=True ) + if isinstance(node, HashedNode): + raise AssertionError("Incomplete witness: insert into hashed node") + _invalidate_hash(node) if isinstance(node, MutableLeafNode): @@ -655,6 +681,9 @@ def _mpt_delete_node( if node is None: return None + if isinstance(node, HashedNode): + raise AssertionError("Incomplete witness: delete from hashed node") + _invalidate_hash(node) if isinstance(node, MutableLeafNode): @@ -705,7 +734,9 @@ def _delete_from_extension( ) child_changed = new_child is not old_child or ( - new_child is not None and new_child._dirty + new_child is not None + and not isinstance(new_child, HashedNode) + and new_child._dirty ) if not child_changed: return node @@ -738,7 +769,9 @@ def _delete_from_branch( level + Uint(1), ) child_changed = new_child is not old_child or ( - new_child is not None and new_child._dirty + new_child is not None + and not isinstance(new_child, HashedNode) + and new_child._dirty ) if not child_changed: return node @@ -816,3 +849,177 @@ def mpt_root(mpt: IncrementalMPT) -> Root: return Root(root_encoded) else: return keccak256(rlp.encode(root_encoded)) + + +def compact_to_nibbles(compact: Bytes) -> Tuple[Bytes, bool]: + """ + Decode hex-prefix (compact) encoding into nibbles and leaf flag. + + Inverse of ``nibble_list_to_compact``. + + Parameters + ---------- + compact : + Compact-encoded key bytes. + + Returns + ------- + nibbles : + The decoded nibble sequence. + is_leaf : + ``True`` if the compact encoding indicates a leaf node. + + """ + first_nibble = compact[0] >> 4 + is_leaf = (first_nibble & 0x02) != 0 + odd = (first_nibble & 0x01) != 0 + + nibbles = bytearray() + if odd: + nibbles.append(compact[0] & 0x0F) + for byte in compact[1:]: + nibbles.append(byte >> 4) + nibbles.append(byte & 0x0F) + + return Bytes(nibbles), is_leaf + + +def _resolve_child_ref( + node_db: Dict[Bytes, Bytes], + child_ref: Extended, +) -> MutableNode: + """ + Resolve a child reference from an RLP-decoded trie node. + + Handle three cases: empty string (no child), 32-byte hash + (look up in node_db or create a ``HashedNode``), and inline RLP list + (decode directly). + """ + if isinstance(child_ref, (bytes, bytearray)): + ref_bytes = Bytes(child_ref) + if len(ref_bytes) == 0: + return None + if len(ref_bytes) == 32: + if ref_bytes in node_db: + return _decode_witness_node(node_db, node_db[ref_bytes]) + return HashedNode(_hash=ref_bytes) + return _decode_witness_node(node_db, rlp.encode(ref_bytes)) + else: + return _decode_witness_node(node_db, rlp.encode(child_ref)) + + +def _decode_witness_node( + node_db: Dict[Bytes, Bytes], + rlp_bytes: Bytes, +) -> MutableNode: + """ + Decode an RLP-encoded trie node into a ``MutableNode``. + + Parameters + ---------- + node_db : + Mapping from node hash to RLP-encoded node data. + rlp_bytes : + The RLP-encoded node to decode. + + """ + node_hash: Optional[Bytes] = None + if len(rlp_bytes) >= 32: + node_hash = keccak256(rlp_bytes) + + decoded = rlp.decode(rlp_bytes) + + if isinstance(decoded, (bytes, bytearray)): + return None + + assert isinstance(decoded, list) + + if len(decoded) == 2: + path_bytes = decoded[0] + assert isinstance(path_bytes, (bytes, bytearray)) + nibbles, is_leaf = compact_to_nibbles(Bytes(path_bytes)) + + if is_leaf: + value = decoded[1] + assert isinstance(value, (bytes, bytearray)) + return MutableLeafNode( + rest_of_key=nibbles, + value=Bytes(value), + _hash=node_hash, + _rlp=rlp_bytes, + ) + else: + child = _resolve_child_ref(node_db, decoded[1]) + return MutableExtensionNode( + key_segment=nibbles, + child=child, + _hash=node_hash, + _rlp=rlp_bytes, + ) + + elif len(decoded) == 17: + children: List[Optional[MutableNode]] = [] + for i in range(16): + children.append(_resolve_child_ref(node_db, decoded[i])) + value_raw = decoded[16] + if isinstance(value_raw, (bytes, bytearray)): + value = Bytes(value_raw) + else: + value = b"" + return MutableBranchNode( + children=children, + value=value, + _hash=node_hash, + _rlp=rlp_bytes, + ) + else: + raise AssertionError(f"Invalid RLP node length: {len(decoded)}") + + +def decode_witness_to_mpt( + node_db: Dict[Bytes, Bytes], + root_hash: Root, + secured: bool, + default: V, +) -> IncrementalMPT[K, V]: + """ + Build an ``IncrementalMPT`` from a witness node database. + + Decode the trie starting at ``root_hash``, resolving child + references from ``node_db``. Unknown children become + ``HashedNode`` placeholders. + + Parameters + ---------- + node_db : + Mapping from node hash to RLP-encoded node data. + root_hash : + Root hash of the trie to decode. + secured : + Whether keys are hashed before insertion. + default : + Default value for missing keys. + + Returns + ------- + mpt : `IncrementalMPT[K, V]` + The decoded incremental MPT. + + """ + if root_hash == EMPTY_TRIE_ROOT: + return IncrementalMPT( + secured=secured, + default=default, + root_node=None, + _data={}, + ) + + root_rlp = node_db[root_hash] + root_node = _decode_witness_node(node_db, root_rlp) + + return IncrementalMPT( + secured=secured, + default=default, + root_node=root_node, + _data={}, + ) diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 907642f5834..3958861be28 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -27,6 +27,7 @@ EMPTY_CODE_HASH, Account, Address, + BlockDiff, InternalNode, Root, ) @@ -140,31 +141,22 @@ def close_state(state: State) -> None: del state._code_store -def apply_changes_to_state( - state: State, - account_changes: Dict[Address, Optional[Account]], - storage_changes: Dict[Address, Dict[Bytes32, U256]], - code_changes: Dict[Hash32, Bytes], -) -> None: +def apply_changes_to_state(state: State, diff: BlockDiff) -> None: """ - Apply block-level diffs to the ``State`` for the next block. + Apply block-level diff to the ``State`` for the next block. Parameters ---------- state : The state to update. - account_changes : - Account changes to apply. - storage_changes : - Storage changes to apply. - code_changes : - Code changes to apply. + diff : + Account, storage, and code changes to apply. """ - for address, account in account_changes.items(): + for address, account in diff.account_changes.items(): trie_set(state._main_trie, address, account) - for address, slots in storage_changes.items(): + for address, slots in diff.storage_changes.items(): trie = state._storage_tries.get(address) if trie is None: trie = Trie(secured=True, default=U256(0)) @@ -174,7 +166,7 @@ def apply_changes_to_state( if trie._data == {}: del state._storage_tries[address] - state._code_store.update(code_changes) + state._code_store.update(diff.code_changes) def set_account( diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 0ddb17c2c92..9ffbb19b4b0 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -31,6 +31,7 @@ EMPTY_CODE_HASH, Account, Address, + BlockDiff, PreState, ) @@ -763,15 +764,9 @@ def incorporate_tx_into_block( tx_state.code_reads = set() -def extract_block_diffs( - block_state: BlockState, -) -> Tuple[ - Dict[Address, Optional[Account]], - Dict[Address, Dict[Bytes32, U256]], - Dict[Hash32, Bytes], -]: +def extract_block_diff(block_state: BlockState) -> BlockDiff: """ - Extract account, storage, and code diffs from the block state. + Extract account, storage, and code diff from the block state. Parameters ---------- @@ -780,18 +775,14 @@ def extract_block_diffs( Returns ------- - account_diffs : - Account changes to apply. - storage_diffs : - Storage changes to apply. - code_diffs : - Code changes to apply. + diff : `BlockDiff` + Account, storage, and code changes accumulated during block execution. """ - return ( - block_state.account_writes, - block_state.storage_writes, - block_state.code_writes, + return BlockDiff( + account_changes=block_state.account_writes, + storage_changes=block_state.storage_writes, + code_changes=block_state.code_writes, ) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 330c45ec7b8..6cdd1725096 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -3,19 +3,23 @@ """ from dataclasses import dataclass -from typing import Sequence, Tuple +from typing import List, Sequence, Tuple +from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U64 -from ethereum.crypto.hash import Hash32 +from ethereum.crypto.hash import Hash32, keccak256 from ethereum.state import Root -from .blocks import Block +from .blocks import Block, Header +from .execution_engine.new_payload import execute_new_payload_request from .execution_engine.types import NewPayloadRequest +from .fork import ChainContext from .fork_types import VersionedHash from .stateless_types import ExecutionWitness +from .witness_state import WitnessState, build_code_db, build_node_db # Amsterdam currently carries execution requests as raw bytes in order. ExecutionRequests = Tuple[Bytes, ...] @@ -96,6 +100,9 @@ class StatelessInput: class StatelessValidationResult: """ Result returned by stateless validation. + + Note: We use return values to denote "public inputs" + """ new_payload_request_root: Hash32 @@ -126,20 +133,67 @@ def new_payload_request_to_block( raise NotImplementedError +def validate_headers( + headers: List[Header], + encoded_headers: Tuple[Bytes, ...], +) -> List[Hash32]: + """ + Validate that a sequence of encoded headers forms a contiguous chain. + + Each header's ``parent_hash`` must match the hash of the preceding + header. Return the list of block hashes. + """ + block_hashes: List[Hash32] = [ + keccak256(header_bytes) for header_bytes in encoded_headers + ] + for i in range(1, len(headers)): + if headers[i].parent_hash != block_hashes[i - 1]: + raise Exception("Witness headers are not contiguous") + return block_hashes + + def verify_stateless_new_payload( stateless_input: StatelessInput, ) -> StatelessValidationResult: """ Statelessly validate the execution payload. """ - # TODO: We can fill this in properly once the pre-state PR - # TODO: and state change PRs are completed. - # TODO: We would effectively call `verify_and_notify_new_payload` + witness = stateless_input.witness + + # Validate the headers are contiguous and compute their + # blockhashes + decoded_headers = [ + rlp.decode_to(Header, header_bytes) for header_bytes in witness.headers + ] + block_hashes = validate_headers(decoded_headers, witness.headers) + parent_header = decoded_headers[-1] + + chain_context = ChainContext( + chain_id=stateless_input.chain_config.chain_id, + block_hashes=block_hashes, + parent_header=parent_header, + ) + + pre_state = WitnessState( + _node_db=build_node_db(witness.state), + _state_root=parent_header.state_root, + _code_db=build_code_db(witness.codes), + ) + + try: + execute_new_payload_request( + stateless_input.new_payload_request, + pre_state, + chain_context, + ) + successful_validation = True + except Exception: + successful_validation = False return StatelessValidationResult( new_payload_request_root=compute_new_payload_request_root( stateless_input ), - successful_validation=True, + successful_validation=successful_validation, chain_config=stateless_input.chain_config, ) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py new file mode 100644 index 00000000000..70e19114211 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -0,0 +1,68 @@ +""" +Stateless guest interfaces. +""" + +# ------- IO --------- + +import io +import threading + +from ethereum_types.bytes import Bytes + +from .stateless import ( + StatelessInput, + StatelessValidationResult, + verify_stateless_new_payload, +) + +_local: threading.local = threading.local() + + +def _get_buffer() -> io.BytesIO: + if not hasattr(_local, "buffer"): + _local.buffer = io.BytesIO() + return _local.buffer + + +# TODO: This method is for the host +def write_input(data: Bytes) -> None: + """ + Write bytes as input for the guest to read. + """ + _get_buffer().write(data) + + +# This is a method for the guest +def read_input(n: int) -> Bytes: + """ + Read ``n`` bytes written by ``write_input``. + """ + return Bytes(_get_buffer().read(n)) + + +def serialize_stateless_output(output: StatelessValidationResult) -> Bytes: + """ + Serialize a ``StatelessValidationResult`` to raw bytes. + """ + raise NotImplementedError + + +def deserialize_stateless_input(data: Bytes) -> StatelessInput: + """ + Deserialize a ``StatelessInput`` from raw bytes. + """ + raise NotImplementedError + + +def entrypoint() -> Bytes: + """ + Guest program entry point. + """ + length = int.from_bytes(read_input(4), "big") + input_data = read_input(length) + stateless_input = deserialize_stateless_input(input_data) + + stateless_output = verify_stateless_new_payload(stateless_input) + + output_data = serialize_stateless_output(stateless_output) + return output_data diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py new file mode 100644 index 00000000000..de1b1da4e55 --- /dev/null +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -0,0 +1,309 @@ +""" +Witness-backed PreState. + +Implement the ``PreState`` protocol using execution witness data +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.state import ( + EMPTY_CODE_HASH, + Account, + Address, + InternalNode, + Root, +) + +from .incremental_mpt import ( + IncrementalMPT, + compact_to_nibbles, + decode_witness_to_mpt, + mpt_root, + mpt_set, +) +from .trie import EMPTY_TRIE_ROOT + + +def build_node_db(state_entries: Tuple[Bytes, ...]) -> Dict[Bytes, Bytes]: + """Build hash -> RLP mapping from witness state preimages.""" + db: Dict[Bytes, Bytes] = {} + for entry in state_entries: + db[keccak256(entry)] = entry + return db + + +def build_code_db(code_entries: Tuple[Bytes, ...]) -> Dict[Hash32, Bytes]: + """Build code_hash -> bytecode mapping from witness codes.""" + db: Dict[Hash32, Bytes] = {} + for code in code_entries: + db[keccak256(code)] = code + return db + + +def _resolve_node_ref( + node_db: Dict[Bytes, Bytes], + ref: object, +) -> object: + """ + Resolve a node reference to a decoded RLP object. + + Handle inline nodes (lists), 32-byte hash references (look up + in node_db), and empty bytes (absent node). + """ + if isinstance(ref, list): + return ref + if isinstance(ref, (bytes, bytearray)): + ref_bytes = Bytes(ref) + if len(ref_bytes) == 0: + return None + if len(ref_bytes) == 32: + if ref_bytes not in node_db: + return None + return rlp.decode(node_db[ref_bytes]) + return rlp.decode(ref_bytes) + return None + + +def _trie_lookup( + node_db: Dict[Bytes, Bytes], + root_hash: Root, + key_hash: Hash32, +) -> Optional[Bytes]: + """ + Walk MPT from root following nibblized key_hash. + + Return leaf value or ``None`` if not found. + + Parameters + ---------- + node_db : + Mapping from node hash to RLP-encoded node data. + root_hash : + Root hash of the trie. + key_hash : + Hashed key to look up (will be nibblized). + + """ + if root_hash == EMPTY_TRIE_ROOT: + return None + if root_hash not in node_db: + return None + + nibbles = bytearray() + for byte in key_hash: + nibbles.append(byte >> 4) + nibbles.append(byte & 0x0F) + + node = _resolve_node_ref(node_db, root_hash) + pos = 0 + + while node is not None: + if not isinstance(node, list): + return None + + if len(node) == 2: + path = node[0] + assert isinstance(path, (bytes, bytearray)) + segment, is_leaf = compact_to_nibbles(Bytes(path)) + remaining = bytes(nibbles[pos:]) + + if is_leaf: + if remaining == segment: + value = node[1] + assert isinstance(value, (bytes, bytearray)) + return Bytes(value) + return None + else: + if not remaining[: len(segment)] == segment: + return None + pos += len(segment) + node = _resolve_node_ref(node_db, node[1]) + + elif len(node) == 17: + if pos >= len(nibbles): + value = node[16] + if isinstance(value, (bytes, bytearray)) and len(value) > 0: + return Bytes(value) + return None + idx = nibbles[pos] + pos += 1 + node = _resolve_node_ref(node_db, node[idx]) + else: + return None + + return None + + +def _decode_account_from_leaf( + leaf_value: Bytes, +) -> Tuple[Account, Root]: + """ + Decode (nonce, balance, storage_root, code_hash) from trie leaf. + + Return the ``Account`` and the ``storage_root`` separately + (storage_root is not stored on ``Account``). + """ + decoded = rlp.decode(leaf_value) + assert isinstance(decoded, list) and len(decoded) == 4 + + nonce = Uint(int.from_bytes(decoded[0], "big")) if decoded[0] else Uint(0) + balance = ( + U256(int.from_bytes(decoded[1], "big")) if decoded[1] else U256(0) + ) + storage_root = Root(decoded[2]) if decoded[2] else EMPTY_TRIE_ROOT + code_hash = Hash32(decoded[3]) if decoded[3] else EMPTY_CODE_HASH + + account = Account( + nonce=nonce, + balance=balance, + code_hash=code_hash, + ) + return account, storage_root + + +@dataclass +class WitnessState: + """ + ``PreState`` backed by execution witness data. + + Serve account, storage, and code reads from trie-node + preimages and bytecodes provided in the execution witness. + """ + + _node_db: Dict[Bytes, Bytes] + _state_root: Root + _code_db: Dict[Hash32, Bytes] + _storage_root_cache: Dict[Address, Root] = field(default_factory=dict) + + def get_account_optional(self, address: Address) -> Optional[Account]: + """ + Get the account at an address. + + Return ``None`` if there is no account at the address. + """ + key_hash = keccak256(address) + leaf = _trie_lookup(self._node_db, self._state_root, key_hash) + if leaf is None: + self._storage_root_cache[address] = EMPTY_TRIE_ROOT + return None + account, storage_root = _decode_account_from_leaf(leaf) + self._storage_root_cache[address] = storage_root + return account + + def get_storage(self, address: Address, key: Bytes32) -> U256: + """ + Get a storage value. + + Return ``U256(0)`` if the key has not been set. + """ + if address not in self._storage_root_cache: + self.get_account_optional(address) + storage_root = self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + if storage_root == EMPTY_TRIE_ROOT: + return U256(0) + + key_hash = keccak256(key) + leaf = _trie_lookup(self._node_db, storage_root, key_hash) + if leaf is None: + return U256(0) + + decoded = rlp.decode(leaf) + if isinstance(decoded, (bytes, bytearray)): + if len(decoded) == 0: + return U256(0) + return U256(int.from_bytes(decoded, "big")) + return U256(0) + + def get_code(self, code_hash: Hash32) -> Bytes: + """ + Get the bytecode for a given code hash. + + Return ``b""`` for ``EMPTY_CODE_HASH``. + """ + if code_hash == EMPTY_CODE_HASH: + return b"" + return self._code_db[code_hash] + + def account_has_storage(self, address: Address) -> bool: + """ + Check whether an account has any storage. + + Only needed for EIP-7610. + """ + if address not in self._storage_root_cache: + self.get_account_optional(address) + storage_root = self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + return storage_root != EMPTY_TRIE_ROOT + + def compute_state_root_and_trie_changes( + self, + account_changes: Dict[Address, Optional[Account]], + storage_changes: Dict[Address, Dict[Bytes32, U256]], + ) -> Tuple[Root, List[InternalNode]]: + """ + Compute the state root after applying changes. + + Build partial ``IncrementalMPT`` tries from the witness, + apply diffs, and compute the new root. + """ + new_storage_roots: Dict[Address, Root] = {} + + for address, slots in storage_changes.items(): + if address not in self._storage_root_cache: + self.get_account_optional(address) + old_root = self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + storage_mpt: IncrementalMPT[Bytes32, U256] = decode_witness_to_mpt( + self._node_db, + old_root, + secured=True, + default=U256(0), + ) + for key, value in slots.items(): + mpt_set(storage_mpt, key, value) + new_storage_roots[address] = mpt_root(storage_mpt) + + state_mpt: IncrementalMPT[Address, Optional[Account]] = ( + decode_witness_to_mpt( + self._node_db, + self._state_root, + secured=True, + default=None, + ) + ) + + for address in storage_changes: + if address not in account_changes: + account = self.get_account_optional(address) + if account is not None: + sr = new_storage_roots.get(address, EMPTY_TRIE_ROOT) + + def _sr_fn(_a: Address, _sr: Root = sr) -> Root: + return _sr + + mpt_set( + state_mpt, + address, + account, + get_storage_root=_sr_fn, + ) + + def get_storage_root(addr: Address) -> Root: + if addr in new_storage_roots: + return new_storage_roots[addr] + return self._storage_root_cache.get(addr, EMPTY_TRIE_ROOT) + + for address, account in account_changes.items(): + mpt_set( + state_mpt, + address, + account, + get_storage_root=get_storage_root, + ) + + return mpt_root(state_mpt), [] diff --git a/src/ethereum/state.py b/src/ethereum/state.py index 92d8e69833b..48349700f2b 100644 --- a/src/ethereum/state.py +++ b/src/ethereum/state.py @@ -92,6 +92,22 @@ class BranchNode: InternalNode = LeafNode | ExtensionNode | BranchNode +@dataclass +class BlockDiff: + """ + State changes produced by executing a block. + """ + + account_changes: Dict[Address, Optional[Account]] + """Per-address account diffs produced by execution.""" + + storage_changes: Dict[Address, Dict[Bytes32, U256]] + """Per-address storage diffs produced by execution.""" + + code_changes: Dict[Hash32, Bytes] + """New bytecodes (keyed by code hash) introduced by execution.""" + + class PreState(Protocol): """ Protocol for providing pre-execution state. diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index ffbaa6ad6d9..3793ffac368 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -320,25 +320,18 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # to the older forks from ethereum.forks.amsterdam.state import apply_changes_to_state from ethereum.forks.amsterdam.state_tracker import ( - extract_block_diffs, + extract_block_diff, ) - account_changes, storage_changes, code_changes = ( - extract_block_diffs(t8n._block_state) - ) + block_diff = extract_block_diff(t8n._block_state) state_root_value, _ = ( t8n.alloc.state.compute_state_root_and_trie_changes( - account_changes, storage_changes + block_diff.account_changes, block_diff.storage_changes ) ) self.state_root = state_root_value # Apply diffs to pre-state for alloc output - apply_changes_to_state( - t8n.alloc.state, - account_changes, - storage_changes, - code_changes, - ) + apply_changes_to_state(t8n.alloc.state, block_diff) else: self.state_root = t8n.fork.state_root(block_env.state) self.receipts = self.get_receipts_from_output(t8n, block_output) diff --git a/tests/amsterdam/test_incremental_mpt.py b/tests/amsterdam/test_incremental_mpt.py new file mode 100644 index 00000000000..4df7185544b --- /dev/null +++ b/tests/amsterdam/test_incremental_mpt.py @@ -0,0 +1,597 @@ +"""Tests for the incremental MPT witness decoding and HashedNode.""" + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes + +from ethereum.crypto.hash import keccak256 +from ethereum.forks.amsterdam.incremental_mpt import ( + HashedNode, + IncrementalMPT, + MutableBranchNode, + MutableLeafNode, + build_mpt, + compact_to_nibbles, + decode_witness_to_mpt, + mpt_get, + mpt_root, + mpt_set, +) +from ethereum.forks.amsterdam.trie import ( + EMPTY_TRIE_ROOT, + Trie, + nibble_list_to_compact, + root, + trie_set, +) +from ethereum.state import Root + + +class TestCompactToNibbles: + """Test compact_to_nibbles.""" + + def test_even_leaf(self) -> None: + """Even-length leaf: flag nibble = 0x20.""" + nibbles = Bytes(b"\x01\x02\x03\x04") + compact = nibble_list_to_compact(nibbles, True) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is True + + def test_odd_leaf(self) -> None: + """Odd-length leaf: flag nibble = 0x3X.""" + nibbles = Bytes(b"\x01\x02\x03") + compact = nibble_list_to_compact(nibbles, True) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is True + + def test_even_extension(self) -> None: + """Even-length extension: flag nibble = 0x00.""" + nibbles = Bytes(b"\x0a\x0b") + compact = nibble_list_to_compact(nibbles, False) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is False + + def test_odd_extension(self) -> None: + """Odd-length extension: flag nibble = 0x1X.""" + nibbles = Bytes(b"\x0f") + compact = nibble_list_to_compact(nibbles, False) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is False + + def test_empty_even_leaf(self) -> None: + """Empty nibble list as even leaf.""" + nibbles = Bytes(b"") + compact = nibble_list_to_compact(nibbles, True) + result, is_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert is_leaf is True + + @pytest.mark.parametrize( + "nibbles,is_leaf", + [ + pytest.param(Bytes(bytes(range(16))), True, id="all-nibbles-leaf"), + pytest.param( + Bytes(bytes(range(16))), + False, + id="all-nibbles-ext", + ), + pytest.param(Bytes(b"\x00"), True, id="zero-leaf"), + pytest.param(Bytes(b"\x0f" * 20), True, id="long-leaf"), + ], + ) + def test_roundtrip(self, nibbles: Bytes, is_leaf: bool) -> None: + """Roundtrip compact -> nibbles -> compact.""" + compact = nibble_list_to_compact(nibbles, is_leaf) + result, result_leaf = compact_to_nibbles(compact) + assert result == nibbles + assert result_leaf == is_leaf + + +class TestHashedNode: + """Test HashedNode behavior in the MutableNode functions.""" + + def test_hashed_node_as_child_in_root_computation(self) -> None: + """A hashed node child contributes its stored hash to the root.""" + fake_hash = keccak256(b"some subtree data") + hashed_node = HashedNode(_hash=fake_hash) + + branch = MutableBranchNode( + children=[None] * 16, + value=b"", + _dirty=True, + ) + branch.children[0] = MutableLeafNode( + rest_of_key=Bytes(b"\x01\x02"), + value=b"hello", + _dirty=True, + ) + branch.children[1] = hashed_node + + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=branch, + _data={}, + ) + # Should not raise — hashed node's hash is used directly + result = mpt_root(mpt) + assert isinstance(result, bytes) + assert len(result) == 32 + + def test_insert_into_hashed_node_raises(self) -> None: + """Inserting into a HashedNode raises AssertionError.""" + hashed_node = HashedNode(_hash=b"\x00" * 32) + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=hashed_node, + _data={}, + ) + with pytest.raises(AssertionError, match="Incomplete"): + mpt_set(mpt, b"\x01", b"value") + + def test_delete_from_hashed_node_raises(self) -> None: + """Deleting from a HashedNode raises AssertionError.""" + hashed_node = HashedNode(_hash=b"\x00" * 32) + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=hashed_node, + _data={}, + ) + with pytest.raises(AssertionError, match="Incomplete"): + mpt_set(mpt, b"\x01", b"") + + def test_witness_traversal_skips_hashed_node(self) -> None: + """Witness traversal does not record hashed nodes.""" + hashed_node = HashedNode(_hash=b"\x00" * 32) + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=hashed_node, + _data={}, + ) + # mpt_get traverses for witness but should not crash + result = mpt_get(mpt, b"\x01") + assert result == b"" + assert len(mpt.witness.accessed_nodes) == 0 + + +def _build_trie_and_collect_nodes( + data: dict[Bytes, Bytes], secured: bool +) -> tuple[Root, dict[Bytes, Bytes]]: + """ + Build a standard trie, then build an IncrementalMPT and collect + all witness nodes by traversing every key. + + Return (root_hash, node_db). + """ + from ethereum.forks.amsterdam.incremental_mpt import ( + _encode_mutable_node, + ) + + # Compute the expected root via standard trie + std_trie: Trie[Bytes, Bytes] = Trie(secured=secured, default=b"") + for k, v in data.items(): + trie_set(std_trie, k, v) + expected_root = root(std_trie) + + # Build incremental MPT and collect witness nodes + inc_mpt = build_mpt(data, secured=secured, default=b"") + for k in data: + mpt_get(inc_mpt, k) + + node_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + + # Small root nodes (RLP < 32 bytes) are not recorded in the + # witness because they have no hash. Ensure the root is + # always present in node_db. + if inc_mpt.root_node is not None and expected_root not in node_db: + root_rlp = rlp.encode(_encode_mutable_node(inc_mpt.root_node)) + node_db[keccak256(root_rlp)] = root_rlp + + return expected_root, node_db + + +class TestDecodeWitnessToMpt: + """Test decode_witness_to_mpt with synthetic witness data.""" + + def test_empty_trie(self) -> None: + """Decoding an empty trie returns an empty MPT.""" + node_db: dict[Bytes, Bytes] = {} + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, EMPTY_TRIE_ROOT, secured=False, default=b"" + ) + assert mpt.root_node is None + assert mpt_root(mpt) == EMPTY_TRIE_ROOT + + def test_single_entry(self) -> None: + """Decode a trie with a single key-value pair.""" + data = {b"key1": b"value1"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_multiple_entries(self) -> None: + """Decode a trie with multiple entries and verify root.""" + data = { + b"do": b"verb", + b"dog": b"puppy", + b"doge": b"coin", + b"horse": b"stallion", + } + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_secured_trie(self) -> None: + """Decode a secured (hashed-key) trie.""" + data = { + b"account1": b"data1", + b"account2": b"data2", + b"account3": b"data3", + } + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=True + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=True, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_decode_then_modify(self) -> None: + """Decode from witness, modify a key, verify new root.""" + data = {b"aa": b"val_a", b"ab": b"val_b", b"ba": b"val_c"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + # Modify a key + mpt_set(mpt, b"aa", b"new_val") + + # Build expected trie with the modification + data_modified = dict(data) + data_modified[b"aa"] = b"new_val" + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_modified.items(): + trie_set(std_trie, k, v) + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + def test_decode_then_delete(self) -> None: + """Decode from witness, delete a key, verify new root.""" + data = {b"aa": b"val_a", b"ab": b"val_b"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + + # Delete a key + mpt_set(mpt, b"aa", b"") + + # Build expected trie with the deletion + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + trie_set(std_trie, b"ab", b"val_b") + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + def test_decode_then_insert(self) -> None: + """Decode from witness, insert a new key, verify root.""" + data = {b"aa": b"val_a", b"ab": b"val_b"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + + # Insert a new key + mpt_set(mpt, b"ac", b"val_c") + + # Build expected trie with the insertion + data_with_insert = dict(data) + data_with_insert[b"ac"] = b"val_c" + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_with_insert.items(): + trie_set(std_trie, k, v) + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + +class TestPartialWitness: + """Test decode_witness_to_mpt with incomplete witness data.""" + + def test_partial_witness_preserves_root(self) -> None: + """ + Build a trie, collect witness for only some keys. + Decode from partial witness. Root should still match + because hashed nodes preserve hashes of unvisited subtrees. + """ + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + # Build the full trie to get the root + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data.items(): + trie_set(std_trie, k, v) + expected_root = root(std_trie) + + # Build incremental MPT but only access some keys + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + # Intentionally NOT accessing b"ba" and b"bb" + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + + # Also need the root node itself + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, expected_root, secured=False, default=b"" + ) + + # Root should match even with hashed nodes for b"ba"/b"bb" + assert mpt_root(decoded_mpt) == expected_root + + def test_partial_witness_modify_known_path(self) -> None: + """ + Decode from partial witness, modify a key on a known path. + The hashed node subtrees should remain intact. + """ + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + # Full trie for expected roots + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data.items(): + trie_set(std_trie, k, v) + + # Build incremental MPT, access only "a*" keys + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, root_hash, secured=False, default=b"" + ) + + # Modify a known key + mpt_set(decoded_mpt, b"aa", b"new_a") + + # Build expected root with modification + data_mod = dict(data) + data_mod[b"aa"] = b"new_a" + mod_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_mod.items(): + trie_set(mod_trie, k, v) + expected_mod_root = root(mod_trie) + + assert mpt_root(decoded_mpt) == expected_mod_root + + def test_partial_witness_insert_into_hashed_node_fails(self) -> None: + """ + Decode from partial witness, try to modify a key in the + hashed node region. Should raise AssertionError. + """ + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, root_hash, secured=False, default=b"" + ) + + # Try to insert into the hashed node subtree + with pytest.raises(AssertionError, match="Incomplete"): + mpt_set(decoded_mpt, b"ba", b"new_c") + + +def _encode_root_for_db( + node: object, +) -> object: + """Encode a MutableNode root into its RLP-encodable form.""" + from ethereum.forks.amsterdam.incremental_mpt import ( + _encode_mutable_node, + ) + + return _encode_mutable_node(node) # type: ignore[arg-type] + + +class TestBuildVsDecode: + """ + Verify that decode_witness_to_mpt produces tries equivalent to + build_mpt by checking root hashes after identical mutations. + """ + + @pytest.mark.parametrize( + "data", + [ + pytest.param({b"a": b"1"}, id="single"), + pytest.param({b"a": b"1", b"b": b"2"}, id="two-keys"), + pytest.param( + { + b"do": b"verb", + b"dog": b"puppy", + b"doge": b"coin", + b"horse": b"stallion", + }, + id="ethereum-example", + ), + pytest.param( + {bytes([i]): bytes([i]) for i in range(20)}, + id="many-keys", + ), + ], + ) + def test_roots_match_after_mutation( + self, data: dict[Bytes, Bytes] + ) -> None: + """Build and decode produce same root after same mutations.""" + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + # Decode + decoded: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(decoded) == expected_root + + # Build fresh + built = build_mpt(data, secured=False, default=b"") + assert mpt_root(built) == expected_root + + # Now mutate both identically — add a new key + new_key = b"\xff" + new_val = b"new" + mpt_set(decoded, new_key, new_val) + mpt_set(built, new_key, new_val) + + assert mpt_root(decoded) == mpt_root(built) + + +class TestDecodeEdgeCases: + """Test decode_witness_to_mpt edge cases.""" + + def test_branch_with_value(self) -> None: + """ + Decode a trie where a key terminates at a branch node. + + Keys "ab" and "abc" coexist, so "ab" occupies the branch + value slot (index 16) rather than a leaf child. + """ + data = {b"ab": b"short", b"abc": b"longer"} + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=False + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=False, default=b"" + ) + assert mpt_root(mpt) == expected_root + + def test_secured_trie_modify_and_delete(self) -> None: + """Modify and delete keys in a secured (hashed-key) trie.""" + data = { + b"account1": b"data1", + b"account2": b"data2", + b"account3": b"data3", + } + expected_root, node_db = _build_trie_and_collect_nodes( + data, secured=True + ) + + mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + node_db, expected_root, secured=True, default=b"" + ) + + # Modify one key, delete another + mpt_set(mpt, b"account1", b"updated") + mpt_set(mpt, b"account3", b"") + + # Build expected trie with same changes + std_trie: Trie[Bytes, Bytes] = Trie(secured=True, default=b"") + trie_set(std_trie, b"account1", b"updated") + trie_set(std_trie, b"account2", b"data2") + new_expected_root = root(std_trie) + + assert mpt_root(mpt) == new_expected_root + + def test_partial_witness_delete_known_path(self) -> None: + """Delete a key on a known path while hashed nodes exist elsewhere.""" + data = { + b"aa": b"val_a", + b"ab": b"val_b", + b"ba": b"val_c", + b"bb": b"val_d", + } + + # Build incremental MPT, access only "a*" keys + inc_mpt = build_mpt(data, secured=False, default=b"") + mpt_get(inc_mpt, b"aa") + mpt_get(inc_mpt, b"ab") + + partial_db: dict[Bytes, Bytes] = dict(inc_mpt.witness.accessed_nodes) + root_rlp = rlp.encode( + _encode_root_for_db(inc_mpt.root_node) # type: ignore[arg-type] + ) + root_hash = Root(keccak256(root_rlp)) + partial_db[root_hash] = root_rlp + + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + partial_db, root_hash, secured=False, default=b"" + ) + + # Delete a known key + mpt_set(decoded_mpt, b"aa", b"") + + # Build expected root with the deletion + data_del = dict(data) + del data_del[b"aa"] + std_trie: Trie[Bytes, Bytes] = Trie(secured=False, default=b"") + for k, v in data_del.items(): + trie_set(std_trie, k, v) + expected_del_root = root(std_trie) + + assert mpt_root(decoded_mpt) == expected_del_root diff --git a/tests/amsterdam/test_witness_state.py b/tests/amsterdam/test_witness_state.py new file mode 100644 index 00000000000..804f66b8610 --- /dev/null +++ b/tests/amsterdam/test_witness_state.py @@ -0,0 +1,238 @@ +"""Tests for WitnessState.""" + +from typing import Optional + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.amsterdam.incremental_mpt import ( + IncrementalMPT, + build_mpt, + mpt_get, + mpt_root, +) +from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT +from ethereum.forks.amsterdam.witness_state import ( + WitnessState, + build_code_db, + build_node_db, +) +from ethereum.state import EMPTY_CODE_HASH, Account, Address, Root + +_ADDR1 = Address(b"\x01" * 20) +_ADDR2 = Address(b"\x02" * 20) + +_CODE = b"\x60\x00\x56" +_CODE_HASH = Hash32(keccak256(_CODE)) + +_SLOT1 = Bytes32(b"\x00" * 31 + b"\x01") +_SLOT2 = Bytes32(b"\x00" * 31 + b"\x02") + + +def _acct(balance: int = 0, nonce: int = 1) -> Account: + return Account( + nonce=Uint(nonce), balance=U256(balance), code_hash=EMPTY_CODE_HASH + ) + + +def _build_witness( + accounts: dict[Address, Optional[Account]], + storage: dict[Address, dict[Bytes32, U256]], +) -> tuple[Root, dict[Bytes, Bytes]]: + """Build a (state_root, node_db) witness from account and storage data.""" + storage_mpts: dict[Address, IncrementalMPT[Bytes32, U256]] = {} + for addr, slots in storage.items(): + smpt: IncrementalMPT[Bytes32, U256] = build_mpt( + slots, secured=True, default=U256(0) + ) + for key in slots: + mpt_get(smpt, key) + storage_mpts[addr] = smpt + + def get_storage_root(addr: Address) -> Root: + if addr in storage_mpts: + return mpt_root(storage_mpts[addr]) + return EMPTY_TRIE_ROOT + + account_mpt: IncrementalMPT[Address, Optional[Account]] = build_mpt( + accounts, secured=True, default=None, get_storage_root=get_storage_root + ) + for addr in accounts: + mpt_get(account_mpt, addr) + + state_root = mpt_root(account_mpt) + node_db: dict[Bytes, Bytes] = dict(account_mpt.witness.accessed_nodes) + for smpt in storage_mpts.values(): + node_db.update(smpt.witness.accessed_nodes) + + return state_root, node_db + + +def _make_ws( + accounts: dict[Address, Optional[Account]], + storage: dict[Address, dict[Bytes32, U256]] | None = None, + code_db: dict[Hash32, Bytes] | None = None, +) -> WitnessState: + """Build a WitnessState from account and storage data.""" + state_root, node_db = _build_witness(accounts, storage or {}) + return WitnessState( + _node_db=node_db, _state_root=state_root, _code_db=code_db or {} + ) + + +class TestBuildNodeDb: + """Test build_node_db.""" + + def test_empty(self) -> None: + """Empty input produces empty mapping.""" + assert build_node_db(()) == {} + + def test_single_entry(self) -> None: + """Each entry is keyed by its keccak256 hash.""" + data = b"some_rlp_node_data_long_enough_to_be_realistic" + db = build_node_db((data,)) + assert db == {keccak256(data): data} + + def test_multiple_entries(self) -> None: + """Multiple entries all appear in the mapping.""" + a, b = b"node_aaa", b"node_bbb" + db = build_node_db((a, b)) + assert db[keccak256(a)] == a + assert db[keccak256(b)] == b + + +class TestBuildCodeDb: + """Test build_code_db.""" + + def test_empty(self) -> None: + """Empty input produces empty mapping.""" + assert build_code_db(()) == {} + + def test_single_entry(self) -> None: + """Entry is keyed by code hash.""" + db = build_code_db((_CODE,)) + assert db == {_CODE_HASH: _CODE} + + def test_multiple_entries(self) -> None: + """Multiple bytecodes all appear.""" + code2 = b"\x60\x01\x56" + db = build_code_db((_CODE, code2)) + assert db[keccak256(_CODE)] == _CODE + assert db[keccak256(code2)] == code2 + + +class TestGetAccountOptional: + """Test WitnessState.get_account_optional.""" + + def test_existing_account(self) -> None: + """Returns the account stored in the trie.""" + ws = _make_ws({_ADDR1: _acct(balance=1000, nonce=5)}) + result = ws.get_account_optional(_ADDR1) + assert result is not None + assert result.nonce == Uint(5) + assert result.balance == U256(1000) + assert result.code_hash == EMPTY_CODE_HASH + + def test_missing_account(self) -> None: + """Returns None for an address not in the trie.""" + ws = _make_ws({_ADDR1: _acct()}) + assert ws.get_account_optional(_ADDR2) is None + + def test_multiple_accounts(self) -> None: + """Correctly distinguishes between multiple accounts.""" + ws = _make_ws({_ADDR1: _acct(balance=100), _ADDR2: _acct(balance=200)}) + r1 = ws.get_account_optional(_ADDR1) + r2 = ws.get_account_optional(_ADDR2) + assert r1 is not None and r1.balance == U256(100) + assert r2 is not None and r2.balance == U256(200) + + +class TestGetStorage: + """Test WitnessState.get_storage.""" + + def test_existing_slot(self) -> None: + """Returns the storage value for a known slot.""" + ws = _make_ws({_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}}) + assert ws.get_storage(_ADDR1, _SLOT1) == U256(42) + + def test_missing_slot(self) -> None: + """Returns U256(0) for a slot not in the trie.""" + ws = _make_ws({_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}}) + assert ws.get_storage(_ADDR1, _SLOT2) == U256(0) + + def test_no_storage_account(self) -> None: + """Returns U256(0) for an account with no storage.""" + ws = _make_ws({_ADDR1: _acct(balance=100)}) + assert ws.get_storage(_ADDR1, _SLOT1) == U256(0) + + def test_multiple_slots(self) -> None: + """Correctly distinguishes between multiple storage slots.""" + ws = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(10), _SLOT2: U256(20)}} + ) + assert ws.get_storage(_ADDR1, _SLOT1) == U256(10) + assert ws.get_storage(_ADDR1, _SLOT2) == U256(20) + + +class TestGetCode: + """Test WitnessState.get_code.""" + + def test_empty_code_hash(self) -> None: + """EMPTY_CODE_HASH always returns b'' without a lookup.""" + ws = _make_ws({}) + assert ws.get_code(EMPTY_CODE_HASH) == b"" + + def test_known_code(self) -> None: + """Returns the bytecode for a known code hash.""" + ws = _make_ws({}, code_db=build_code_db((_CODE,))) + assert ws.get_code(_CODE_HASH) == _CODE + + +class TestAccountHasStorage: + """Test WitnessState.account_has_storage.""" + + def test_with_storage(self) -> None: + """Returns True for an account that has a non-empty storage trie.""" + ws = _make_ws({_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(1)}}) + assert ws.account_has_storage(_ADDR1) is True + + def test_without_storage(self) -> None: + """Returns False for an account with an empty storage trie.""" + ws = _make_ws({_ADDR1: _acct(balance=100)}) + assert ws.account_has_storage(_ADDR1) is False + + +class TestComputeStateRoot: + """Test WitnessState.compute_state_root_and_trie_changes.""" + + def test_account_balance_change(self) -> None: + """Changing an account balance produces the correct new state root.""" + ws = _make_ws({_ADDR1: _acct(balance=100)}) + new_acct = _acct(balance=200) + new_root, _ = ws.compute_state_root_and_trie_changes( + {_ADDR1: new_acct}, {} + ) + expected_root, _ = _build_witness({_ADDR1: new_acct}, {}) + assert new_root == expected_root + + def test_storage_slot_change(self) -> None: + """Changing a storage slot produces the correct new state root.""" + acct = _acct() + ws = _make_ws({_ADDR1: acct}, {_ADDR1: {_SLOT1: U256(10)}}) + new_root, _ = ws.compute_state_root_and_trie_changes( + {}, {_ADDR1: {_SLOT1: U256(99)}} + ) + expected_root, _ = _build_witness( + {_ADDR1: acct}, {_ADDR1: {_SLOT1: U256(99)}} + ) + assert new_root == expected_root + + def test_no_changes_preserves_root(self) -> None: + """Empty diffs leave the state root unchanged.""" + state_root, node_db = _build_witness({_ADDR1: _acct(balance=100)}, {}) + ws = WitnessState( + _node_db=node_db, _state_root=state_root, _code_db={} + ) + new_root, _ = ws.compute_state_root_and_trie_changes({}, {}) + assert new_root == state_root From 977109adaa6467b5bcfdcef427d55963c7172f5b Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 13:38:02 +0000 Subject: [PATCH 032/265] add serialization --- .../forks/amsterdam/stateless_guest.py | 20 ++- tests/amsterdam/test_stateless_guest.py | 162 ++++++++++++++++++ 2 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 tests/amsterdam/test_stateless_guest.py diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 70e19114211..ec5c614f45d 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -7,6 +7,7 @@ import io import threading +from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from .stateless import ( @@ -42,16 +43,27 @@ def read_input(n: int) -> Bytes: def serialize_stateless_output(output: StatelessValidationResult) -> Bytes: """ - Serialize a ``StatelessValidationResult`` to raw bytes. + Serialize a ``StatelessValidationResult`` to RLP-encoded bytes. + TODO: change to ssz, rlp was easier to get working with codebase. """ - raise NotImplementedError + return Bytes(rlp.encode(output)) + + +# TODO: This method is for the host +def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: + """ + Serialize a ``StatelessInput`` to RLP-encoded bytes. + TODO: change to ssz, rlp was easier to get working with codebase. + """ + return Bytes(rlp.encode(stateless_input)) def deserialize_stateless_input(data: Bytes) -> StatelessInput: """ - Deserialize a ``StatelessInput`` from raw bytes. + Deserialize a ``StatelessInput`` from RLP-encoded bytes. + TODO: change to ssz, rlp was easier to get working with codebase. """ - raise NotImplementedError + return rlp.decode_to(StatelessInput, data) def entrypoint() -> Bytes: diff --git a/tests/amsterdam/test_stateless_guest.py b/tests/amsterdam/test_stateless_guest.py new file mode 100644 index 00000000000..0508ba1d48c --- /dev/null +++ b/tests/amsterdam/test_stateless_guest.py @@ -0,0 +1,162 @@ +"""Tests for stateless_guest serialization roundtrip.""" + +import random +from typing import Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32 +from ethereum.forks.amsterdam.execution_engine.types import ( + ExecutionPayload, + NewPayloadRequest, +) +from ethereum.forks.amsterdam.fork_types import Bloom +from ethereum.forks.amsterdam.stateless import ( + ChainConfig, + StatelessInput, + StatelessValidationResult, +) +from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + serialize_stateless_input, + serialize_stateless_output, +) +from ethereum.forks.amsterdam.stateless_types import ExecutionWitness +from ethereum.state import Address, Root + +_RNG = random.Random(0xDEADBEEF) + + +def _rb(n: int) -> bytes: + """Return ``n`` pseudo-random bytes.""" + return bytes(_RNG.getrandbits(8) for _ in range(n)) + + +def _make_payload() -> ExecutionPayload: + return ExecutionPayload( + parent_hash=Hash32(_rb(32)), + fee_recipient=Address(_rb(20)), + state_root=Root(_rb(32)), + receipts_root=Root(_rb(32)), + logs_bloom=Bloom(_rb(256)), + prev_randao=Bytes32(_rb(32)), + block_number=Uint(_RNG.randint(1, 2**32)), + gas_limit=Uint(30_000_000), + gas_used=Uint(_RNG.randint(0, 20_000_000)), + timestamp=U256(_RNG.randint(1, 2**32)), + extra_data=Bytes(_rb(32)), + base_fee_per_gas=Uint(_RNG.randint(1, 10**9)), + block_hash=Hash32(_rb(32)), + transactions=(Bytes(_rb(64)), Bytes(_rb(128))), + withdrawals=(), + blob_gas_used=U64(_RNG.randint(0, 2**17)), + excess_blob_gas=U64(_RNG.randint(0, 2**17)), + block_access_list=Bytes(_rb(16)), + ) + + +def _make_stateless_input() -> StatelessInput: + versioned_hashes: Tuple[Hash32, ...] = (Hash32(_rb(32)), Hash32(_rb(32))) + return StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=_make_payload(), + versioned_hashes=versioned_hashes, + parent_beacon_block_root=Root(_rb(32)), + execution_requests=(Bytes(_rb(48)), Bytes(_rb(48))), + ), + witness=ExecutionWitness( + state=(Bytes(_rb(64)), Bytes(_rb(64)), Bytes(_rb(32))), + codes=(Bytes(_rb(48)), Bytes(_rb(96))), + headers=(Bytes(_rb(512)), Bytes(_rb(512))), + ), + chain_config=ChainConfig(chain_id=U64(1)), + public_keys=(Bytes(_rb(33)), Bytes(_rb(33))), + ) + + +def _make_stateless_output() -> StatelessValidationResult: + return StatelessValidationResult( + new_payload_request_root=Hash32(_rb(32)), + successful_validation=True, + chain_config=ChainConfig(chain_id=U64(1)), + ) + + +class TestSerializeStatelessInput: + """Test serialize_stateless_input.""" + + def test_roundtrip(self) -> None: + """Encoding then decoding recovers the original StatelessInput.""" + original = _make_stateless_input() + encoded = serialize_stateless_input(original) + recovered = deserialize_stateless_input(encoded) + assert recovered == original + + def test_empty_witness(self) -> None: + """Works with an empty witness.""" + original = StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=_make_payload(), + versioned_hashes=(), + parent_beacon_block_root=Root(_rb(32)), + execution_requests=(), + ), + witness=ExecutionWitness(state=(), codes=(), headers=()), + chain_config=ChainConfig(chain_id=U64(1)), + public_keys=(), + ) + encoded = serialize_stateless_input(original) + recovered = deserialize_stateless_input(encoded) + assert recovered == original + + +class TestDeserializeStatelessInput: + """Test deserialize_stateless_input.""" + + def test_roundtrip(self) -> None: + """Encoding then decoding recovers the original StatelessInput.""" + original = _make_stateless_input() + encoded = rlp.encode(original) + recovered = deserialize_stateless_input(Bytes(encoded)) + assert recovered == original + + def test_empty_witness(self) -> None: + """Works with an empty witness.""" + original = StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=_make_payload(), + versioned_hashes=(), + parent_beacon_block_root=Root(_rb(32)), + execution_requests=(), + ), + witness=ExecutionWitness(state=(), codes=(), headers=()), + chain_config=ChainConfig(chain_id=U64(1)), + public_keys=(), + ) + recovered = deserialize_stateless_input(Bytes(rlp.encode(original))) + assert recovered == original + + +class TestSerializeStatelessOutput: + """Test serialize_stateless_output.""" + + def test_roundtrip(self) -> None: + """Encoding then decoding recovers the original result.""" + original = _make_stateless_output() + encoded = serialize_stateless_output(original) + recovered = rlp.decode_to(StatelessValidationResult, encoded) + assert recovered == original + + def test_failed_validation(self) -> None: + """Serializes a failed validation result correctly.""" + original = StatelessValidationResult( + new_payload_request_root=Hash32(_rb(32)), + successful_validation=False, + chain_config=ChainConfig(chain_id=U64(1)), + ) + recovered = rlp.decode_to( + StatelessValidationResult, serialize_stateless_output(original) + ) + assert recovered == original From 3c1e149fb4756d7bf3926192cebb069b4aa8c01c Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 13:40:09 +0000 Subject: [PATCH 033/265] spelling --- tests/amsterdam/test_witness_state.py | 78 +++++++++++++++------------ 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/tests/amsterdam/test_witness_state.py b/tests/amsterdam/test_witness_state.py index 804f66b8610..28ee014c73e 100644 --- a/tests/amsterdam/test_witness_state.py +++ b/tests/amsterdam/test_witness_state.py @@ -43,12 +43,12 @@ def _build_witness( """Build a (state_root, node_db) witness from account and storage data.""" storage_mpts: dict[Address, IncrementalMPT[Bytes32, U256]] = {} for addr, slots in storage.items(): - smpt: IncrementalMPT[Bytes32, U256] = build_mpt( + storage_mpt: IncrementalMPT[Bytes32, U256] = build_mpt( slots, secured=True, default=U256(0) ) for key in slots: - mpt_get(smpt, key) - storage_mpts[addr] = smpt + mpt_get(storage_mpt, key) + storage_mpts[addr] = storage_mpt def get_storage_root(addr: Address) -> Root: if addr in storage_mpts: @@ -63,8 +63,8 @@ def get_storage_root(addr: Address) -> Root: state_root = mpt_root(account_mpt) node_db: dict[Bytes, Bytes] = dict(account_mpt.witness.accessed_nodes) - for smpt in storage_mpts.values(): - node_db.update(smpt.witness.accessed_nodes) + for storage_mpt in storage_mpts.values(): + node_db.update(storage_mpt.witness.accessed_nodes) return state_root, node_db @@ -127,8 +127,8 @@ class TestGetAccountOptional: def test_existing_account(self) -> None: """Returns the account stored in the trie.""" - ws = _make_ws({_ADDR1: _acct(balance=1000, nonce=5)}) - result = ws.get_account_optional(_ADDR1) + witness_state = _make_ws({_ADDR1: _acct(balance=1000, nonce=5)}) + result = witness_state.get_account_optional(_ADDR1) assert result is not None assert result.nonce == Uint(5) assert result.balance == U256(1000) @@ -136,14 +136,16 @@ def test_existing_account(self) -> None: def test_missing_account(self) -> None: """Returns None for an address not in the trie.""" - ws = _make_ws({_ADDR1: _acct()}) - assert ws.get_account_optional(_ADDR2) is None + witness_state = _make_ws({_ADDR1: _acct()}) + assert witness_state.get_account_optional(_ADDR2) is None def test_multiple_accounts(self) -> None: """Correctly distinguishes between multiple accounts.""" - ws = _make_ws({_ADDR1: _acct(balance=100), _ADDR2: _acct(balance=200)}) - r1 = ws.get_account_optional(_ADDR1) - r2 = ws.get_account_optional(_ADDR2) + witness_state = _make_ws( + {_ADDR1: _acct(balance=100), _ADDR2: _acct(balance=200)} + ) + r1 = witness_state.get_account_optional(_ADDR1) + r2 = witness_state.get_account_optional(_ADDR2) assert r1 is not None and r1.balance == U256(100) assert r2 is not None and r2.balance == U256(200) @@ -153,26 +155,30 @@ class TestGetStorage: def test_existing_slot(self) -> None: """Returns the storage value for a known slot.""" - ws = _make_ws({_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}}) - assert ws.get_storage(_ADDR1, _SLOT1) == U256(42) + witness_state = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}} + ) + assert witness_state.get_storage(_ADDR1, _SLOT1) == U256(42) def test_missing_slot(self) -> None: """Returns U256(0) for a slot not in the trie.""" - ws = _make_ws({_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}}) - assert ws.get_storage(_ADDR1, _SLOT2) == U256(0) + witness_state = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(42)}} + ) + assert witness_state.get_storage(_ADDR1, _SLOT2) == U256(0) def test_no_storage_account(self) -> None: """Returns U256(0) for an account with no storage.""" - ws = _make_ws({_ADDR1: _acct(balance=100)}) - assert ws.get_storage(_ADDR1, _SLOT1) == U256(0) + witness_state = _make_ws({_ADDR1: _acct(balance=100)}) + assert witness_state.get_storage(_ADDR1, _SLOT1) == U256(0) def test_multiple_slots(self) -> None: """Correctly distinguishes between multiple storage slots.""" - ws = _make_ws( + witness_state = _make_ws( {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(10), _SLOT2: U256(20)}} ) - assert ws.get_storage(_ADDR1, _SLOT1) == U256(10) - assert ws.get_storage(_ADDR1, _SLOT2) == U256(20) + assert witness_state.get_storage(_ADDR1, _SLOT1) == U256(10) + assert witness_state.get_storage(_ADDR1, _SLOT2) == U256(20) class TestGetCode: @@ -180,13 +186,13 @@ class TestGetCode: def test_empty_code_hash(self) -> None: """EMPTY_CODE_HASH always returns b'' without a lookup.""" - ws = _make_ws({}) - assert ws.get_code(EMPTY_CODE_HASH) == b"" + witness_state = _make_ws({}) + assert witness_state.get_code(EMPTY_CODE_HASH) == b"" def test_known_code(self) -> None: """Returns the bytecode for a known code hash.""" - ws = _make_ws({}, code_db=build_code_db((_CODE,))) - assert ws.get_code(_CODE_HASH) == _CODE + witness_state = _make_ws({}, code_db=build_code_db((_CODE,))) + assert witness_state.get_code(_CODE_HASH) == _CODE class TestAccountHasStorage: @@ -194,13 +200,15 @@ class TestAccountHasStorage: def test_with_storage(self) -> None: """Returns True for an account that has a non-empty storage trie.""" - ws = _make_ws({_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(1)}}) - assert ws.account_has_storage(_ADDR1) is True + witness_state = _make_ws( + {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(1)}} + ) + assert witness_state.account_has_storage(_ADDR1) is True def test_without_storage(self) -> None: """Returns False for an account with an empty storage trie.""" - ws = _make_ws({_ADDR1: _acct(balance=100)}) - assert ws.account_has_storage(_ADDR1) is False + witness_state = _make_ws({_ADDR1: _acct(balance=100)}) + assert witness_state.account_has_storage(_ADDR1) is False class TestComputeStateRoot: @@ -208,9 +216,9 @@ class TestComputeStateRoot: def test_account_balance_change(self) -> None: """Changing an account balance produces the correct new state root.""" - ws = _make_ws({_ADDR1: _acct(balance=100)}) + witness_state = _make_ws({_ADDR1: _acct(balance=100)}) new_acct = _acct(balance=200) - new_root, _ = ws.compute_state_root_and_trie_changes( + new_root, _ = witness_state.compute_state_root_and_trie_changes( {_ADDR1: new_acct}, {} ) expected_root, _ = _build_witness({_ADDR1: new_acct}, {}) @@ -219,8 +227,8 @@ def test_account_balance_change(self) -> None: def test_storage_slot_change(self) -> None: """Changing a storage slot produces the correct new state root.""" acct = _acct() - ws = _make_ws({_ADDR1: acct}, {_ADDR1: {_SLOT1: U256(10)}}) - new_root, _ = ws.compute_state_root_and_trie_changes( + witness_state = _make_ws({_ADDR1: acct}, {_ADDR1: {_SLOT1: U256(10)}}) + new_root, _ = witness_state.compute_state_root_and_trie_changes( {}, {_ADDR1: {_SLOT1: U256(99)}} ) expected_root, _ = _build_witness( @@ -231,8 +239,8 @@ def test_storage_slot_change(self) -> None: def test_no_changes_preserves_root(self) -> None: """Empty diffs leave the state root unchanged.""" state_root, node_db = _build_witness({_ADDR1: _acct(balance=100)}, {}) - ws = WitnessState( + witness_state = WitnessState( _node_db=node_db, _state_root=state_root, _code_db={} ) - new_root, _ = ws.compute_state_root_and_trie_changes({}, {}) + new_root, _ = witness_state.compute_state_root_and_trie_changes({}, {}) assert new_root == state_root From 0de13638e55227ad86a90d31bc28558430a1c9be Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 14:00:28 +0000 Subject: [PATCH 034/265] update --- src/ethereum/forks/amsterdam/stateless.py | 5 +- .../forks/amsterdam/stateless_guest.py | 30 +++- tests/amsterdam/test_stateless_entrypoint.py | 159 ++++++++++++++++++ 3 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tests/amsterdam/test_stateless_entrypoint.py diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 6cdd1725096..cafadb899b1 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -116,12 +116,13 @@ def compute_new_payload_request_root( """ Compute the request root for a stateless input. - TODO: Replace this with ``new_payload_request.tree_hash_root``. + TODO: Replace with ``new_payload_request.tree_hash_root`` (SSZ). # For readability, we can convert to NewPayloadRequestHeader and # then the payload request root. + # Using rlp for now since its available """ - raise NotImplementedError + return keccak256(rlp.encode(stateless_input.new_payload_request)) def new_payload_request_to_block( diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index ec5c614f45d..b6e186b2edb 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -26,19 +26,34 @@ def _get_buffer() -> io.BytesIO: # TODO: This method is for the host -def write_input(data: Bytes) -> None: +def write_input_bytes(data: Bytes) -> None: """ - Write bytes as input for the guest to read. + Write bytes as input for the guest to read, prefixed with a 4-byte + big-endian length. """ + _get_buffer().write(len(data).to_bytes(4, "big")) _get_buffer().write(data) +# TODO: This method is for the host +def rewind_input() -> None: + """ + Seek the input buffer back to the start so the guest can read it. + + Call this after all ``write_input_bytes`` calls and before ``entrypoint``. + """ + _get_buffer().seek(0) + + # This is a method for the guest -def read_input(n: int) -> Bytes: +def read_input_bytes() -> Bytes: """ - Read ``n`` bytes written by ``write_input``. + Read the input written by ``write_input``. + + Reads the 4-byte big-endian length prefix, then returns that many bytes. """ - return Bytes(_get_buffer().read(n)) + length = int.from_bytes(_get_buffer().read(4), "big") + return Bytes(_get_buffer().read(length)) def serialize_stateless_output(output: StatelessValidationResult) -> Bytes: @@ -66,12 +81,13 @@ def deserialize_stateless_input(data: Bytes) -> StatelessInput: return rlp.decode_to(StatelessInput, data) +# TODO: We could just have this be a method that takes in bytes and +# returns bytes def entrypoint() -> Bytes: """ Guest program entry point. """ - length = int.from_bytes(read_input(4), "big") - input_data = read_input(length) + input_data = read_input_bytes() stateless_input = deserialize_stateless_input(input_data) stateless_output = verify_stateless_new_payload(stateless_input) diff --git a/tests/amsterdam/test_stateless_entrypoint.py b/tests/amsterdam/test_stateless_entrypoint.py new file mode 100644 index 00000000000..e0b3463d77b --- /dev/null +++ b/tests/amsterdam/test_stateless_entrypoint.py @@ -0,0 +1,159 @@ +"""End-to-end test for stateless_guest.entrypoint.""" + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.amsterdam.blocks import Header +from ethereum.forks.amsterdam.execution_engine.types import ( + ExecutionPayload, + NewPayloadRequest, +) +from ethereum.forks.amsterdam.execution_engine.validation_helpers import ( + _payload_header, +) +from ethereum.forks.amsterdam.fork import EMPTY_OMMER_HASH +from ethereum.forks.amsterdam.fork_types import Bloom +from ethereum.forks.amsterdam.requests import compute_requests_hash +from ethereum.forks.amsterdam.stateless import ( + ChainConfig, + StatelessInput, + StatelessValidationResult, +) +from ethereum.forks.amsterdam.stateless_guest import ( + entrypoint, + rewind_input, + serialize_stateless_input, + write_input_bytes, +) +from ethereum.forks.amsterdam.stateless_types import ExecutionWitness +from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT +from ethereum.state import Address, Root + +_ZERO_HASH = Hash32(b"\x00" * 32) +_ZERO_ROOT = Root(b"\x00" * 32) +_ZERO_ADDR = Address(b"\x00" * 20) +_ZERO_BLOOM = Bloom(b"\x00" * 256) +_ZERO_BYTES32 = Bytes32(b"\x00" * 32) +_ZERO_BYTES8 = Bytes8(b"\x00" * 8) + +_EMPTY_REQUESTS_HASH = Hash32(compute_requests_hash([])) +_EMPTY_ACCESS_LIST_HASH = Hash32(keccak256(b"")) + + +def _make_parent_header() -> Header: + """Build a minimal parent header with empty state.""" + return Header( + parent_hash=_ZERO_HASH, + ommers_hash=EMPTY_OMMER_HASH, + coinbase=_ZERO_ADDR, + state_root=EMPTY_TRIE_ROOT, + transactions_root=EMPTY_TRIE_ROOT, + receipt_root=EMPTY_TRIE_ROOT, + bloom=_ZERO_BLOOM, + difficulty=Uint(0), + number=Uint(0), + gas_limit=Uint(30_000_000), + gas_used=Uint(0), + timestamp=U256(1_000_000), + extra_data=Bytes(b""), + prev_randao=_ZERO_BYTES32, + nonce=_ZERO_BYTES8, + base_fee_per_gas=Uint(7), + withdrawals_root=EMPTY_TRIE_ROOT, + blob_gas_used=U64(0), + excess_blob_gas=U64(0), + parent_beacon_block_root=_ZERO_ROOT, + requests_hash=_EMPTY_REQUESTS_HASH, + block_access_list_hash=_EMPTY_ACCESS_LIST_HASH, + ) + + +def _make_child_payload(parent_header: Header) -> ExecutionPayload: + """Build a child execution payload whose block_hash is correct.""" + parent_hash = Hash32(keccak256(rlp.encode(parent_header))) + parent_beacon_block_root = Root(_ZERO_ROOT) + + # Build a provisional payload (block_hash will be computed below). + provisional = ExecutionPayload( + parent_hash=parent_hash, + fee_recipient=_ZERO_ADDR, + state_root=EMPTY_TRIE_ROOT, + receipts_root=EMPTY_TRIE_ROOT, + logs_bloom=_ZERO_BLOOM, + prev_randao=_ZERO_BYTES32, + block_number=Uint(1), + gas_limit=Uint(30_000_000), + gas_used=Uint(0), + timestamp=U256(1_000_001), + extra_data=Bytes(b""), + base_fee_per_gas=Uint(7), + block_hash=_ZERO_HASH, + transactions=(), + withdrawals=(), + blob_gas_used=U64(0), + excess_blob_gas=U64(0), + block_access_list=Bytes(b""), + ) + + child_header = _payload_header(provisional, parent_beacon_block_root, ()) + block_hash = Hash32(keccak256(rlp.encode(child_header))) + + return ExecutionPayload( + parent_hash=parent_hash, + fee_recipient=_ZERO_ADDR, + state_root=EMPTY_TRIE_ROOT, + receipts_root=EMPTY_TRIE_ROOT, + logs_bloom=_ZERO_BLOOM, + prev_randao=_ZERO_BYTES32, + block_number=Uint(1), + gas_limit=Uint(30_000_000), + gas_used=Uint(0), + timestamp=U256(1_000_001), + extra_data=Bytes(b""), + base_fee_per_gas=Uint(7), + block_hash=block_hash, + transactions=(), + withdrawals=(), + blob_gas_used=U64(0), + excess_blob_gas=U64(0), + block_access_list=Bytes(b""), + ) + + +def _make_stateless_input() -> StatelessInput: + """Build a minimal StatelessInput with a valid parent header.""" + parent_header = _make_parent_header() + encoded_parent = Bytes(rlp.encode(parent_header)) + child_payload = _make_child_payload(parent_header) + + return StatelessInput( + new_payload_request=NewPayloadRequest( + execution_payload=child_payload, + versioned_hashes=(), + parent_beacon_block_root=_ZERO_ROOT, + execution_requests=(), + ), + witness=ExecutionWitness( + state=(), + codes=(), + headers=(encoded_parent,), + ), + chain_config=ChainConfig(chain_id=U64(1)), + public_keys=(), + ) + + +def test_entrypoint_produces_output() -> None: + """entrypoint() returns serialized StatelessValidationResult.""" + stateless_input = _make_stateless_input() + input_data = serialize_stateless_input(stateless_input) + + write_input_bytes(input_data) + rewind_input() + + output_data = entrypoint() + + result = rlp.decode_to(StatelessValidationResult, output_data) + assert result.chain_config == stateless_input.chain_config From 5efa8188aaab5eb2eba001be526d5b4e3d6e39aa Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Sat, 28 Feb 2026 12:38:33 -0300 Subject: [PATCH 035/265] ci: add setup-env step to release flow Signed-off-by: Ignacio Hagopian --- .github/actions/build-fixtures/action.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/build-fixtures/action.yaml b/.github/actions/build-fixtures/action.yaml index eeae2d55a18..f119c1642e5 100644 --- a/.github/actions/build-fixtures/action.yaml +++ b/.github/actions/build-fixtures/action.yaml @@ -10,6 +10,7 @@ runs: - uses: ./.github/actions/setup-uv with: enable-cache: "false" + - uses: ./.github/actions/setup-env - name: Install EEST shell: bash run: uv sync --no-progress From 2b1d573a61223765d9d564f52587cf244eb6359e Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 17:08:51 +0000 Subject: [PATCH 036/265] support encoded legacy transactions --- src/ethereum/forks/amsterdam/transactions.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py index 9246f859039..d1f4a297cb1 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -508,8 +508,12 @@ def decode_transaction(tx: LegacyTransaction | Bytes) -> Transaction: Decode a transaction from its RLP or typed transaction format. Needed because non-legacy transactions aren't RLP. - Legacy transactions are returned as-is, while other transaction types - are decoded based on their type identifier prefix. + Accept a ``LegacyTransaction`` object (returned as-is) or raw + bytes. + + EIP-2718 states that the first byte distinguishes the format: + [0x00, 0x7f] is a typed transaction, [0xc0, 0xfe] is a legacy + transaction (RLP list prefix). """ if isinstance(tx, Bytes): if tx[0] == 1: @@ -520,6 +524,9 @@ def decode_transaction(tx: LegacyTransaction | Bytes) -> Transaction: return rlp.decode_to(BlobTransaction, tx[1:]) elif tx[0] == 4: return rlp.decode_to(SetCodeTransaction, tx[1:]) + elif tx[0] >= 0xC0: + assert tx[0] <= 0xFE + return rlp.decode_to(LegacyTransaction, tx) else: raise TransactionTypeError(tx[0]) else: From 4bc65175ced4a0e144375a75bea433a4ed11fb4c Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 19:40:46 +0000 Subject: [PATCH 037/265] add run_stateless_guest method --- src/ethereum/forks/amsterdam/stateless_guest.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index b6e186b2edb..05f597d11a6 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -81,11 +81,12 @@ def deserialize_stateless_input(data: Bytes) -> StatelessInput: return rlp.decode_to(StatelessInput, data) -# TODO: We could just have this be a method that takes in bytes and -# returns bytes def entrypoint() -> Bytes: """ Guest program entry point. + TODO: Maybe we remove this and keep run_stateless_guest + The nice thing about this is that we can define the read_input + semantics. """ input_data = read_input_bytes() stateless_input = deserialize_stateless_input(input_data) @@ -94,3 +95,14 @@ def entrypoint() -> Bytes: output_data = serialize_stateless_output(stateless_output) return output_data + + +def run_stateless_guest(input_bytes: Bytes) -> Bytes: + """ + Run the stateless guest with serialized input, return serialized output. + """ + stateless_input = deserialize_stateless_input(input_bytes) + stateless_output = verify_stateless_new_payload(stateless_input) + + output_bytes = serialize_stateless_output(stateless_output) + return output_bytes From 2c71a349e6d4b4543f24d3061a75fbb29781b6c6 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 19:59:02 +0000 Subject: [PATCH 038/265] temp: remove IO methods -- currently feel a bit awkward --- .../forks/amsterdam/stateless_guest.py | 71 +------ .../forks/amsterdam/stateless_host.py | 173 ++++++++++++++++++ 2 files changed, 175 insertions(+), 69 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/stateless_host.py diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 05f597d11a6..f1dc5206e69 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -2,11 +2,6 @@ Stateless guest interfaces. """ -# ------- IO --------- - -import io -import threading - from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -16,87 +11,25 @@ verify_stateless_new_payload, ) -_local: threading.local = threading.local() - - -def _get_buffer() -> io.BytesIO: - if not hasattr(_local, "buffer"): - _local.buffer = io.BytesIO() - return _local.buffer - - -# TODO: This method is for the host -def write_input_bytes(data: Bytes) -> None: - """ - Write bytes as input for the guest to read, prefixed with a 4-byte - big-endian length. - """ - _get_buffer().write(len(data).to_bytes(4, "big")) - _get_buffer().write(data) - - -# TODO: This method is for the host -def rewind_input() -> None: - """ - Seek the input buffer back to the start so the guest can read it. - - Call this after all ``write_input_bytes`` calls and before ``entrypoint``. - """ - _get_buffer().seek(0) - - -# This is a method for the guest -def read_input_bytes() -> Bytes: - """ - Read the input written by ``write_input``. - - Reads the 4-byte big-endian length prefix, then returns that many bytes. - """ - length = int.from_bytes(_get_buffer().read(4), "big") - return Bytes(_get_buffer().read(length)) - def serialize_stateless_output(output: StatelessValidationResult) -> Bytes: """ Serialize a ``StatelessValidationResult`` to RLP-encoded bytes. - TODO: change to ssz, rlp was easier to get working with codebase. - """ - return Bytes(rlp.encode(output)) - -# TODO: This method is for the host -def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: - """ - Serialize a ``StatelessInput`` to RLP-encoded bytes. TODO: change to ssz, rlp was easier to get working with codebase. """ - return Bytes(rlp.encode(stateless_input)) + return Bytes(rlp.encode(output)) def deserialize_stateless_input(data: Bytes) -> StatelessInput: """ Deserialize a ``StatelessInput`` from RLP-encoded bytes. + TODO: change to ssz, rlp was easier to get working with codebase. """ return rlp.decode_to(StatelessInput, data) -def entrypoint() -> Bytes: - """ - Guest program entry point. - TODO: Maybe we remove this and keep run_stateless_guest - The nice thing about this is that we can define the read_input - semantics. - """ - input_data = read_input_bytes() - stateless_input = deserialize_stateless_input(input_data) - - stateless_output = verify_stateless_new_payload(stateless_input) - - output_data = serialize_stateless_output(stateless_output) - return output_data - - def run_stateless_guest(input_bytes: Bytes) -> Bytes: """ Run the stateless guest with serialized input, return serialized output. diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py new file mode 100644 index 00000000000..bdc51f5556c --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -0,0 +1,173 @@ +""" +Host-side assembly of stateless input from block execution data. +""" + +from typing import List, Optional, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8 +from ethereum_types.numeric import U64, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.state import Root + +from .blocks import Header, Withdrawal +from .execution_engine.types import ExecutionPayload, NewPayloadRequest +from .fork import EMPTY_OMMER_HASH +from .fork_types import Bloom, VersionedHash +from .stateless import ChainConfig, StatelessInput +from .stateless_types import ExecutionWitness +from .transactions import BlobTransaction, decode_transaction +from .trie import trie_get +from .vm import BlockEnvironment, BlockOutput + +# Amsterdam currently carries execution requests as raw bytes in order. +# TODO: can we get rid of this? +ExecutionRequests = Tuple[Bytes, ...] + + +def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: + """ + Serialize a ``StatelessInput`` to RLP-encoded bytes. + + TODO: change to ssz, rlp was easier to get working with codebase. + """ + return Bytes(rlp.encode(stateless_input)) + + +def build_stateless_input( + block_output: BlockOutput, + block_env: BlockEnvironment, + *, + state_root: Root, + transactions_root: Root, + receipt_root: Root, + bloom: Bloom, + gas_used: Uint, + withdrawals_root: Root, + requests_hash: Hash32, + block_access_list_hash: Hash32, + withdrawals: Tuple[Withdrawal, ...], + block_headers: Tuple[Bytes, ...], + execution_witness: Optional[ExecutionWitness] = None, +) -> StatelessInput: + """ + Build a StatelessInput from a completed block's execution data. + + Assemble the block header, compute the block hash, extract + transaction bytes and versioned hashes, and package everything + into a StatelessInput ready for stateless guest execution. + """ + # Parent hash from the block environment. + bh = block_env.block_hashes + parent_hash = ( + Hash32(bytes(bh[-1])) + if bh and bh[-1] is not None + else Hash32(b"\x00" * 32) + ) + + header = Header( + parent_hash=parent_hash, + ommers_hash=EMPTY_OMMER_HASH, + coinbase=block_env.coinbase, + state_root=state_root, + transactions_root=transactions_root, + receipt_root=receipt_root, + bloom=bloom, + difficulty=Uint(0), + number=block_env.number, + gas_limit=block_env.block_gas_limit, + gas_used=gas_used, + timestamp=block_env.time, + extra_data=Bytes(b""), + prev_randao=block_env.prev_randao, + nonce=Bytes8(b"\x00\x00\x00\x00\x00\x00\x00\x00"), + base_fee_per_gas=block_env.base_fee_per_gas, + withdrawals_root=withdrawals_root, + blob_gas_used=block_output.blob_gas_used, + excess_blob_gas=block_env.excess_blob_gas, + parent_beacon_block_root=block_env.parent_beacon_block_root, + requests_hash=requests_hash, + block_access_list_hash=block_access_list_hash, + ) + block_hash = Hash32(keccak256(rlp.encode(header))) + + # Extract transaction bytes and versioned hashes. + tx_bytes_list = [] + versioned_hashes: List[VersionedHash] = [] + for key in block_output.receipt_keys: + tx_val = trie_get(block_output.transactions_trie, key) + if tx_val is None: + continue + tx_encoded = ( + tx_val if isinstance(tx_val, bytes) else rlp.encode(tx_val) + ) + tx_bytes_list.append(Bytes(tx_encoded)) + try: + tx_obj = decode_transaction( + tx_encoded if isinstance(tx_val, bytes) else tx_val + ) + if isinstance(tx_obj, BlobTransaction): + versioned_hashes.extend(tx_obj.blob_versioned_hashes) + except Exception: + pass + + # Block access list as RLP bytes. + bal = block_output.block_access_list + bal_bytes = Bytes(rlp.encode(bal)) if bal is not None else Bytes(b"") + + # Execution requests. + exec_requests: ExecutionRequests = tuple( + Bytes(bytes(r)) for r in (block_output.requests or []) + ) + + # Prefer headers populated by BLOCKHASH access during execution; + # fall back to the immediate parent header from the environment. + ew = execution_witness + if ew and ew.headers: + witness_headers = ew.headers + elif block_headers: + witness_headers = (Bytes(block_headers[-1]),) + else: + witness_headers = () + + witness = ExecutionWitness( + state=ew.state if ew else (), + codes=ew.codes if ew else (), + headers=witness_headers, + ) + + payload = ExecutionPayload( + parent_hash=parent_hash, + fee_recipient=block_env.coinbase, + state_root=state_root, + receipts_root=receipt_root, + logs_bloom=bloom, + prev_randao=block_env.prev_randao, + block_number=block_env.number, + gas_limit=block_env.block_gas_limit, + gas_used=gas_used, + timestamp=block_env.time, + extra_data=Bytes(b""), + base_fee_per_gas=block_env.base_fee_per_gas, + block_hash=block_hash, + transactions=tuple(tx_bytes_list), + withdrawals=withdrawals, + blob_gas_used=block_output.blob_gas_used, + excess_blob_gas=block_env.excess_blob_gas, + block_access_list=bal_bytes, + ) + + new_payload = NewPayloadRequest( + execution_payload=payload, + versioned_hashes=tuple(versioned_hashes), + parent_beacon_block_root=block_env.parent_beacon_block_root, + execution_requests=exec_requests, + ) + + return StatelessInput( + new_payload_request=new_payload, + witness=witness, + chain_config=ChainConfig(chain_id=U64(int(block_env.chain_id))), + public_keys=(), + ) From 76faab60af5beaeae5c3e46c0d7cdabe5d477e59 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 19:59:26 +0000 Subject: [PATCH 039/265] update tests --- tests/amsterdam/test_stateless_entrypoint.py | 13 ++++--------- tests/amsterdam/test_stateless_guest.py | 4 +++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/amsterdam/test_stateless_entrypoint.py b/tests/amsterdam/test_stateless_entrypoint.py index e0b3463d77b..f907de1c210 100644 --- a/tests/amsterdam/test_stateless_entrypoint.py +++ b/tests/amsterdam/test_stateless_entrypoint.py @@ -21,11 +21,9 @@ StatelessInput, StatelessValidationResult, ) -from ethereum.forks.amsterdam.stateless_guest import ( - entrypoint, - rewind_input, +from ethereum.forks.amsterdam.stateless_guest import run_stateless_guest +from ethereum.forks.amsterdam.stateless_host import ( serialize_stateless_input, - write_input_bytes, ) from ethereum.forks.amsterdam.stateless_types import ExecutionWitness from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT @@ -146,14 +144,11 @@ def _make_stateless_input() -> StatelessInput: def test_entrypoint_produces_output() -> None: - """entrypoint() returns serialized StatelessValidationResult.""" + """run_stateless_guest returns serialized StatelessValidationResult.""" stateless_input = _make_stateless_input() input_data = serialize_stateless_input(stateless_input) - write_input_bytes(input_data) - rewind_input() - - output_data = entrypoint() + output_data = run_stateless_guest(input_data) result = rlp.decode_to(StatelessValidationResult, output_data) assert result.chain_config == stateless_input.chain_config diff --git a/tests/amsterdam/test_stateless_guest.py b/tests/amsterdam/test_stateless_guest.py index 0508ba1d48c..ac185f09ea2 100644 --- a/tests/amsterdam/test_stateless_guest.py +++ b/tests/amsterdam/test_stateless_guest.py @@ -20,9 +20,11 @@ ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, - serialize_stateless_input, serialize_stateless_output, ) +from ethereum.forks.amsterdam.stateless_host import ( + serialize_stateless_input, +) from ethereum.forks.amsterdam.stateless_types import ExecutionWitness from ethereum.state import Address, Root From 7042d2494f8dcf75b8a80d1822835c84a5c32ada Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 20:12:07 +0000 Subject: [PATCH 040/265] Add stateless_input_bytes and stateless_output_bytes to the output --- .../src/execution_testing/client_clis/cli_types.py | 2 ++ .../src/execution_testing/fixtures/blockchain.py | 2 ++ .../src/execution_testing/specs/blockchain.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index 46e3d688af4..84485d68f3c 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -291,6 +291,8 @@ class Result(CamelModel): block_access_list: BlockAccessList | None = None block_access_list_hash: Hash | None = None execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None block_exception: Annotated[ BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 9492be88459..9dbc6926ed5 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -626,6 +626,8 @@ def strip_block_number_computed_field(cls, data: Any) -> Any: withdrawals: List[FixtureWithdrawal] | None = None receipts: List[FixtureTransactionReceipt] | None = None execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None block_access_list: BlockAccessList | None = Field( None, description="EIP-7928 Block Access List" ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 6c9c78c6499..73ab8ee664e 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -387,6 +387,8 @@ class BuiltBlock(CamelModel): fork: Fork block_access_list: BlockAccessList | None execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None def get_fixture_block( self, *, include_receipts: bool = True @@ -419,6 +421,12 @@ def get_fixture_block( execution_witness=self.execution_witness if self.execution_witness else None, + stateless_input_bytes=self.stateless_input_bytes + if self.stateless_input_bytes + else None, + stateless_output_bytes=self.stateless_output_bytes + if self.stateless_output_bytes + else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -785,6 +793,12 @@ def generate_block_data( execution_witness=( transition_tool_output.result.execution_witness ), + stateless_input_bytes=( + transition_tool_output.result.stateless_input_bytes + ), + stateless_output_bytes=( + transition_tool_output.result.stateless_output_bytes + ), ) try: From 7507329089519bcb48842fccdf82463d3ee08406 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 20:15:56 +0000 Subject: [PATCH 041/265] Multi - Add new methods for serializing input, running the stateless guest and building the stateless input - Move build_execution_witness and build_stateless_input to Result::update so everything is in one place - Remove execution_witness from BlockOutput --- src/ethereum/forks/amsterdam/vm/__init__.py | 4 -- .../evm_tools/loaders/fork_loader.py | 15 +++++ .../evm_tools/t8n/__init__.py | 24 -------- .../evm_tools/t8n/t8n_types.py | 55 ++++++++++++++++++- 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 366c6048784..5d6b2fbfb72 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -26,7 +26,6 @@ from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Authorization, VersionedHash from ..state_tracker import BlockState, TransactionState -from ..stateless_types import ExecutionWitness from ..transactions import LegacyTransaction from ..trie import Trie @@ -79,8 +78,6 @@ class BlockOutput: Hash of all the requests in the block. block_access_list: `BlockAccessList` The block access list for the block. - execution_witness : `ExecutionWitness` - Execution witness data for stateless validation. """ block_gas_used: Uint = Uint(0) @@ -98,7 +95,6 @@ class BlockOutput: blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) block_access_list: BlockAccessList = field(default_factory=list) - execution_witness: Optional[ExecutionWitness] = None @dataclass diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 878018e757f..ab982e74aa7 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -154,6 +154,21 @@ def build_execution_witness(self) -> Any: """Build function of the fork.""" return self._module("stateless_types").build_execution_witness + @property + def build_stateless_input(self) -> Any: + """build_stateless_input function of the fork.""" + return self._module("stateless_host").build_stateless_input + + @property + def serialize_stateless_input(self) -> Any: + """serialize_stateless_input function of the fork.""" + return self._module("stateless_host").serialize_stateless_input + + @property + def run_stateless_guest(self) -> Any: + """run_stateless_guest function of the fork.""" + return self._module("stateless_guest").run_stateless_guest + @property def signing_hash_2930(self) -> Any: """signing_hash_2930 function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index b92c1a0cce2..9aef5db9a92 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -470,30 +470,6 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_env.block_access_list_builder, block_env.state ) - if self.fork.has_execution_witness: - assert self.fork.has_block_state - # TODO(zkevm): since IncrementalMPT isn't the authoritative way - # to do post-state root calculation, we calculate the post-state - # root here with patricialize. Note this work will happen again - # later in t8n to provide the expected output. Although we could - # cache it, it might be too invasive and not worth it. Note also - # this is safe since `compute_state_root_and_trie_changes` does - # not mutate anything in state (i.e. it does transient copies of - # MPTs to do its work). - expected_post_state_root, _ = ( - self.alloc.state.compute_state_root_and_trie_changes( - block_env.state.account_writes, - block_env.state.storage_writes, - ) - ) - block_output.execution_witness = self.fork.build_execution_witness( - block_env.state, - expected_post_state_root=expected_post_state_root, - pre_state_accounts_data=self.alloc.state._main_trie, - pre_state_storages_data=self.alloc.state._storage_tries, - blockchain_headers=self.env.block_headers, - ) - def run_blockchain_test(self) -> None: """ Apply a block on the pre-state. Also includes system operations. diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 3793ffac368..70c21e2e6f7 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -278,6 +278,8 @@ class Result: block_access_list: Optional[Any] = None block_access_list_hash: Optional[Hash32] = None execution_witness: Optional[Any] = None + stateless_input_bytes: Optional[bytes] = None + stateless_output_bytes: Optional[bytes] = None def get_receipts_from_output( self, @@ -330,6 +332,20 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: ) ) self.state_root = state_root_value + + # Build witness between state root and apply_changes_to_state: + # the witness reads pre-state tries that apply_changes mutates. + # This is safe because compute_state_root_and_trie_changes + # does not mutate state (it makes transient copies of MPTs). + if t8n.fork.has_execution_witness: + self.execution_witness = t8n.fork.build_execution_witness( + block_env.state, + expected_post_state_root=state_root_value, + pre_state_accounts_data=(t8n.alloc.state._main_trie), + pre_state_storages_data=(t8n.alloc.state._storage_tries), + blockchain_headers=t8n.env.block_headers, + ) + # Apply diffs to pre-state for alloc output apply_changes_to_state(t8n.alloc.state, block_diff) else: @@ -357,8 +373,33 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - if hasattr(block_output, "execution_witness"): - self.execution_witness = block_output.execution_witness + if t8n.fork.has_execution_witness: + withdrawals = ( + tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () + ) + stateless_input = t8n.fork.build_stateless_input( + block_output, + block_env, + state_root=self.state_root, + transactions_root=self.tx_root, + receipt_root=self.receipt_root, + bloom=self.bloom, + gas_used=self.gas_used, + withdrawals_root=self.withdrawals_root, + requests_hash=self.requests_hash, + block_access_list_hash=self.block_access_list_hash, + withdrawals=withdrawals, + block_headers=tuple(t8n.env.block_headers), + execution_witness=self.execution_witness, + ) + stateless_input_bytes = t8n.fork.serialize_stateless_input( + stateless_input + ) + stateless_output_bytes = t8n.fork.run_stateless_guest( + stateless_input_bytes + ) + self.stateless_input_bytes = bytes(stateless_input_bytes) + self.stateless_output_bytes = bytes(stateless_output_bytes) @staticmethod def _block_access_list_to_json(account_changes: Any) -> Any: @@ -525,4 +566,14 @@ def to_json(self) -> Any: "headers": ["0x" + h.hex() for h in ew.headers], } + if self.stateless_input_bytes is not None: + data["statelessInputBytes"] = ( + "0x" + self.stateless_input_bytes.hex() + ) + + if self.stateless_output_bytes is not None: + data["statelessOutputBytes"] = ( + "0x" + self.stateless_output_bytes.hex() + ) + return data From e3b29b6cbeea7359ddf3c9fb2a12f01afb1cde4b Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 20:28:49 +0000 Subject: [PATCH 042/265] move ExecutionWitness to stateless.py --- src/ethereum/forks/amsterdam/stateless.py | 27 +++++++++++++++++- .../forks/amsterdam/stateless_host.py | 3 +- .../forks/amsterdam/stateless_types.py | 28 +------------------ .../evm_tools/loaders/fork_loader.py | 2 +- tests/amsterdam/test_stateless_entrypoint.py | 2 +- tests/amsterdam/test_stateless_guest.py | 2 +- 6 files changed, 31 insertions(+), 33 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index cafadb899b1..a2cc1f35166 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -18,9 +18,34 @@ from .execution_engine.types import NewPayloadRequest from .fork import ChainContext from .fork_types import VersionedHash -from .stateless_types import ExecutionWitness from .witness_state import WitnessState, build_code_db, build_node_db + +@slotted_freezable +@dataclass +class ExecutionWitness: + """ + Execution witness data for stateless validation. + """ + + state: Tuple[Bytes, ...] + """ + Hashed trie-node preimages needed during execution and state-root + recomputation. + """ + + codes: Tuple[Bytes, ...] + """ + Contract-code preimages (created or accessed) needed during execution. + """ + + headers: Tuple[Bytes, ...] + """ + RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness + proofs. This may trend toward empty EIP-7709. + """ + + # Amsterdam currently carries execution requests as raw bytes in order. ExecutionRequests = Tuple[Bytes, ...] diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index bdc51f5556c..006e3f7d266 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -15,8 +15,7 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork import EMPTY_OMMER_HASH from .fork_types import Bloom, VersionedHash -from .stateless import ChainConfig, StatelessInput -from .stateless_types import ExecutionWitness +from .stateless import ChainConfig, ExecutionWitness, StatelessInput from .transactions import BlobTransaction, decode_transaction from .trie import trie_get from .vm import BlockEnvironment, BlockOutput diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_types.py index 30a3f8562c5..6b43de41bd0 100644 --- a/src/ethereum/forks/amsterdam/stateless_types.py +++ b/src/ethereum/forks/amsterdam/stateless_types.py @@ -2,11 +2,9 @@ Stateless validation types. """ -from dataclasses import dataclass from typing import Dict, List, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 -from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U256, Uint from ethereum.crypto.hash import Hash32 @@ -20,34 +18,10 @@ mpt_set, ) from .state_tracker import BlockState +from .stateless import ExecutionWitness from .trie import EMPTY_TRIE_ROOT, Trie -@slotted_freezable -@dataclass -class ExecutionWitness: - """ - Execution witness data for stateless validation. - """ - - state: Tuple[Bytes, ...] - """ - Hashed trie-node preimages needed during execution and state-root - recomputation. - """ - - codes: Tuple[Bytes, ...] - """ - Contract-code preimages (created or accessed) needed during execution. - """ - - headers: Tuple[Bytes, ...] - """ - RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness - proofs. This may trend toward empty EIP-7709. - """ - - def _build_pre_state_storage_mpts( pre_state_storages_data: Dict[Address, Trie[Bytes32, U256]], ) -> Dict[Address, IncrementalMPT[Bytes32, U256]]: diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index ab982e74aa7..26e49d04390 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -144,7 +144,7 @@ def has_hash_block_access_list(self) -> bool: def has_execution_witness(self) -> bool: """Check if the fork has an `ExecutionWitness` type.""" try: - module = self._module("stateless_types") + module = self._module("stateless") except ModuleNotFoundError: return False return hasattr(module, "ExecutionWitness") diff --git a/tests/amsterdam/test_stateless_entrypoint.py b/tests/amsterdam/test_stateless_entrypoint.py index f907de1c210..bbfa806afa8 100644 --- a/tests/amsterdam/test_stateless_entrypoint.py +++ b/tests/amsterdam/test_stateless_entrypoint.py @@ -18,6 +18,7 @@ from ethereum.forks.amsterdam.requests import compute_requests_hash from ethereum.forks.amsterdam.stateless import ( ChainConfig, + ExecutionWitness, StatelessInput, StatelessValidationResult, ) @@ -25,7 +26,6 @@ from ethereum.forks.amsterdam.stateless_host import ( serialize_stateless_input, ) -from ethereum.forks.amsterdam.stateless_types import ExecutionWitness from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT from ethereum.state import Address, Root diff --git a/tests/amsterdam/test_stateless_guest.py b/tests/amsterdam/test_stateless_guest.py index ac185f09ea2..d23f6aad7ce 100644 --- a/tests/amsterdam/test_stateless_guest.py +++ b/tests/amsterdam/test_stateless_guest.py @@ -15,6 +15,7 @@ from ethereum.forks.amsterdam.fork_types import Bloom from ethereum.forks.amsterdam.stateless import ( ChainConfig, + ExecutionWitness, StatelessInput, StatelessValidationResult, ) @@ -25,7 +26,6 @@ from ethereum.forks.amsterdam.stateless_host import ( serialize_stateless_input, ) -from ethereum.forks.amsterdam.stateless_types import ExecutionWitness from ethereum.state import Address, Root _RNG = random.Random(0xDEADBEEF) From e6b38f3fd7a2f77d15646e3f7fd296b9230b3eac Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 20:30:47 +0000 Subject: [PATCH 043/265] rename stateless_types -> stateless_host_exec_witness (this was just building the execution witness) --- .../{stateless_types.py => stateless_host_exec_witness.py} | 0 src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) rename src/ethereum/forks/amsterdam/{stateless_types.py => stateless_host_exec_witness.py} (100%) diff --git a/src/ethereum/forks/amsterdam/stateless_types.py b/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py similarity index 100% rename from src/ethereum/forks/amsterdam/stateless_types.py rename to src/ethereum/forks/amsterdam/stateless_host_exec_witness.py diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 26e49d04390..e496fa9b95a 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -152,7 +152,8 @@ def has_execution_witness(self) -> bool: @property def build_execution_witness(self) -> Any: """Build function of the fork.""" - return self._module("stateless_types").build_execution_witness + mod = self._module("stateless_host_exec_witness") + return mod.build_execution_witness @property def build_stateless_input(self) -> Any: From bc2abda2bb15a541a3e33fb60f2a1413a60036c6 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 20:39:00 +0000 Subject: [PATCH 044/265] add unit tests (not spec tests) --- tests/{amsterdam => json_infra}/test_incremental_mpt.py | 0 tests/{amsterdam => json_infra}/test_stateless_entrypoint.py | 0 tests/{amsterdam => json_infra}/test_stateless_guest.py | 0 tests/{amsterdam => json_infra}/test_witness_state.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tests/{amsterdam => json_infra}/test_incremental_mpt.py (100%) rename tests/{amsterdam => json_infra}/test_stateless_entrypoint.py (100%) rename tests/{amsterdam => json_infra}/test_stateless_guest.py (100%) rename tests/{amsterdam => json_infra}/test_witness_state.py (100%) diff --git a/tests/amsterdam/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py similarity index 100% rename from tests/amsterdam/test_incremental_mpt.py rename to tests/json_infra/test_incremental_mpt.py diff --git a/tests/amsterdam/test_stateless_entrypoint.py b/tests/json_infra/test_stateless_entrypoint.py similarity index 100% rename from tests/amsterdam/test_stateless_entrypoint.py rename to tests/json_infra/test_stateless_entrypoint.py diff --git a/tests/amsterdam/test_stateless_guest.py b/tests/json_infra/test_stateless_guest.py similarity index 100% rename from tests/amsterdam/test_stateless_guest.py rename to tests/json_infra/test_stateless_guest.py diff --git a/tests/amsterdam/test_witness_state.py b/tests/json_infra/test_witness_state.py similarity index 100% rename from tests/amsterdam/test_witness_state.py rename to tests/json_infra/test_witness_state.py From f395d666ba69160fc5e6daeef8dd08b41437b460 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 20:47:46 +0000 Subject: [PATCH 045/265] only build stateless input if execution witness was not None --- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 70c21e2e6f7..0577c1483a6 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -373,7 +373,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - if t8n.fork.has_execution_witness: + if self.execution_witness is not None: withdrawals = ( tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () ) From b61f85cf254d55c664bdec15735157f9bc430073 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 21:14:39 +0000 Subject: [PATCH 046/265] widen Header -> Header | PreviousForkHeader --- src/ethereum/forks/amsterdam/fork.py | 8 ++++++-- src/ethereum/forks/amsterdam/stateless.py | 21 +++++++++++++++++---- src/ethereum/forks/amsterdam/vm/gas.py | 7 +++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 4051767344e..29c51aa3fba 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -28,6 +28,7 @@ InvalidSenderError, NonceMismatchError, ) +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.state import EMPTY_CODE_HASH, Address, BlockDiff, PreState from . import vm @@ -145,7 +146,7 @@ class ChainContext: block_hashes: List[Hash32] """Recent ancestor hashes (up to 256) for the ``BLOCKHASH`` opcode.""" - parent_header: Header + parent_header: Header | PreviousForkHeader """Parent header used for header validation and system contracts.""" @@ -419,7 +420,10 @@ def calculate_base_fee_per_gas( return Uint(expected_base_fee_per_gas) -def validate_header(parent_header: Header, header: Header) -> None: +def validate_header( + parent_header: Header | PreviousForkHeader, + header: Header, +) -> None: """ Verify a block header against its parent. diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index a2cc1f35166..9d8759bd9f7 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -11,6 +11,7 @@ from ethereum_types.numeric import U64 from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.state import Root from .blocks import Block, Header @@ -159,15 +160,27 @@ def new_payload_request_to_block( raise NotImplementedError +def _decode_header(header_bytes: Bytes) -> Header | PreviousForkHeader: + """ + Decode an RLP-encoded header, trying the current fork first and + falling back to the previous fork for transition-period headers. + """ + try: + return rlp.decode_to(Header, header_bytes) + except rlp.DecodingError: + return rlp.decode_to(PreviousForkHeader, header_bytes) + + def validate_headers( - headers: List[Header], + headers: List[Header | PreviousForkHeader], encoded_headers: Tuple[Bytes, ...], ) -> List[Hash32]: """ Validate that a sequence of encoded headers forms a contiguous chain. Each header's ``parent_hash`` must match the hash of the preceding - header. Return the list of block hashes. + header. Return the list of block hashes. Headers may come from + different forks during fork transitions. """ block_hashes: List[Hash32] = [ keccak256(header_bytes) for header_bytes in encoded_headers @@ -187,9 +200,9 @@ def verify_stateless_new_payload( witness = stateless_input.witness # Validate the headers are contiguous and compute their - # blockhashes + # blockhashes. decoded_headers = [ - rlp.decode_to(Header, header_bytes) for header_bytes in witness.headers + _decode_header(header_bytes) for header_bytes in witness.headers ] block_hashes = validate_headers(decoded_headers, witness.headers) parent_header = decoded_headers[-1] diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index c4655cf0ba5..5e9a7fb0d5b 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -16,6 +16,7 @@ from ethereum_types.numeric import U64, U256, Uint +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.trace import GasAndRefund, evm_trace from ethereum.utils.numeric import ceil32, taylor_exponential @@ -303,7 +304,9 @@ def init_code_cost(init_code_length: Uint) -> Uint: return GAS_INIT_CODE_WORD_COST * ceil32(init_code_length) // Uint(32) -def calculate_excess_blob_gas(parent_header: Header) -> U64: +def calculate_excess_blob_gas( + parent_header: Header | PreviousForkHeader, +) -> U64: """ Calculates the excess blob gas for the current block based on the gas used in the parent block. @@ -324,7 +327,7 @@ def calculate_excess_blob_gas(parent_header: Header) -> U64: blob_gas_used = U64(0) base_fee_per_gas = Uint(0) - if isinstance(parent_header, Header): + if isinstance(parent_header, (Header, PreviousForkHeader)): # After the fork block, read them from the parent header. excess_blob_gas = parent_header.excess_blob_gas blob_gas_used = parent_header.blob_gas_used From f9c17a9b43e57cbb8ce7abada00d93c924d20e9b Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 21:47:58 +0000 Subject: [PATCH 047/265] check for state tests --- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 0577c1483a6..e0d73b683c7 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -373,7 +373,9 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - if self.execution_witness is not None: + # State tests lack the parent header needed for stateless + # verification, so skip the stateless roundtrip. + if self.execution_witness is not None and not t8n.options.state_test: withdrawals = ( tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () ) From 2bcaa3313c139844db9faeea910e4487eb8d47e0 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 28 Feb 2026 22:28:05 +0000 Subject: [PATCH 048/265] trigger CI rerun From 108672217f111834bf5d62324706d6ff3e958c37 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sun, 1 Mar 2026 17:34:28 +0000 Subject: [PATCH 049/265] ignore bigmem tests --- .../eip7825_transaction_gas_limit_cap/test_tx_gas_limit.py | 1 + .../eip7934_block_rlp_limit/test_max_block_rlp_size.py | 2 +- tests/prague/eip7702_set_code_tx/test_set_code_txs.py | 7 +++++++ tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py | 3 +++ tests/shanghai/eip3855_push0/test_push0.py | 2 ++ tests/shanghai/eip3860_initcode/test_initcode.py | 2 ++ tests/shanghai/eip4895_withdrawals/test_withdrawals.py | 2 ++ tox.ini | 6 ++---- 8 files changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/osaka/eip7825_transaction_gas_limit_cap/test_tx_gas_limit.py b/tests/osaka/eip7825_transaction_gas_limit_cap/test_tx_gas_limit.py index 0296ef48cae..79473082ee6 100644 --- a/tests/osaka/eip7825_transaction_gas_limit_cap/test_tx_gas_limit.py +++ b/tests/osaka/eip7825_transaction_gas_limit_cap/test_tx_gas_limit.py @@ -331,6 +331,7 @@ def total_cost_floor_per_token(fork: Fork) -> int: return gas_costs.G_TX_DATA_FLOOR_TOKEN_COST +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize( "exceed_tx_gas_limit,correct_intrinsic_cost_in_transaction_gas_limit", diff --git a/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py b/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py index de5061072f2..b769bbd1a77 100644 --- a/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py +++ b/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py @@ -35,7 +35,7 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_7934.git_path REFERENCE_SPEC_VERSION = ref_spec_7934.version -pytestmark = pytest.mark.xdist_group(name="bigmem") +pytestmark = [pytest.mark.bigmem, pytest.mark.xdist_group(name="bigmem")] HEADER_TIMESTAMP = 123456789 diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py index d9275f4ea46..0a676c321ef 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py @@ -1999,6 +1999,7 @@ def test_set_code_to_self_destructing_account_deployed_in_same_tx( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_set_code_multiple_first_valid_authorization_tuples_same_signer( state_test: StateTestFiller, @@ -2050,6 +2051,7 @@ def test_set_code_multiple_first_valid_authorization_tuples_same_signer( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce( # noqa: E501 state_test: StateTestFiller, @@ -2102,6 +2104,7 @@ def test_set_code_multiple_valid_authorization_tuples_same_signer_increasing_non ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce_self_sponsored( # noqa: E501 state_test: StateTestFiller, @@ -2205,6 +2208,7 @@ def test_set_code_multiple_valid_authorization_tuples_first_invalid_same_signer( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_set_code_all_invalid_authorization_tuples( state_test: StateTestFiller, @@ -2248,6 +2252,7 @@ def test_set_code_all_invalid_authorization_tuples( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_set_code_using_chain_specific_id( state_test: StateTestFiller, @@ -2300,6 +2305,7 @@ def test_set_code_using_chain_specific_id( SECP256K1N_OVER_2 = SECP256K1N // 2 +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize( "v,r,s", @@ -2368,6 +2374,7 @@ def test_set_code_using_valid_synthetic_signatures( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize( "v,r,s", diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py index 988dbf868bc..22dab3d1cd2 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py @@ -1762,6 +1762,7 @@ class DelegationTo(Enum): RESET = 3 +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.valid_from("Prague") @pytest.mark.parametrize( @@ -1862,6 +1863,7 @@ def test_double_auth( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.valid_from("Prague") def test_pointer_resets_an_empty_code_account_with_storage( @@ -2096,6 +2098,7 @@ def test_set_code_type_tx_pre_fork( @pytest.mark.valid_from("Prague") +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_delegation_replacement_call_previous_contract( state_test: StateTestFiller, diff --git a/tests/shanghai/eip3855_push0/test_push0.py b/tests/shanghai/eip3855_push0/test_push0.py index ff15dc9c9f9..03a1f434e23 100644 --- a/tests/shanghai/eip3855_push0/test_push0.py +++ b/tests/shanghai/eip3855_push0/test_push0.py @@ -27,6 +27,7 @@ pytestmark = pytest.mark.valid_from("Shanghai") +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize( "contract_code,expected_storage", @@ -134,6 +135,7 @@ def push0_contract_caller( ) return pre.deploy_contract(call_code) + @pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize( "call_opcode", diff --git a/tests/shanghai/eip3860_initcode/test_initcode.py b/tests/shanghai/eip3860_initcode/test_initcode.py index 48fbf96108f..95c6affcda1 100644 --- a/tests/shanghai/eip3860_initcode/test_initcode.py +++ b/tests/shanghai/eip3860_initcode/test_initcode.py @@ -112,6 +112,7 @@ def initcode(fork: Fork, initcode_name: str) -> Initcode: """Test cases using a contract creating transaction""" +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize( "initcode_name", @@ -515,6 +516,7 @@ def tx( sender=sender, ) + @pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.slow() def test_create_opcode_initcode( diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 272dc6293a9..117b0370131 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -712,6 +712,7 @@ def test_zero_amount( ) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") def test_large_amount( blockchain_test: BlockchainTestFiller, @@ -753,6 +754,7 @@ def test_large_amount( blockchain_test(pre=pre, post=post, blocks=blocks) +@pytest.mark.bigmem @pytest.mark.xdist_group(name="bigmem") @pytest.mark.parametrize("amount", [0, 1]) @pytest.mark.with_all_precompiles diff --git a/tox.ini b/tox.ini index f23707dc359..e796dc0127b 100644 --- a/tox.ini +++ b/tox.ini @@ -90,8 +90,7 @@ commands = description = Fill the tests using EELS (with Python) commands = fill \ - -m "not slow and not benchmark" \ - # loadgroup: serialize xdist_group("bigmem") tests onto one worker to limit peak memory + -m "not slow and not benchmark and not bigmem" \ -n {env:PYTEST_XDIST_AUTO_NUM_WORKERS:6} --dist=loadgroup \ --skip-index \ --cov-config=pyproject.toml \ @@ -123,9 +122,8 @@ commands = --tb=no \ --show-capture=no \ --disable-warnings \ - -m "not slow and not benchmark and not derived_test" \ + -m "not slow and not benchmark and not derived_test and not bigmem" \ -n auto --maxprocesses 7 \ - # loadgroup: serialize xdist_group("bigmem") tests onto one worker to limit peak memory --dist=loadgroup \ --basetemp="{temp_dir}/pytest" \ --log-to "{toxworkdir}/logs" \ From 2378b4865d24e5b28ff0d042de8847aa108bae48 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 2 Mar 2026 16:42:21 +0000 Subject: [PATCH 050/265] fix --- src/ethereum/forks/amsterdam/incremental_mpt.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index c8f186388fd..b7b106b295f 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -224,7 +224,8 @@ def build_mpt( def _invalidate_hash(node: MutableNode) -> None: """Invalidate the cached hash of a node.""" - if node is None or isinstance(node, HashedNode): + assert not isinstance(node, HashedNode), "HashedNode cannot be invalidated" + if node is None: return node._hash = None node._rlp = None @@ -235,7 +236,8 @@ def _record_witness( node: MutableNode, ) -> None: """Record a node access in the witness.""" - if node is None or isinstance(node, HashedNode): + assert not isinstance(node, HashedNode), "HashedNode cannot be witnessed" + if node is None: return if node._dirty: @@ -374,7 +376,7 @@ def _mpt_traverse_for_witness( level: Uint, ) -> None: """Traverse the tree recording nodes in the witness.""" - if node is None or isinstance(node, HashedNode): + if node is None: return _record_witness(mpt.witness, node) From 986533dac1858eb1ead78dbcefaf41680961dc59 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 2 Mar 2026 16:53:08 +0000 Subject: [PATCH 051/265] assert HashedNode is invalid in _invalidate_hash and _record_witness Remove redundant HashedNode checks in _insert_node and _delete_node since _invalidate_hash now asserts before they would be reached. --- src/ethereum/forks/amsterdam/incremental_mpt.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index b7b106b295f..b1a9e22b3de 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -476,9 +476,6 @@ def _mpt_insert_node( rest_of_key=key[level:], value=value, _dirty=True ) - if isinstance(node, HashedNode): - raise AssertionError("Incomplete witness: insert into hashed node") - _invalidate_hash(node) if isinstance(node, MutableLeafNode): @@ -683,9 +680,6 @@ def _mpt_delete_node( if node is None: return None - if isinstance(node, HashedNode): - raise AssertionError("Incomplete witness: delete from hashed node") - _invalidate_hash(node) if isinstance(node, MutableLeafNode): From 378b74cc8119aa5fc245769a6172be04802ab8d4 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 2 Mar 2026 16:59:34 +0000 Subject: [PATCH 052/265] tests --- tests/json_infra/test_incremental_mpt.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/json_infra/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py index 4df7185544b..0eb1fdc095a 100644 --- a/tests/json_infra/test_incremental_mpt.py +++ b/tests/json_infra/test_incremental_mpt.py @@ -131,7 +131,7 @@ def test_insert_into_hashed_node_raises(self) -> None: root_node=hashed_node, _data={}, ) - with pytest.raises(AssertionError, match="Incomplete"): + with pytest.raises(AssertionError, match="cannot be invalidated"): mpt_set(mpt, b"\x01", b"value") def test_delete_from_hashed_node_raises(self) -> None: @@ -143,11 +143,11 @@ def test_delete_from_hashed_node_raises(self) -> None: root_node=hashed_node, _data={}, ) - with pytest.raises(AssertionError, match="Incomplete"): + with pytest.raises(AssertionError, match="cannot be invalidated"): mpt_set(mpt, b"\x01", b"") - def test_witness_traversal_skips_hashed_node(self) -> None: - """Witness traversal does not record hashed nodes.""" + def test_witness_traversal_on_hashed_node_raises(self) -> None: + """Witness traversal on a HashedNode raises AssertionError.""" hashed_node = HashedNode(_hash=b"\x00" * 32) mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( secured=False, @@ -155,10 +155,8 @@ def test_witness_traversal_skips_hashed_node(self) -> None: root_node=hashed_node, _data={}, ) - # mpt_get traverses for witness but should not crash - result = mpt_get(mpt, b"\x01") - assert result == b"" - assert len(mpt.witness.accessed_nodes) == 0 + with pytest.raises(AssertionError, match="cannot be witnessed"): + mpt_get(mpt, b"\x01") def _build_trie_and_collect_nodes( @@ -443,7 +441,7 @@ def test_partial_witness_insert_into_hashed_node_fails(self) -> None: ) # Try to insert into the hashed node subtree - with pytest.raises(AssertionError, match="Incomplete"): + with pytest.raises(AssertionError, match="cannot be invalidated"): mpt_set(decoded_mpt, b"ba", b"new_c") From 6e05674d83696269617df5e8b52d8f9a55885451 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 2 Mar 2026 23:02:20 +0000 Subject: [PATCH 053/265] remove todo: spoke with ignacio, this is for the parent header --- src/ethereum/forks/amsterdam/fork.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 29c51aa3fba..2b75099a465 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -855,7 +855,6 @@ def apply_body( target_address=HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash ) - # TODO: can we remove this? track_ancestor_access( block_env.state, Uint(1), From a992aa7c530c99337c4dc2993adeb12429fd22de Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 2 Mar 2026 23:18:42 +0000 Subject: [PATCH 054/265] return decoded_headers and block_hashes in validate_headers --- src/ethereum/forks/amsterdam/stateless.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 9d8759bd9f7..12e99d6d5fe 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -172,23 +172,26 @@ def _decode_header(header_bytes: Bytes) -> Header | PreviousForkHeader: def validate_headers( - headers: List[Header | PreviousForkHeader], encoded_headers: Tuple[Bytes, ...], -) -> List[Hash32]: +) -> Tuple[List[Header | PreviousForkHeader], List[Hash32]]: """ Validate that a sequence of encoded headers forms a contiguous chain. Each header's ``parent_hash`` must match the hash of the preceding - header. Return the list of block hashes. Headers may come from - different forks during fork transitions. + header. Return the decoded headers and block hashes. Headers may + come from different forks during fork transitions. """ + assert len(encoded_headers) <= 256, "Too many headers in witness" + headers = [ + _decode_header(header_bytes) for header_bytes in encoded_headers + ] block_hashes: List[Hash32] = [ keccak256(header_bytes) for header_bytes in encoded_headers ] for i in range(1, len(headers)): if headers[i].parent_hash != block_hashes[i - 1]: raise Exception("Witness headers are not contiguous") - return block_hashes + return headers, block_hashes def verify_stateless_new_payload( @@ -201,10 +204,7 @@ def verify_stateless_new_payload( # Validate the headers are contiguous and compute their # blockhashes. - decoded_headers = [ - _decode_header(header_bytes) for header_bytes in witness.headers - ] - block_hashes = validate_headers(decoded_headers, witness.headers) + decoded_headers, block_hashes = validate_headers(witness.headers) parent_header = decoded_headers[-1] chain_context = ChainContext( From f29c4bab2ea608d125fc93e849f76e7965a340a5 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 2 Mar 2026 23:21:26 +0000 Subject: [PATCH 055/265] ignacio review --- src/ethereum/forks/amsterdam/incremental_mpt.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index b1a9e22b3de..59fc0fe9cfe 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -729,10 +729,9 @@ def _delete_from_extension( _dirty=True, ) + assert not isinstance(new_child, HashedNode) child_changed = new_child is not old_child or ( - new_child is not None - and not isinstance(new_child, HashedNode) - and new_child._dirty + new_child is not None and new_child._dirty ) if not child_changed: return node @@ -764,10 +763,9 @@ def _delete_from_branch( key, level + Uint(1), ) + assert not isinstance(new_child, HashedNode) child_changed = new_child is not old_child or ( - new_child is not None - and not isinstance(new_child, HashedNode) - and new_child._dirty + new_child is not None and new_child._dirty ) if not child_changed: return node @@ -926,6 +924,7 @@ def _decode_witness_node( decoded = rlp.decode(rlp_bytes) if isinstance(decoded, (bytes, bytearray)): + assert len(decoded) == 0, "Expected empty node" return None assert isinstance(decoded, list) @@ -946,6 +945,9 @@ def _decode_witness_node( ) else: child = _resolve_child_ref(node_db, decoded[1]) + assert isinstance(child, (MutableBranchNode, HashedNode)), ( + "ExtensionNode child must be a BranchNode" + ) return MutableExtensionNode( key_segment=nibbles, child=child, @@ -962,6 +964,9 @@ def _decode_witness_node( value = Bytes(value_raw) else: value = b"" + # TODO: value is always empty in practice; refactor + occupied = 16 - children.count(None) + (value != b"") + assert occupied >= 2, "BranchNode must have at least 2 children" return MutableBranchNode( children=children, value=value, From a08a40a7cfd49ad23620b44f87ad6fe711a14da8 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Tue, 3 Mar 2026 22:41:51 +0000 Subject: [PATCH 056/265] update --- .../forks/amsterdam/incremental_mpt.py | 22 ++- .../forks/amsterdam/stateless_host.py | 166 +++++------------- .../evm_tools/t8n/t8n_types.py | 47 ++++- 3 files changed, 101 insertions(+), 134 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index 59fc0fe9cfe..7c9d526decc 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -316,8 +316,9 @@ def _compute_node_hash_and_rlp( if node is None: return None, b"" - if isinstance(node, HashedNode): - return node._hash, b"" + assert not isinstance(node, HashedNode), ( + "HashedNode cannot appear in _compute_node_hash_and_rlp" + ) if node._rlp is not None: if node._hash is not None: @@ -800,12 +801,16 @@ def _collapse_branch( child=child.child, _dirty=True, ) - else: + elif isinstance(child, MutableBranchNode): return MutableExtensionNode( key_segment=nibble, child=child, _dirty=True, ) + else: + raise AssertionError( + f"Unexpected node type {type(child)}" + ) if len(non_empty) == 0 and node.value != b"": return MutableLeafNode( @@ -893,11 +898,12 @@ def _resolve_child_ref( ref_bytes = Bytes(child_ref) if len(ref_bytes) == 0: return None - if len(ref_bytes) == 32: - if ref_bytes in node_db: - return _decode_witness_node(node_db, node_db[ref_bytes]) - return HashedNode(_hash=ref_bytes) - return _decode_witness_node(node_db, rlp.encode(ref_bytes)) + assert len(ref_bytes) == 32, ( + f"Unexpected child ref length: {len(ref_bytes)}" + ) + if ref_bytes in node_db: + return _decode_witness_node(node_db, node_db[ref_bytes]) + return HashedNode(_hash=ref_bytes) else: return _decode_witness_node(node_db, rlp.encode(child_ref)) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 006e3f7d266..46c95bfb558 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -2,27 +2,20 @@ Host-side assembly of stateless input from block execution data. """ -from typing import List, Optional, Tuple +from typing import List, Tuple from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes, Bytes8 -from ethereum_types.numeric import U64, Uint +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64 from ethereum.crypto.hash import Hash32, keccak256 -from ethereum.state import Root -from .blocks import Header, Withdrawal +from .block_access_lists import BlockAccessList +from .blocks import Block from .execution_engine.types import ExecutionPayload, NewPayloadRequest -from .fork import EMPTY_OMMER_HASH -from .fork_types import Bloom, VersionedHash +from .fork_types import VersionedHash from .stateless import ChainConfig, ExecutionWitness, StatelessInput -from .transactions import BlobTransaction, decode_transaction -from .trie import trie_get -from .vm import BlockEnvironment, BlockOutput - -# Amsterdam currently carries execution requests as raw bytes in order. -# TODO: can we get rid of this? -ExecutionRequests = Tuple[Bytes, ...] +from .transactions import BlobTransaction, LegacyTransaction, decode_transaction def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: @@ -35,138 +28,69 @@ def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: def build_stateless_input( - block_output: BlockOutput, - block_env: BlockEnvironment, + block: Block, *, - state_root: Root, - transactions_root: Root, - receipt_root: Root, - bloom: Bloom, - gas_used: Uint, - withdrawals_root: Root, - requests_hash: Hash32, - block_access_list_hash: Hash32, - withdrawals: Tuple[Withdrawal, ...], - block_headers: Tuple[Bytes, ...], - execution_witness: Optional[ExecutionWitness] = None, + execution_witness: ExecutionWitness, + execution_requests: Tuple[Bytes, ...], + block_access_list: BlockAccessList, + chain_id: U64, ) -> StatelessInput: """ - Build a StatelessInput from a completed block's execution data. + Build a StatelessInput from a completed block. - Assemble the block header, compute the block hash, extract - transaction bytes and versioned hashes, and package everything - into a StatelessInput ready for stateless guest execution. + Extract the header, transactions, and withdrawals from the block, + compute the block hash, collect versioned hashes, and package + everything into a StatelessInput ready for stateless guest execution. """ - # Parent hash from the block environment. - bh = block_env.block_hashes - parent_hash = ( - Hash32(bytes(bh[-1])) - if bh and bh[-1] is not None - else Hash32(b"\x00" * 32) - ) - - header = Header( - parent_hash=parent_hash, - ommers_hash=EMPTY_OMMER_HASH, - coinbase=block_env.coinbase, - state_root=state_root, - transactions_root=transactions_root, - receipt_root=receipt_root, - bloom=bloom, - difficulty=Uint(0), - number=block_env.number, - gas_limit=block_env.block_gas_limit, - gas_used=gas_used, - timestamp=block_env.time, - extra_data=Bytes(b""), - prev_randao=block_env.prev_randao, - nonce=Bytes8(b"\x00\x00\x00\x00\x00\x00\x00\x00"), - base_fee_per_gas=block_env.base_fee_per_gas, - withdrawals_root=withdrawals_root, - blob_gas_used=block_output.blob_gas_used, - excess_blob_gas=block_env.excess_blob_gas, - parent_beacon_block_root=block_env.parent_beacon_block_root, - requests_hash=requests_hash, - block_access_list_hash=block_access_list_hash, - ) + header = block.header block_hash = Hash32(keccak256(rlp.encode(header))) - # Extract transaction bytes and versioned hashes. - tx_bytes_list = [] + # Encode transactions to bytes and collect versioned hashes. + tx_bytes_list: List[Bytes] = [] versioned_hashes: List[VersionedHash] = [] - for key in block_output.receipt_keys: - tx_val = trie_get(block_output.transactions_trie, key) - if tx_val is None: - continue - tx_encoded = ( - tx_val if isinstance(tx_val, bytes) else rlp.encode(tx_val) - ) - tx_bytes_list.append(Bytes(tx_encoded)) - try: - tx_obj = decode_transaction( - tx_encoded if isinstance(tx_val, bytes) else tx_val - ) + for tx in block.transactions: + if isinstance(tx, LegacyTransaction): + tx_bytes_list.append(Bytes(rlp.encode(tx))) + else: + tx_bytes_list.append(Bytes(tx)) + tx_obj = decode_transaction(tx) if isinstance(tx_obj, BlobTransaction): versioned_hashes.extend(tx_obj.blob_versioned_hashes) - except Exception: - pass # Block access list as RLP bytes. - bal = block_output.block_access_list - bal_bytes = Bytes(rlp.encode(bal)) if bal is not None else Bytes(b"") - - # Execution requests. - exec_requests: ExecutionRequests = tuple( - Bytes(bytes(r)) for r in (block_output.requests or []) - ) - - # Prefer headers populated by BLOCKHASH access during execution; - # fall back to the immediate parent header from the environment. - ew = execution_witness - if ew and ew.headers: - witness_headers = ew.headers - elif block_headers: - witness_headers = (Bytes(block_headers[-1]),) - else: - witness_headers = () - - witness = ExecutionWitness( - state=ew.state if ew else (), - codes=ew.codes if ew else (), - headers=witness_headers, - ) + bal_bytes = Bytes(rlp.encode(block_access_list)) payload = ExecutionPayload( - parent_hash=parent_hash, - fee_recipient=block_env.coinbase, - state_root=state_root, - receipts_root=receipt_root, - logs_bloom=bloom, - prev_randao=block_env.prev_randao, - block_number=block_env.number, - gas_limit=block_env.block_gas_limit, - gas_used=gas_used, - timestamp=block_env.time, - extra_data=Bytes(b""), - base_fee_per_gas=block_env.base_fee_per_gas, + parent_hash=header.parent_hash, + fee_recipient=header.coinbase, + state_root=header.state_root, + receipts_root=header.receipt_root, + logs_bloom=header.bloom, + prev_randao=header.prev_randao, + block_number=header.number, + gas_limit=header.gas_limit, + gas_used=header.gas_used, + timestamp=header.timestamp, + extra_data=header.extra_data, + base_fee_per_gas=header.base_fee_per_gas, block_hash=block_hash, transactions=tuple(tx_bytes_list), - withdrawals=withdrawals, - blob_gas_used=block_output.blob_gas_used, - excess_blob_gas=block_env.excess_blob_gas, + withdrawals=block.withdrawals, + blob_gas_used=header.blob_gas_used, + excess_blob_gas=header.excess_blob_gas, block_access_list=bal_bytes, ) new_payload = NewPayloadRequest( execution_payload=payload, versioned_hashes=tuple(versioned_hashes), - parent_beacon_block_root=block_env.parent_beacon_block_root, - execution_requests=exec_requests, + parent_beacon_block_root=header.parent_beacon_block_root, + execution_requests=execution_requests, ) return StatelessInput( new_payload_request=new_payload, - witness=witness, - chain_config=ChainConfig(chain_id=U64(int(block_env.chain_id))), + witness=execution_witness, + chain_config=ChainConfig(chain_id=chain_id), public_keys=(), ) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index e0d73b683c7..1b1e9a23e7b 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from ethereum_rlp import Simple, rlp -from ethereum_types.bytes import Bytes +from ethereum_types.bytes import Bytes, Bytes8 from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 @@ -379,20 +379,57 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: withdrawals = ( tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () ) - stateless_input = t8n.fork.build_stateless_input( - block_output, - block_env, + + # Build the block header from execution results. + bh = block_env.block_hashes + assert bh and bh[-1] is not None + header = t8n.fork.Header( + parent_hash=Hash32(bytes(bh[-1])), + ommers_hash=keccak256(rlp.encode([])), + coinbase=block_env.coinbase, state_root=self.state_root, transactions_root=self.tx_root, receipt_root=self.receipt_root, bloom=self.bloom, + difficulty=Uint(0), + number=block_env.number, + gas_limit=block_env.block_gas_limit, gas_used=self.gas_used, + timestamp=block_env.time, + extra_data=Bytes(b""), + prev_randao=block_env.prev_randao, + nonce=Bytes8(b"\x00" * 8), + base_fee_per_gas=block_env.base_fee_per_gas, withdrawals_root=self.withdrawals_root, + blob_gas_used=block_output.blob_gas_used, + excess_blob_gas=block_env.excess_blob_gas, + parent_beacon_block_root=block_env.parent_beacon_block_root, requests_hash=self.requests_hash, block_access_list_hash=self.block_access_list_hash, + ) + + # TODO: perhaps change this for t8n.all_txs minus rejected_txs? + included_txs = [] + for key in block_output.receipt_keys: + tx = t8n.fork.trie_get( + block_output.transactions_trie, key + ) + assert tx is not None + included_txs.append(tx) + + block = t8n.fork.Block( + header=header, + transactions=tuple(included_txs), + ommers=(), withdrawals=withdrawals, - block_headers=tuple(t8n.env.block_headers), + ) + + stateless_input = t8n.fork.build_stateless_input( + block, execution_witness=self.execution_witness, + execution_requests=tuple(self.requests), + block_access_list=self.block_access_list, + chain_id=block_env.chain_id, ) stateless_input_bytes = t8n.fork.serialize_stateless_input( stateless_input From d0181069af46cd89fd73b0aca32a4080ffbe5778 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Tue, 3 Mar 2026 22:47:15 +0000 Subject: [PATCH 057/265] lint --- src/ethereum/forks/amsterdam/stateless_host.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 46c95bfb558..18f54d5258b 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -15,7 +15,11 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash from .stateless import ChainConfig, ExecutionWitness, StatelessInput -from .transactions import BlobTransaction, LegacyTransaction, decode_transaction +from .transactions import ( + BlobTransaction, + LegacyTransaction, + decode_transaction, +) def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: From 03ea9960aecadabde4de47d5530c7eb748645569 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Tue, 3 Mar 2026 23:37:39 +0000 Subject: [PATCH 058/265] add `deserialize_stateless_output` --- src/ethereum/forks/amsterdam/stateless_host.py | 12 +++++++++++- .../evm_tools/loaders/fork_loader.py | 5 +++++ src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 18f54d5258b..85c4ceea89d 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -14,7 +14,12 @@ from .blocks import Block from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash -from .stateless import ChainConfig, ExecutionWitness, StatelessInput +from .stateless import ( + ChainConfig, + ExecutionWitness, + StatelessInput, + StatelessValidationResult, +) from .transactions import ( BlobTransaction, LegacyTransaction, @@ -31,6 +36,11 @@ def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: return Bytes(rlp.encode(stateless_input)) +def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: + """Deserialize a StatelessValidationResult from RLP bytes.""" + return rlp.decode_to(StatelessValidationResult, data) + + def build_stateless_input( block: Block, *, diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index e496fa9b95a..6c88440b553 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -165,6 +165,11 @@ def serialize_stateless_input(self) -> Any: """serialize_stateless_input function of the fork.""" return self._module("stateless_host").serialize_stateless_input + @property + def deserialize_stateless_output(self) -> Any: + """deserialize_stateless_output function of the fork.""" + return self._module("stateless_host").deserialize_stateless_output + @property def run_stateless_guest(self) -> Any: """run_stateless_guest function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 1b1e9a23e7b..621cd1029ef 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -437,6 +437,12 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: stateless_output_bytes = t8n.fork.run_stateless_guest( stateless_input_bytes ) + result = t8n.fork.deserialize_stateless_output( + stateless_output_bytes + ) + assert result.successful_validation, ( + "Stateless validation failed" + ) self.stateless_input_bytes = bytes(stateless_input_bytes) self.stateless_output_bytes = bytes(stateless_output_bytes) From 3fa6fbdac13a77b7dafa2fb26243250e132a36ce Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Tue, 3 Mar 2026 23:43:05 +0000 Subject: [PATCH 059/265] delete redundant file --- tests/json_infra/test_stateless_entrypoint.py | 154 ------------------ 1 file changed, 154 deletions(-) delete mode 100644 tests/json_infra/test_stateless_entrypoint.py diff --git a/tests/json_infra/test_stateless_entrypoint.py b/tests/json_infra/test_stateless_entrypoint.py deleted file mode 100644 index bbfa806afa8..00000000000 --- a/tests/json_infra/test_stateless_entrypoint.py +++ /dev/null @@ -1,154 +0,0 @@ -"""End-to-end test for stateless_guest.entrypoint.""" - -from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes, Bytes8, Bytes32 -from ethereum_types.numeric import U64, U256, Uint - -from ethereum.crypto.hash import Hash32, keccak256 -from ethereum.forks.amsterdam.blocks import Header -from ethereum.forks.amsterdam.execution_engine.types import ( - ExecutionPayload, - NewPayloadRequest, -) -from ethereum.forks.amsterdam.execution_engine.validation_helpers import ( - _payload_header, -) -from ethereum.forks.amsterdam.fork import EMPTY_OMMER_HASH -from ethereum.forks.amsterdam.fork_types import Bloom -from ethereum.forks.amsterdam.requests import compute_requests_hash -from ethereum.forks.amsterdam.stateless import ( - ChainConfig, - ExecutionWitness, - StatelessInput, - StatelessValidationResult, -) -from ethereum.forks.amsterdam.stateless_guest import run_stateless_guest -from ethereum.forks.amsterdam.stateless_host import ( - serialize_stateless_input, -) -from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT -from ethereum.state import Address, Root - -_ZERO_HASH = Hash32(b"\x00" * 32) -_ZERO_ROOT = Root(b"\x00" * 32) -_ZERO_ADDR = Address(b"\x00" * 20) -_ZERO_BLOOM = Bloom(b"\x00" * 256) -_ZERO_BYTES32 = Bytes32(b"\x00" * 32) -_ZERO_BYTES8 = Bytes8(b"\x00" * 8) - -_EMPTY_REQUESTS_HASH = Hash32(compute_requests_hash([])) -_EMPTY_ACCESS_LIST_HASH = Hash32(keccak256(b"")) - - -def _make_parent_header() -> Header: - """Build a minimal parent header with empty state.""" - return Header( - parent_hash=_ZERO_HASH, - ommers_hash=EMPTY_OMMER_HASH, - coinbase=_ZERO_ADDR, - state_root=EMPTY_TRIE_ROOT, - transactions_root=EMPTY_TRIE_ROOT, - receipt_root=EMPTY_TRIE_ROOT, - bloom=_ZERO_BLOOM, - difficulty=Uint(0), - number=Uint(0), - gas_limit=Uint(30_000_000), - gas_used=Uint(0), - timestamp=U256(1_000_000), - extra_data=Bytes(b""), - prev_randao=_ZERO_BYTES32, - nonce=_ZERO_BYTES8, - base_fee_per_gas=Uint(7), - withdrawals_root=EMPTY_TRIE_ROOT, - blob_gas_used=U64(0), - excess_blob_gas=U64(0), - parent_beacon_block_root=_ZERO_ROOT, - requests_hash=_EMPTY_REQUESTS_HASH, - block_access_list_hash=_EMPTY_ACCESS_LIST_HASH, - ) - - -def _make_child_payload(parent_header: Header) -> ExecutionPayload: - """Build a child execution payload whose block_hash is correct.""" - parent_hash = Hash32(keccak256(rlp.encode(parent_header))) - parent_beacon_block_root = Root(_ZERO_ROOT) - - # Build a provisional payload (block_hash will be computed below). - provisional = ExecutionPayload( - parent_hash=parent_hash, - fee_recipient=_ZERO_ADDR, - state_root=EMPTY_TRIE_ROOT, - receipts_root=EMPTY_TRIE_ROOT, - logs_bloom=_ZERO_BLOOM, - prev_randao=_ZERO_BYTES32, - block_number=Uint(1), - gas_limit=Uint(30_000_000), - gas_used=Uint(0), - timestamp=U256(1_000_001), - extra_data=Bytes(b""), - base_fee_per_gas=Uint(7), - block_hash=_ZERO_HASH, - transactions=(), - withdrawals=(), - blob_gas_used=U64(0), - excess_blob_gas=U64(0), - block_access_list=Bytes(b""), - ) - - child_header = _payload_header(provisional, parent_beacon_block_root, ()) - block_hash = Hash32(keccak256(rlp.encode(child_header))) - - return ExecutionPayload( - parent_hash=parent_hash, - fee_recipient=_ZERO_ADDR, - state_root=EMPTY_TRIE_ROOT, - receipts_root=EMPTY_TRIE_ROOT, - logs_bloom=_ZERO_BLOOM, - prev_randao=_ZERO_BYTES32, - block_number=Uint(1), - gas_limit=Uint(30_000_000), - gas_used=Uint(0), - timestamp=U256(1_000_001), - extra_data=Bytes(b""), - base_fee_per_gas=Uint(7), - block_hash=block_hash, - transactions=(), - withdrawals=(), - blob_gas_used=U64(0), - excess_blob_gas=U64(0), - block_access_list=Bytes(b""), - ) - - -def _make_stateless_input() -> StatelessInput: - """Build a minimal StatelessInput with a valid parent header.""" - parent_header = _make_parent_header() - encoded_parent = Bytes(rlp.encode(parent_header)) - child_payload = _make_child_payload(parent_header) - - return StatelessInput( - new_payload_request=NewPayloadRequest( - execution_payload=child_payload, - versioned_hashes=(), - parent_beacon_block_root=_ZERO_ROOT, - execution_requests=(), - ), - witness=ExecutionWitness( - state=(), - codes=(), - headers=(encoded_parent,), - ), - chain_config=ChainConfig(chain_id=U64(1)), - public_keys=(), - ) - - -def test_entrypoint_produces_output() -> None: - """run_stateless_guest returns serialized StatelessValidationResult.""" - stateless_input = _make_stateless_input() - input_data = serialize_stateless_input(stateless_input) - - output_data = run_stateless_guest(input_data) - - result = rlp.decode_to(StatelessValidationResult, output_data) - assert result.chain_config == stateless_input.chain_config From 3acff965e9ceb00f4f363b954959efb209bbe6c6 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Tue, 3 Mar 2026 23:43:30 +0000 Subject: [PATCH 060/265] format --- src/ethereum/forks/amsterdam/incremental_mpt.py | 4 +--- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index 7c9d526decc..5e71db069a4 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -808,9 +808,7 @@ def _collapse_branch( _dirty=True, ) else: - raise AssertionError( - f"Unexpected node type {type(child)}" - ) + raise AssertionError(f"Unexpected node type {type(child)}") if len(non_empty) == 0 and node.value != b"": return MutableLeafNode( diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 621cd1029ef..ba9d9c1bf5d 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -411,9 +411,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # TODO: perhaps change this for t8n.all_txs minus rejected_txs? included_txs = [] for key in block_output.receipt_keys: - tx = t8n.fork.trie_get( - block_output.transactions_trie, key - ) + tx = t8n.fork.trie_get(block_output.transactions_trie, key) assert tx is not None included_txs.append(tx) @@ -440,9 +438,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: result = t8n.fork.deserialize_stateless_output( stateless_output_bytes ) - assert result.successful_validation, ( - "Stateless validation failed" - ) + assert result.successful_validation, "Stateless validation failed" self.stateless_input_bytes = bytes(stateless_input_bytes) self.stateless_output_bytes = bytes(stateless_output_bytes) From 9d613c5a2bc55ae16b69374ce5ac438c704ad44a Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Wed, 4 Mar 2026 18:58:55 +0000 Subject: [PATCH 061/265] new check on blockchain_from_state_tests --- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index ba9d9c1bf5d..9fb63779c5a 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -337,7 +337,14 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # the witness reads pre-state tries that apply_changes mutates. # This is safe because compute_state_root_and_trie_changes # does not mutate state (it makes transient copies of MPTs). - if t8n.fork.has_execution_witness: + if ( + t8n.fork.has_execution_witness + # state tests have no headers + and not t8n.options.state_test + # When we make a blockchain test from state test + # it has no headers + and t8n.env.block_headers + ): self.execution_witness = t8n.fork.build_execution_witness( block_env.state, expected_post_state_root=state_root_value, @@ -373,9 +380,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - # State tests lack the parent header needed for stateless - # verification, so skip the stateless roundtrip. - if self.execution_witness is not None and not t8n.options.state_test: + if self.execution_witness is not None: withdrawals = ( tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () ) @@ -422,6 +427,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: withdrawals=withdrawals, ) + assert self.requests is not None stateless_input = t8n.fork.build_stateless_input( block, execution_witness=self.execution_witness, From 67743426a6f76a23c5b5932593741efbed4f157f Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Wed, 4 Mar 2026 19:33:08 +0000 Subject: [PATCH 062/265] fail if stateful failed --- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 9fb63779c5a..6d47c9778dc 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -444,7 +444,12 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: result = t8n.fork.deserialize_stateless_output( stateless_output_bytes ) - assert result.successful_validation, "Stateless validation failed" + if t8n.txs.rejected_txs: + assert not result.successful_validation + else: + assert result.successful_validation, ( + "Stateless validation failed" + ) self.stateless_input_bytes = bytes(stateless_input_bytes) self.stateless_output_bytes = bytes(stateless_output_bytes) From 26a1fbaca0572add3f3016d12abc37c7b6d5d45f Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Wed, 4 Mar 2026 19:37:41 +0000 Subject: [PATCH 063/265] check for block exception --- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 6d47c9778dc..a5c54d4b427 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -444,7 +444,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: result = t8n.fork.deserialize_stateless_output( stateless_output_bytes ) - if t8n.txs.rejected_txs: + if t8n.txs.rejected_txs or self.block_exception: assert not result.successful_validation else: assert result.successful_validation, ( From 288ea51a49d50194d62a536d922fd8b9b6b61e11 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 00:29:14 +0000 Subject: [PATCH 064/265] ssz --- pyproject.toml | 1 + src/ethereum/forks/amsterdam/stateless.py | 16 +- .../forks/amsterdam/stateless_guest.py | 28 +- .../forks/amsterdam/stateless_host.py | 23 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 358 ++++++++++++++++++ tests/json_infra/test_stateless_guest.py | 34 +- uv.lock | 11 + 7 files changed, 433 insertions(+), 38 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/stateless_ssz.py diff --git a/pyproject.toml b/pyproject.toml index f91a8695bd8..112ca65be59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "py-ecc>=8.0.0b2,<9", "ethereum-types>=0.3.0,<0.4", "ethereum-rlp>=0.1.5,<0.2", + "eth-remerkleable>=0.1.29,<0.2", "cryptography>=45.0.1,<46", "platformdirs>=4.2,<5", "libcst>=1.8,<2", diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 12e99d6d5fe..093b9f7a59b 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -140,15 +140,17 @@ def compute_new_payload_request_root( stateless_input: StatelessInput, ) -> Hash32: """ - Compute the request root for a stateless input. + Compute the request root for a stateless input via SSZ hash tree root. - TODO: Replace with ``new_payload_request.tree_hash_root`` (SSZ). - - # For readability, we can convert to NewPayloadRequestHeader and - # then the payload request root. - # Using rlp for now since its available + TODO: For readability, convert to NewPayloadRequestHeader and + then compute the payload request root. """ - return keccak256(rlp.encode(stateless_input.new_payload_request)) + from .stateless_ssz import _new_payload_request_to_ssz + + ssz_npr = _new_payload_request_to_ssz( + stateless_input.new_payload_request + ) + return Hash32(ssz_npr.hash_tree_root()) def new_payload_request_to_block( diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index f1dc5206e69..ebf26f77bad 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -2,7 +2,6 @@ Stateless guest interfaces. """ -from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from .stateless import ( @@ -10,24 +9,25 @@ StatelessValidationResult, verify_stateless_new_payload, ) +from .stateless_ssz import ( + SszStatelessInput, + ssz_to_stateless_input, + validation_result_to_ssz, +) -def serialize_stateless_output(output: StatelessValidationResult) -> Bytes: - """ - Serialize a ``StatelessValidationResult`` to RLP-encoded bytes. - - TODO: change to ssz, rlp was easier to get working with codebase. - """ - return Bytes(rlp.encode(output)) +def serialize_stateless_output( + output: StatelessValidationResult, +) -> Bytes: + """Serialize a StatelessValidationResult to SSZ bytes.""" + ssz_obj = validation_result_to_ssz(output) + return Bytes(ssz_obj.encode_bytes()) def deserialize_stateless_input(data: Bytes) -> StatelessInput: - """ - Deserialize a ``StatelessInput`` from RLP-encoded bytes. - - TODO: change to ssz, rlp was easier to get working with codebase. - """ - return rlp.decode_to(StatelessInput, data) + """Deserialize a StatelessInput from SSZ bytes.""" + ssz_obj = SszStatelessInput.decode_bytes(data) + return ssz_to_stateless_input(ssz_obj) def run_stateless_guest(input_bytes: Bytes) -> Bytes: diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 85c4ceea89d..36f490749c8 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -20,6 +20,11 @@ StatelessInput, StatelessValidationResult, ) +from .stateless_ssz import ( + SszStatelessValidationResult, + ssz_to_validation_result, + stateless_input_to_ssz, +) from .transactions import ( BlobTransaction, LegacyTransaction, @@ -27,18 +32,18 @@ ) -def serialize_stateless_input(stateless_input: StatelessInput) -> Bytes: - """ - Serialize a ``StatelessInput`` to RLP-encoded bytes. - - TODO: change to ssz, rlp was easier to get working with codebase. - """ - return Bytes(rlp.encode(stateless_input)) +def serialize_stateless_input( + stateless_input: StatelessInput, +) -> Bytes: + """Serialize a StatelessInput to SSZ-encoded bytes.""" + ssz_obj = stateless_input_to_ssz(stateless_input) + return Bytes(ssz_obj.encode_bytes()) def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: - """Deserialize a StatelessValidationResult from RLP bytes.""" - return rlp.decode_to(StatelessValidationResult, data) + """Deserialize a StatelessValidationResult from SSZ bytes.""" + ssz_obj = SszStatelessValidationResult.decode_bytes(data) + return ssz_to_validation_result(ssz_obj) def build_stateless_input( diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py new file mode 100644 index 00000000000..c8ec3e9bdd1 --- /dev/null +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -0,0 +1,358 @@ +""" +SSZ serialization schema for stateless validation types. + +Define ``Container`` subclasses (eth-remerkleable) that mirror the dataclass +types in ``stateless`` and ``execution_engine.types``, plus conversion +functions between the two representations. +""" + +from remerkleable.basic import boolean, uint64, uint256 +from remerkleable.byte_arrays import ByteList, ByteVector, Bytes32 +from remerkleable.complex import Container, List as SszList + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32 +from ethereum.state import Address, Root + +from .blocks import Withdrawal +from .execution_engine.types import ExecutionPayload, NewPayloadRequest +from .fork_types import Bloom, VersionedHash +from .stateless import ( + ChainConfig, + ExecutionWitness, + StatelessInput, + StatelessValidationResult, +) + + +# --- SSZ max-length constants --- + +MAX_EXTRA_DATA_BYTES = 32 +MAX_BYTES_PER_TRANSACTION = 2**30 +MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 +MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 +MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 +MAX_EXECUTION_REQUESTS = 16 +MAX_BYTES_PER_REQUEST = 2**20 +MAX_BLOCK_ACCESS_LIST_BYTES = 2**24 +MAX_WITNESS_NODES = 2**20 +MAX_WITNESS_CODES = 2**16 +MAX_WITNESS_HEADERS = 256 +MAX_BYTES_PER_WITNESS_NODE = 2**20 +MAX_BYTES_PER_CODE = 2**24 +MAX_BYTES_PER_HEADER = 2**10 +MAX_PUBLIC_KEYS = 2**20 +MAX_BYTES_PER_PUBLIC_KEY = 48 + + +# --- SSZ Container types --- + + +class SszWithdrawal(Container): + """SSZ container mirroring ``Withdrawal``.""" + + index: uint64 + validator_index: uint64 + address: ByteVector[20] + amount: uint256 + + +class SszExecutionPayload(Container): + """SSZ container mirroring ``ExecutionPayload``.""" + + parent_hash: Bytes32 + fee_recipient: ByteVector[20] + state_root: Bytes32 + receipts_root: Bytes32 + logs_bloom: ByteVector[256] + prev_randao: Bytes32 + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: Bytes32 + transactions: SszList[ByteList[MAX_BYTES_PER_TRANSACTION], MAX_TRANSACTIONS_PER_PAYLOAD] # noqa: E501 + withdrawals: SszList[SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + blob_gas_used: uint64 + excess_blob_gas: uint64 + block_access_list: ByteList[MAX_BLOCK_ACCESS_LIST_BYTES] + + +class SszNewPayloadRequest(Container): + """SSZ container mirroring ``NewPayloadRequest``.""" + + execution_payload: SszExecutionPayload + versioned_hashes: SszList[Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK] + parent_beacon_block_root: Bytes32 + execution_requests: SszList[ByteList[MAX_BYTES_PER_REQUEST], MAX_EXECUTION_REQUESTS] # noqa: E501 + + +class SszExecutionWitness(Container): + """SSZ container mirroring ``ExecutionWitness``.""" + + state: SszList[ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES] + codes: SszList[ByteList[MAX_BYTES_PER_CODE], MAX_WITNESS_CODES] + headers: SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] + + +class SszChainConfig(Container): + """SSZ container mirroring ``ChainConfig``.""" + + chain_id: uint64 + + +class SszStatelessInput(Container): + """SSZ container mirroring ``StatelessInput``.""" + + new_payload_request: SszNewPayloadRequest + witness: SszExecutionWitness + chain_config: SszChainConfig + public_keys: SszList[ByteList[MAX_BYTES_PER_PUBLIC_KEY], MAX_PUBLIC_KEYS] + + +class SszStatelessValidationResult(Container): + """SSZ container mirroring ``StatelessValidationResult``.""" + + new_payload_request_root: Bytes32 + successful_validation: boolean + chain_config: SszChainConfig + + +# --- Conversion helpers --- + + +def _withdrawal_to_ssz(w: Withdrawal) -> SszWithdrawal: + """Convert a Withdrawal to its SSZ form.""" + return SszWithdrawal( + index=uint64(int(w.index)), + validator_index=uint64(int(w.validator_index)), + address=ByteVector[20](bytes(w.address)), + amount=uint256(int(w.amount)), + ) + + +def _ssz_to_withdrawal(sw: SszWithdrawal) -> Withdrawal: + """Convert an SSZ withdrawal back to a Withdrawal.""" + return Withdrawal( + index=U64(sw.index), + validator_index=U64(sw.validator_index), + address=Address(bytes(sw.address)), + amount=U256(sw.amount), + ) + + +def _payload_to_ssz( + p: ExecutionPayload, +) -> SszExecutionPayload: + """Convert an ExecutionPayload to its SSZ form.""" + return SszExecutionPayload( + parent_hash=Bytes32(bytes(p.parent_hash)), + fee_recipient=ByteVector[20](bytes(p.fee_recipient)), + state_root=Bytes32(bytes(p.state_root)), + receipts_root=Bytes32(bytes(p.receipts_root)), + logs_bloom=ByteVector[256](bytes(p.logs_bloom)), + prev_randao=Bytes32(bytes(p.prev_randao)), + block_number=uint64(int(p.block_number)), + gas_limit=uint64(int(p.gas_limit)), + gas_used=uint64(int(p.gas_used)), + timestamp=uint64(int(p.timestamp)), + extra_data=ByteList[MAX_EXTRA_DATA_BYTES](bytes(p.extra_data)), + base_fee_per_gas=uint256(int(p.base_fee_per_gas)), + block_hash=Bytes32(bytes(p.block_hash)), + transactions=SszList[ + ByteList[MAX_BYTES_PER_TRANSACTION], + MAX_TRANSACTIONS_PER_PAYLOAD, + ]( + ByteList[MAX_BYTES_PER_TRANSACTION](bytes(tx)) + for tx in p.transactions + ), + withdrawals=SszList[ + SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD + ](_withdrawal_to_ssz(w) for w in p.withdrawals), + blob_gas_used=uint64(int(p.blob_gas_used)), + excess_blob_gas=uint64(int(p.excess_blob_gas)), + block_access_list=ByteList[MAX_BLOCK_ACCESS_LIST_BYTES]( + bytes(p.block_access_list) + ), + ) + + +def _ssz_to_payload( + sp: SszExecutionPayload, +) -> ExecutionPayload: + """Convert an SSZ execution payload back to ExecutionPayload.""" + return ExecutionPayload( + parent_hash=Hash32(bytes(sp.parent_hash)), + fee_recipient=Address(bytes(sp.fee_recipient)), + state_root=Root(bytes(sp.state_root)), + receipts_root=Root(bytes(sp.receipts_root)), + logs_bloom=Bloom(bytes(sp.logs_bloom)), + prev_randao=Bytes32(bytes(sp.prev_randao)), + block_number=Uint(sp.block_number), + gas_limit=Uint(sp.gas_limit), + gas_used=Uint(sp.gas_used), + timestamp=U256(sp.timestamp), + extra_data=Bytes(bytes(sp.extra_data)), + base_fee_per_gas=Uint(sp.base_fee_per_gas), + block_hash=Hash32(bytes(sp.block_hash)), + transactions=tuple( + Bytes(bytes(tx)) for tx in sp.transactions + ), + withdrawals=tuple( + _ssz_to_withdrawal(sw) for sw in sp.withdrawals + ), + blob_gas_used=U64(sp.blob_gas_used), + excess_blob_gas=U64(sp.excess_blob_gas), + block_access_list=Bytes(bytes(sp.block_access_list)), + ) + + +def _new_payload_request_to_ssz( + npr: NewPayloadRequest, +) -> SszNewPayloadRequest: + """Convert a NewPayloadRequest to its SSZ form.""" + return SszNewPayloadRequest( + execution_payload=_payload_to_ssz(npr.execution_payload), + versioned_hashes=SszList[ + Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK + ](Bytes32(bytes(vh)) for vh in npr.versioned_hashes), + parent_beacon_block_root=Bytes32( + bytes(npr.parent_beacon_block_root) + ), + execution_requests=SszList[ + ByteList[MAX_BYTES_PER_REQUEST], + MAX_EXECUTION_REQUESTS, + ]( + ByteList[MAX_BYTES_PER_REQUEST](bytes(er)) + for er in npr.execution_requests + ), + ) + + +def _ssz_to_new_payload_request( + snpr: SszNewPayloadRequest, +) -> NewPayloadRequest: + """Convert an SSZ new payload request back.""" + return NewPayloadRequest( + execution_payload=_ssz_to_payload(snpr.execution_payload), + versioned_hashes=tuple( + VersionedHash(bytes(vh)) + for vh in snpr.versioned_hashes + ), + parent_beacon_block_root=Root( + bytes(snpr.parent_beacon_block_root) + ), + execution_requests=tuple( + Bytes(bytes(er)) for er in snpr.execution_requests + ), + ) + + +def _witness_to_ssz( + w: ExecutionWitness, +) -> SszExecutionWitness: + """Convert an ExecutionWitness to its SSZ form.""" + return SszExecutionWitness( + state=SszList[ + ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES + ](ByteList[MAX_BYTES_PER_WITNESS_NODE](bytes(s)) for s in w.state), + codes=SszList[ByteList[MAX_BYTES_PER_CODE], MAX_WITNESS_CODES]( + ByteList[MAX_BYTES_PER_CODE](bytes(c)) for c in w.codes + ), + headers=SszList[ + ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS + ]( + ByteList[MAX_BYTES_PER_HEADER](bytes(h)) + for h in w.headers + ), + ) + + +def _ssz_to_witness( + sw: SszExecutionWitness, +) -> ExecutionWitness: + """Convert an SSZ execution witness back.""" + return ExecutionWitness( + state=tuple(Bytes(bytes(s)) for s in sw.state), + codes=tuple(Bytes(bytes(c)) for c in sw.codes), + headers=tuple(Bytes(bytes(h)) for h in sw.headers), + ) + + +def _chain_config_to_ssz( + cc: ChainConfig, +) -> SszChainConfig: + """Convert a ChainConfig to its SSZ form.""" + return SszChainConfig(chain_id=uint64(int(cc.chain_id))) + + +def _ssz_to_chain_config( + scc: SszChainConfig, +) -> ChainConfig: + """Convert an SSZ chain config back.""" + return ChainConfig(chain_id=U64(scc.chain_id)) + + +def stateless_input_to_ssz( + si: StatelessInput, +) -> SszStatelessInput: + """Convert a StatelessInput to its SSZ form.""" + return SszStatelessInput( + new_payload_request=_new_payload_request_to_ssz( + si.new_payload_request + ), + witness=_witness_to_ssz(si.witness), + chain_config=_chain_config_to_ssz(si.chain_config), + public_keys=SszList[ + ByteList[MAX_BYTES_PER_PUBLIC_KEY], MAX_PUBLIC_KEYS + ]( + ByteList[MAX_BYTES_PER_PUBLIC_KEY](bytes(pk)) + for pk in si.public_keys + ), + ) + + +def ssz_to_stateless_input( + ssz_si: SszStatelessInput, +) -> StatelessInput: + """Convert an SSZ stateless input back.""" + return StatelessInput( + new_payload_request=_ssz_to_new_payload_request( + ssz_si.new_payload_request + ), + witness=_ssz_to_witness(ssz_si.witness), + chain_config=_ssz_to_chain_config(ssz_si.chain_config), + public_keys=tuple( + Bytes(bytes(pk)) for pk in ssz_si.public_keys + ), + ) + + +def validation_result_to_ssz( + vr: StatelessValidationResult, +) -> SszStatelessValidationResult: + """Convert a StatelessValidationResult to its SSZ form.""" + return SszStatelessValidationResult( + new_payload_request_root=Bytes32( + bytes(vr.new_payload_request_root) + ), + successful_validation=boolean(vr.successful_validation), + chain_config=_chain_config_to_ssz(vr.chain_config), + ) + + +def ssz_to_validation_result( + ssz_vr: SszStatelessValidationResult, +) -> StatelessValidationResult: + """Convert an SSZ validation result back.""" + return StatelessValidationResult( + new_payload_request_root=Hash32( + bytes(ssz_vr.new_payload_request_root) + ), + successful_validation=bool(ssz_vr.successful_validation), + chain_config=_ssz_to_chain_config(ssz_vr.chain_config), + ) diff --git a/tests/json_infra/test_stateless_guest.py b/tests/json_infra/test_stateless_guest.py index d23f6aad7ce..d488079eb41 100644 --- a/tests/json_infra/test_stateless_guest.py +++ b/tests/json_infra/test_stateless_guest.py @@ -3,7 +3,6 @@ import random from typing import Tuple -from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U64, U256, Uint @@ -18,12 +17,14 @@ ExecutionWitness, StatelessInput, StatelessValidationResult, + compute_new_payload_request_root, ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, serialize_stateless_output, ) from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, serialize_stateless_input, ) from ethereum.state import Address, Root @@ -120,8 +121,8 @@ class TestDeserializeStatelessInput: def test_roundtrip(self) -> None: """Encoding then decoding recovers the original StatelessInput.""" original = _make_stateless_input() - encoded = rlp.encode(original) - recovered = deserialize_stateless_input(Bytes(encoded)) + encoded = serialize_stateless_input(original) + recovered = deserialize_stateless_input(encoded) assert recovered == original def test_empty_witness(self) -> None: @@ -137,7 +138,8 @@ def test_empty_witness(self) -> None: chain_config=ChainConfig(chain_id=U64(1)), public_keys=(), ) - recovered = deserialize_stateless_input(Bytes(rlp.encode(original))) + encoded = serialize_stateless_input(original) + recovered = deserialize_stateless_input(encoded) assert recovered == original @@ -148,7 +150,7 @@ def test_roundtrip(self) -> None: """Encoding then decoding recovers the original result.""" original = _make_stateless_output() encoded = serialize_stateless_output(original) - recovered = rlp.decode_to(StatelessValidationResult, encoded) + recovered = deserialize_stateless_output(encoded) assert recovered == original def test_failed_validation(self) -> None: @@ -158,7 +160,23 @@ def test_failed_validation(self) -> None: successful_validation=False, chain_config=ChainConfig(chain_id=U64(1)), ) - recovered = rlp.decode_to( - StatelessValidationResult, serialize_stateless_output(original) - ) + encoded = serialize_stateless_output(original) + recovered = deserialize_stateless_output(encoded) assert recovered == original + + +class TestComputeNewPayloadRequestRoot: + """Test compute_new_payload_request_root.""" + + def test_hash_tree_root_is_deterministic(self) -> None: + """Same input produces the same root.""" + si = _make_stateless_input() + root_a = compute_new_payload_request_root(si) + root_b = compute_new_payload_request_root(si) + assert root_a == root_b + + def test_hash_tree_root_is_32_bytes(self) -> None: + """Root is always 32 bytes.""" + si = _make_stateless_input() + root = compute_new_payload_request_root(si) + assert len(root) == 32 diff --git a/uv.lock b/uv.lock index 47ff3bfd8d2..6165e6f8fa2 100644 --- a/uv.lock +++ b/uv.lock @@ -765,6 +765,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/db/f8775490669d28aca24871c67dd56b3e72105cb3bcae9a4ec65dd70859b3/eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a", size = 8028, upload-time = "2025-01-13T21:29:19.365Z" }, ] +[[package]] +name = "eth-remerkleable" +version = "0.1.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/16/b91f3fc0c46f8ff597f26fbbbc1d8fbe7ab8d5103e00546417455298b449/eth_remerkleable-0.1.29.tar.gz", hash = "sha256:2418b008cd7471d0f5ab37e5a9784ef4184e503192e6f38c223fe25b7cbbcac3", size = 49252, upload-time = "2026-02-18T00:01:54.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/c0/f9d34d93b36563482368a60b09b839fd879dcbb36882ed8bb597d4dd9768/eth_remerkleable-0.1.29-py3-none-any.whl", hash = "sha256:0763da127f49795a3f98e424bfea008f26c75d8970a65ebef73d09c4b7ba4973", size = 56855, upload-time = "2026-02-18T00:01:52.631Z" }, +] + [[package]] name = "eth-typing" version = "5.2.1" @@ -820,6 +829,7 @@ source = { editable = "." } dependencies = [ { name = "coincurve" }, { name = "cryptography" }, + { name = "eth-remerkleable" }, { name = "ethereum-rlp" }, { name = "ethereum-types" }, { name = "libcst" }, @@ -930,6 +940,7 @@ test = [ requires-dist = [ { name = "coincurve", specifier = ">=20,<21" }, { name = "cryptography", specifier = ">=45.0.1,<46" }, + { name = "eth-remerkleable", specifier = ">=0.1.29,<0.2" }, { name = "ethash", marker = "extra == 'optimized'", specifier = ">=1.1.0,<2" }, { name = "ethereum-rlp", specifier = ">=0.1.5,<0.2" }, { name = "ethereum-types", specifier = ">=0.3.0,<0.4" }, From 4bf55fd5e945411cff6a9c41af2650b9b9d17a04 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 00:39:37 +0000 Subject: [PATCH 065/265] lint --- src/ethereum/forks/amsterdam/stateless.py | 4 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 67 ++++++++----------- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 093b9f7a59b..4d0a02bebeb 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -147,9 +147,7 @@ def compute_new_payload_request_root( """ from .stateless_ssz import _new_payload_request_to_ssz - ssz_npr = _new_payload_request_to_ssz( - stateless_input.new_payload_request - ) + ssz_npr = _new_payload_request_to_ssz(stateless_input.new_payload_request) return Hash32(ssz_npr.hash_tree_root()) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index c8ec3e9bdd1..c46561cd0c7 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -6,12 +6,12 @@ functions between the two representations. """ -from remerkleable.basic import boolean, uint64, uint256 -from remerkleable.byte_arrays import ByteList, ByteVector, Bytes32 -from remerkleable.complex import Container, List as SszList - from ethereum_types.bytes import Bytes from ethereum_types.numeric import U64, U256, Uint +from remerkleable.basic import boolean, uint64, uint256 +from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector +from remerkleable.complex import Container +from remerkleable.complex import List as SszList from ethereum.crypto.hash import Hash32 from ethereum.state import Address, Root @@ -26,7 +26,6 @@ StatelessValidationResult, ) - # --- SSZ max-length constants --- MAX_EXTRA_DATA_BYTES = 32 @@ -75,7 +74,9 @@ class SszExecutionPayload(Container): extra_data: ByteList[MAX_EXTRA_DATA_BYTES] base_fee_per_gas: uint256 block_hash: Bytes32 - transactions: SszList[ByteList[MAX_BYTES_PER_TRANSACTION], MAX_TRANSACTIONS_PER_PAYLOAD] # noqa: E501 + transactions: SszList[ + ByteList[MAX_BYTES_PER_TRANSACTION], MAX_TRANSACTIONS_PER_PAYLOAD + ] # noqa: E501 withdrawals: SszList[SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] blob_gas_used: uint64 excess_blob_gas: uint64 @@ -88,7 +89,9 @@ class SszNewPayloadRequest(Container): execution_payload: SszExecutionPayload versioned_hashes: SszList[Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK] parent_beacon_block_root: Bytes32 - execution_requests: SszList[ByteList[MAX_BYTES_PER_REQUEST], MAX_EXECUTION_REQUESTS] # noqa: E501 + execution_requests: SszList[ + ByteList[MAX_BYTES_PER_REQUEST], MAX_EXECUTION_REQUESTS + ] # noqa: E501 class SszExecutionWitness(Container): @@ -170,9 +173,9 @@ def _payload_to_ssz( ByteList[MAX_BYTES_PER_TRANSACTION](bytes(tx)) for tx in p.transactions ), - withdrawals=SszList[ - SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD - ](_withdrawal_to_ssz(w) for w in p.withdrawals), + withdrawals=SszList[SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]( + _withdrawal_to_ssz(w) for w in p.withdrawals + ), blob_gas_used=uint64(int(p.blob_gas_used)), excess_blob_gas=uint64(int(p.excess_blob_gas)), block_access_list=ByteList[MAX_BLOCK_ACCESS_LIST_BYTES]( @@ -199,12 +202,8 @@ def _ssz_to_payload( extra_data=Bytes(bytes(sp.extra_data)), base_fee_per_gas=Uint(sp.base_fee_per_gas), block_hash=Hash32(bytes(sp.block_hash)), - transactions=tuple( - Bytes(bytes(tx)) for tx in sp.transactions - ), - withdrawals=tuple( - _ssz_to_withdrawal(sw) for sw in sp.withdrawals - ), + transactions=tuple(Bytes(bytes(tx)) for tx in sp.transactions), + withdrawals=tuple(_ssz_to_withdrawal(sw) for sw in sp.withdrawals), blob_gas_used=U64(sp.blob_gas_used), excess_blob_gas=U64(sp.excess_blob_gas), block_access_list=Bytes(bytes(sp.block_access_list)), @@ -217,12 +216,10 @@ def _new_payload_request_to_ssz( """Convert a NewPayloadRequest to its SSZ form.""" return SszNewPayloadRequest( execution_payload=_payload_to_ssz(npr.execution_payload), - versioned_hashes=SszList[ - Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK - ](Bytes32(bytes(vh)) for vh in npr.versioned_hashes), - parent_beacon_block_root=Bytes32( - bytes(npr.parent_beacon_block_root) + versioned_hashes=SszList[Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK]( + Bytes32(bytes(vh)) for vh in npr.versioned_hashes ), + parent_beacon_block_root=Bytes32(bytes(npr.parent_beacon_block_root)), execution_requests=SszList[ ByteList[MAX_BYTES_PER_REQUEST], MAX_EXECUTION_REQUESTS, @@ -240,12 +237,9 @@ def _ssz_to_new_payload_request( return NewPayloadRequest( execution_payload=_ssz_to_payload(snpr.execution_payload), versioned_hashes=tuple( - VersionedHash(bytes(vh)) - for vh in snpr.versioned_hashes - ), - parent_beacon_block_root=Root( - bytes(snpr.parent_beacon_block_root) + VersionedHash(bytes(vh)) for vh in snpr.versioned_hashes ), + parent_beacon_block_root=Root(bytes(snpr.parent_beacon_block_root)), execution_requests=tuple( Bytes(bytes(er)) for er in snpr.execution_requests ), @@ -257,17 +251,14 @@ def _witness_to_ssz( ) -> SszExecutionWitness: """Convert an ExecutionWitness to its SSZ form.""" return SszExecutionWitness( - state=SszList[ - ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES - ](ByteList[MAX_BYTES_PER_WITNESS_NODE](bytes(s)) for s in w.state), + state=SszList[ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES]( + ByteList[MAX_BYTES_PER_WITNESS_NODE](bytes(s)) for s in w.state + ), codes=SszList[ByteList[MAX_BYTES_PER_CODE], MAX_WITNESS_CODES]( ByteList[MAX_BYTES_PER_CODE](bytes(c)) for c in w.codes ), - headers=SszList[ - ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS - ]( - ByteList[MAX_BYTES_PER_HEADER](bytes(h)) - for h in w.headers + headers=SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS]( + ByteList[MAX_BYTES_PER_HEADER](bytes(h)) for h in w.headers ), ) @@ -326,9 +317,7 @@ def ssz_to_stateless_input( ), witness=_ssz_to_witness(ssz_si.witness), chain_config=_ssz_to_chain_config(ssz_si.chain_config), - public_keys=tuple( - Bytes(bytes(pk)) for pk in ssz_si.public_keys - ), + public_keys=tuple(Bytes(bytes(pk)) for pk in ssz_si.public_keys), ) @@ -337,9 +326,7 @@ def validation_result_to_ssz( ) -> SszStatelessValidationResult: """Convert a StatelessValidationResult to its SSZ form.""" return SszStatelessValidationResult( - new_payload_request_root=Bytes32( - bytes(vr.new_payload_request_root) - ), + new_payload_request_root=Bytes32(bytes(vr.new_payload_request_root)), successful_validation=boolean(vr.successful_validation), chain_config=_chain_config_to_ssz(vr.chain_config), ) From 431326e321033a6eccd7162cd4e692473f4b36d2 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 00:45:44 +0000 Subject: [PATCH 066/265] stubs --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 112ca65be59..84c5492aed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -492,6 +492,10 @@ exclude = [ ] plugins = ["pydantic.mypy"] +[[tool.mypy.overrides]] +module = "remerkleable.*" +ignore_missing_imports = true #stubs seemed quite non-trivial + [tool.uv] required-version = ">=0.7.0" extra-build-dependencies = { ethash = ["setuptools", "cmake>=4.2.1,<5"] } From 5855ed220e8389c681c7d6362bf7583570d0e712 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 01:08:40 +0000 Subject: [PATCH 067/265] skip --- .../eip4895_withdrawals/test_withdrawals.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 117b0370131..115380eaf85 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -297,7 +297,17 @@ def test_withdrawals_root( blockchain_test(pre=pre, post={}, blocks=blocks) -@pytest.mark.parametrize("test_case", ["single_block", "multiple_blocks"]) +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + "single_block", + # TODO(zkevm): CL expects a max of 16 withdrawals + marks=pytest.mark.valid_until("BPO5"), + ), + "multiple_blocks", + ], +) class TestMultipleWithdrawalsSameAddress: """ Test that multiple withdrawals can be sent to the same address. @@ -374,6 +384,9 @@ def test_multiple_withdrawals_same_address( blockchain_test(pre=pre, post=post, blocks=blocks) +# TODO(zkevm): CL expects a max of 16 withdrawals +# so we dont activate on amsterdam, when 8025 activates +@pytest.mark.valid_until("BPO5") def test_many_withdrawals( blockchain_test: BlockchainTestFiller, pre: Alloc, From 3f087b244c4fdb381bbd051d72f664eb211e72aa Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 09:36:29 +0000 Subject: [PATCH 068/265] Revert "skip" This reverts commit 5855ed220e8389c681c7d6362bf7583570d0e712. --- .../eip4895_withdrawals/test_withdrawals.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 115380eaf85..117b0370131 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -297,17 +297,7 @@ def test_withdrawals_root( blockchain_test(pre=pre, post={}, blocks=blocks) -@pytest.mark.parametrize( - "test_case", - [ - pytest.param( - "single_block", - # TODO(zkevm): CL expects a max of 16 withdrawals - marks=pytest.mark.valid_until("BPO5"), - ), - "multiple_blocks", - ], -) +@pytest.mark.parametrize("test_case", ["single_block", "multiple_blocks"]) class TestMultipleWithdrawalsSameAddress: """ Test that multiple withdrawals can be sent to the same address. @@ -384,9 +374,6 @@ def test_multiple_withdrawals_same_address( blockchain_test(pre=pre, post=post, blocks=blocks) -# TODO(zkevm): CL expects a max of 16 withdrawals -# so we dont activate on amsterdam, when 8025 activates -@pytest.mark.valid_until("BPO5") def test_many_withdrawals( blockchain_test: BlockchainTestFiller, pre: Alloc, From 9c9360db995a073d8d608ad317693889e6541805 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 09:38:52 +0000 Subject: [PATCH 069/265] bump ssz limit --- src/ethereum/forks/amsterdam/stateless_ssz.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index c46561cd0c7..ccc608f2abc 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -31,7 +31,10 @@ MAX_EXTRA_DATA_BYTES = 32 MAX_BYTES_PER_TRANSACTION = 2**30 MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 -MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 +# TODO: CL spec defines MAX_WITHDRAWALS_PER_PAYLOAD as 2**4 (16). +# Some fill tests exceed this; raised to 2**16 until those tests are +# capped or skipped for Amsterdam. +MAX_WITHDRAWALS_PER_PAYLOAD = 2**16 MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 MAX_EXECUTION_REQUESTS = 16 MAX_BYTES_PER_REQUEST = 2**20 From 2d68a903f7ea7abeb9e238ff0cb068fafa8101fa Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 11:10:20 +0000 Subject: [PATCH 070/265] remove input/output_bytes and enable state tests again --- .../client_clis/cli_types.py | 5 +- .../execution_testing/fixtures/blockchain.py | 5 +- .../src/execution_testing/specs/blockchain.py | 31 +-- .../evm_tools/t8n/t8n_types.py | 215 +++++++++--------- 4 files changed, 135 insertions(+), 121 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index 84485d68f3c..4bd500a31d7 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -291,8 +291,9 @@ class Result(CamelModel): block_access_list: BlockAccessList | None = None block_access_list_hash: Hash | None = None execution_witness: ExecutionWitness | None = None - stateless_input_bytes: Bytes | None = None - stateless_output_bytes: Bytes | None = None + # TODO: Re-enable, compare fixtures will fail + # stateless_input_bytes: Bytes | None = None + # stateless_output_bytes: Bytes | None = None block_exception: Annotated[ BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 9dbc6926ed5..b24f4cc7e20 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -626,8 +626,9 @@ def strip_block_number_computed_field(cls, data: Any) -> Any: withdrawals: List[FixtureWithdrawal] | None = None receipts: List[FixtureTransactionReceipt] | None = None execution_witness: ExecutionWitness | None = None - stateless_input_bytes: Bytes | None = None - stateless_output_bytes: Bytes | None = None + # TODO: Re-enable, compare fixtures will fail + # stateless_input_bytes: Bytes | None = None + # stateless_output_bytes: Bytes | None = None block_access_list: BlockAccessList | None = Field( None, description="EIP-7928 Block Access List" ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 73ab8ee664e..e78dc9aa48c 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -387,8 +387,9 @@ class BuiltBlock(CamelModel): fork: Fork block_access_list: BlockAccessList | None execution_witness: ExecutionWitness | None = None - stateless_input_bytes: Bytes | None = None - stateless_output_bytes: Bytes | None = None + # TODO: Re-enable, compare fixtures will fail + # stateless_input_bytes: Bytes | None = None + # stateless_output_bytes: Bytes | None = None def get_fixture_block( self, *, include_receipts: bool = True @@ -421,12 +422,13 @@ def get_fixture_block( execution_witness=self.execution_witness if self.execution_witness else None, - stateless_input_bytes=self.stateless_input_bytes - if self.stateless_input_bytes - else None, - stateless_output_bytes=self.stateless_output_bytes - if self.stateless_output_bytes - else None, + # TODO: Re-enable once SSZ encoding is finalized + # stateless_input_bytes=self.stateless_input_bytes + # if self.stateless_input_bytes + # else None, + # stateless_output_bytes=self.stateless_output_bytes + # if self.stateless_output_bytes + # else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -793,12 +795,13 @@ def generate_block_data( execution_witness=( transition_tool_output.result.execution_witness ), - stateless_input_bytes=( - transition_tool_output.result.stateless_input_bytes - ), - stateless_output_bytes=( - transition_tool_output.result.stateless_output_bytes - ), + # TODO: Re-enable, compare fixtures will fail + # stateless_input_bytes=( + # transition_tool_output.result.stateless_input_bytes + # ), + # stateless_output_bytes=( + # transition_tool_output.result.stateless_output_bytes + # ), ) try: diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index a5c54d4b427..5142f470cd2 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from ethereum_rlp import Simple, rlp -from ethereum_types.bytes import Bytes, Bytes8 +from ethereum_types.bytes import Bytes from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 @@ -278,8 +278,9 @@ class Result: block_access_list: Optional[Any] = None block_access_list_hash: Optional[Hash32] = None execution_witness: Optional[Any] = None - stateless_input_bytes: Optional[bytes] = None - stateless_output_bytes: Optional[bytes] = None + # TODO: Re-enable, compare fixtures will fail + # stateless_input_bytes: Optional[bytes] = None + # stateless_output_bytes: Optional[bytes] = None def get_receipts_from_output( self, @@ -333,25 +334,28 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: ) self.state_root = state_root_value - # Build witness between state root and apply_changes_to_state: - # the witness reads pre-state tries that apply_changes mutates. - # This is safe because compute_state_root_and_trie_changes - # does not mutate state (it makes transient copies of MPTs). - if ( - t8n.fork.has_execution_witness - # state tests have no headers - and not t8n.options.state_test - # When we make a blockchain test from state test - # it has no headers - and t8n.env.block_headers - ): - self.execution_witness = t8n.fork.build_execution_witness( - block_env.state, - expected_post_state_root=state_root_value, - pre_state_accounts_data=(t8n.alloc.state._main_trie), - pre_state_storages_data=(t8n.alloc.state._storage_tries), - blockchain_headers=t8n.env.block_headers, - ) + # TODO: Re-enable once stateless guest handles state tests + # # Build witness between state root and apply_changes_to_state: + # # the witness reads pre-state tries that apply_changes mutates. + # # This is safe because compute_state_root_and_trie_changes + # # does not mutate state (it makes transient copies of MPTs). + # if ( + # t8n.fork.has_execution_witness + # # state tests have no headers + # and not t8n.options.state_test + # # When we make a blockchain test from state test + # # it has no headers + # and t8n.env.block_headers + # ): + # self.execution_witness = t8n.fork.build_execution_witness( + # block_env.state, + # expected_post_state_root=state_root_value, + # pre_state_accounts_data=(t8n.alloc.state._main_trie), + # pre_state_storages_data=(t8n.alloc.state._storage_tries), + # blockchain_headers=t8n.env.block_headers, + # ) + if hasattr(block_output, "execution_witness"): + self.execution_witness = block_output.execution_witness # Apply diffs to pre-state for alloc output apply_changes_to_state(t8n.alloc.state, block_diff) @@ -380,78 +384,82 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - if self.execution_witness is not None: - withdrawals = ( - tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () - ) - - # Build the block header from execution results. - bh = block_env.block_hashes - assert bh and bh[-1] is not None - header = t8n.fork.Header( - parent_hash=Hash32(bytes(bh[-1])), - ommers_hash=keccak256(rlp.encode([])), - coinbase=block_env.coinbase, - state_root=self.state_root, - transactions_root=self.tx_root, - receipt_root=self.receipt_root, - bloom=self.bloom, - difficulty=Uint(0), - number=block_env.number, - gas_limit=block_env.block_gas_limit, - gas_used=self.gas_used, - timestamp=block_env.time, - extra_data=Bytes(b""), - prev_randao=block_env.prev_randao, - nonce=Bytes8(b"\x00" * 8), - base_fee_per_gas=block_env.base_fee_per_gas, - withdrawals_root=self.withdrawals_root, - blob_gas_used=block_output.blob_gas_used, - excess_blob_gas=block_env.excess_blob_gas, - parent_beacon_block_root=block_env.parent_beacon_block_root, - requests_hash=self.requests_hash, - block_access_list_hash=self.block_access_list_hash, - ) - - # TODO: perhaps change this for t8n.all_txs minus rejected_txs? - included_txs = [] - for key in block_output.receipt_keys: - tx = t8n.fork.trie_get(block_output.transactions_trie, key) - assert tx is not None - included_txs.append(tx) - - block = t8n.fork.Block( - header=header, - transactions=tuple(included_txs), - ommers=(), - withdrawals=withdrawals, - ) - - assert self.requests is not None - stateless_input = t8n.fork.build_stateless_input( - block, - execution_witness=self.execution_witness, - execution_requests=tuple(self.requests), - block_access_list=self.block_access_list, - chain_id=block_env.chain_id, - ) - stateless_input_bytes = t8n.fork.serialize_stateless_input( - stateless_input - ) - stateless_output_bytes = t8n.fork.run_stateless_guest( - stateless_input_bytes - ) - result = t8n.fork.deserialize_stateless_output( - stateless_output_bytes - ) - if t8n.txs.rejected_txs or self.block_exception: - assert not result.successful_validation - else: - assert result.successful_validation, ( - "Stateless validation failed" - ) - self.stateless_input_bytes = bytes(stateless_input_bytes) - self.stateless_output_bytes = bytes(stateless_output_bytes) + # TODO: Re-enable stateless guest once state test handling is + # resolved. Currently fails on state tests. + # if self.execution_witness is not None: + # withdrawals = ( + # tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () + # ) + # + # # Build the block header from execution results. + # bh = block_env.block_hashes + # assert bh and bh[-1] is not None + # header = t8n.fork.Header( + # parent_hash=Hash32(bytes(bh[-1])), + # ommers_hash=keccak256(rlp.encode([])), + # coinbase=block_env.coinbase, + # state_root=self.state_root, + # transactions_root=self.tx_root, + # receipt_root=self.receipt_root, + # bloom=self.bloom, + # difficulty=Uint(0), + # number=block_env.number, + # gas_limit=block_env.block_gas_limit, + # gas_used=self.gas_used, + # timestamp=block_env.time, + # extra_data=Bytes(b""), + # prev_randao=block_env.prev_randao, + # nonce=Bytes8(b"\x00" * 8), + # base_fee_per_gas=block_env.base_fee_per_gas, + # withdrawals_root=self.withdrawals_root, + # blob_gas_used=block_output.blob_gas_used, + # excess_blob_gas=block_env.excess_blob_gas, + # parent_beacon_block_root=block_env.parent_beacon_block_root, + # requests_hash=self.requests_hash, + # block_access_list_hash=self.block_access_list_hash, + # ) + # + # # TODO: perhaps change this for t8n.all_txs minus rejected_txs? + # included_txs = [] + # for key in block_output.receipt_keys: + # tx = t8n.fork.trie_get( + # block_output.transactions_trie, key + # ) + # assert tx is not None + # included_txs.append(tx) + # + # block = t8n.fork.Block( + # header=header, + # transactions=tuple(included_txs), + # ommers=(), + # withdrawals=withdrawals, + # ) + # + # assert self.requests is not None + # stateless_input = t8n.fork.build_stateless_input( + # block, + # execution_witness=self.execution_witness, + # execution_requests=tuple(self.requests), + # block_access_list=self.block_access_list, + # chain_id=block_env.chain_id, + # ) + # stateless_input_bytes = t8n.fork.serialize_stateless_input( + # stateless_input + # ) + # stateless_output_bytes = t8n.fork.run_stateless_guest( + # stateless_input_bytes + # ) + # result = t8n.fork.deserialize_stateless_output( + # stateless_output_bytes + # ) + # if t8n.txs.rejected_txs or self.block_exception: + # assert not result.successful_validation + # else: + # assert result.successful_validation, ( + # "Stateless validation failed" + # ) + # self.stateless_input_bytes = bytes(stateless_input_bytes) + # self.stateless_output_bytes = bytes(stateless_output_bytes) @staticmethod def _block_access_list_to_json(account_changes: Any) -> Any: @@ -618,14 +626,15 @@ def to_json(self) -> Any: "headers": ["0x" + h.hex() for h in ew.headers], } - if self.stateless_input_bytes is not None: - data["statelessInputBytes"] = ( - "0x" + self.stateless_input_bytes.hex() - ) - - if self.stateless_output_bytes is not None: - data["statelessOutputBytes"] = ( - "0x" + self.stateless_output_bytes.hex() - ) + # TODO: Re-enable but compare fixtures will fail + # if self.stateless_input_bytes is not None: + # data["statelessInputBytes"] = ( + # "0x" + self.stateless_input_bytes.hex() + # ) + # + # if self.stateless_output_bytes is not None: + # data["statelessOutputBytes"] = ( + # "0x" + self.stateless_output_bytes.hex() + # ) return data From e264daacf7efbac211f0070244b7020b9ab6a1df Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 13:16:20 +0000 Subject: [PATCH 071/265] remove guards --- .../evm_tools/t8n/t8n_types.py | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 5142f470cd2..cfdcc8f6137 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -334,28 +334,18 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: ) self.state_root = state_root_value - # TODO: Re-enable once stateless guest handles state tests - # # Build witness between state root and apply_changes_to_state: - # # the witness reads pre-state tries that apply_changes mutates. - # # This is safe because compute_state_root_and_trie_changes - # # does not mutate state (it makes transient copies of MPTs). - # if ( - # t8n.fork.has_execution_witness - # # state tests have no headers - # and not t8n.options.state_test - # # When we make a blockchain test from state test - # # it has no headers - # and t8n.env.block_headers - # ): - # self.execution_witness = t8n.fork.build_execution_witness( - # block_env.state, - # expected_post_state_root=state_root_value, - # pre_state_accounts_data=(t8n.alloc.state._main_trie), - # pre_state_storages_data=(t8n.alloc.state._storage_tries), - # blockchain_headers=t8n.env.block_headers, - # ) - if hasattr(block_output, "execution_witness"): - self.execution_witness = block_output.execution_witness + # Build witness between state root and apply_changes_to_state: + # the witness reads pre-state tries that apply_changes mutates. + # This is safe because compute_state_root_and_trie_changes + # does not mutate state (it makes transient copies of MPTs). + if t8n.fork.has_execution_witness and not self.block_exception: + self.execution_witness = t8n.fork.build_execution_witness( + block_env.state, + expected_post_state_root=state_root_value, + pre_state_accounts_data=(t8n.alloc.state._main_trie), + pre_state_storages_data=(t8n.alloc.state._storage_tries), + blockchain_headers=t8n.env.block_headers, + ) # Apply diffs to pre-state for alloc output apply_changes_to_state(t8n.alloc.state, block_diff) From 792b363d65279623c593ff801882a01f44224714 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 16:32:54 +0000 Subject: [PATCH 072/265] commit --- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index cfdcc8f6137..3b2184c54de 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -338,7 +338,11 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # the witness reads pre-state tries that apply_changes mutates. # This is safe because compute_state_root_and_trie_changes # does not mutate state (it makes transient copies of MPTs). - if t8n.fork.has_execution_witness and not self.block_exception: + if ( + t8n.fork.has_execution_witness + and not self.block_exception + and t8n.env.block_headers + ): self.execution_witness = t8n.fork.build_execution_witness( block_env.state, expected_post_state_root=state_root_value, From 827111267f5003cad15635e50a6a26779d40a8e3 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Thu, 5 Mar 2026 16:41:07 +0000 Subject: [PATCH 073/265] trigger CI From 1e5c42e87a62eba9a71100e94e531f1cd78ebbff Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 6 Mar 2026 09:08:21 +0000 Subject: [PATCH 074/265] uncomment run guest --- .../evm_tools/t8n/t8n_types.py | 144 +++++++++--------- 1 file changed, 71 insertions(+), 73 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 3b2184c54de..2a8e7304aa8 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from ethereum_rlp import Simple, rlp -from ethereum_types.bytes import Bytes +from ethereum_types.bytes import Bytes, Bytes8 from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 @@ -380,78 +380,76 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # TODO: Re-enable stateless guest once state test handling is # resolved. Currently fails on state tests. - # if self.execution_witness is not None: - # withdrawals = ( - # tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () - # ) - # - # # Build the block header from execution results. - # bh = block_env.block_hashes - # assert bh and bh[-1] is not None - # header = t8n.fork.Header( - # parent_hash=Hash32(bytes(bh[-1])), - # ommers_hash=keccak256(rlp.encode([])), - # coinbase=block_env.coinbase, - # state_root=self.state_root, - # transactions_root=self.tx_root, - # receipt_root=self.receipt_root, - # bloom=self.bloom, - # difficulty=Uint(0), - # number=block_env.number, - # gas_limit=block_env.block_gas_limit, - # gas_used=self.gas_used, - # timestamp=block_env.time, - # extra_data=Bytes(b""), - # prev_randao=block_env.prev_randao, - # nonce=Bytes8(b"\x00" * 8), - # base_fee_per_gas=block_env.base_fee_per_gas, - # withdrawals_root=self.withdrawals_root, - # blob_gas_used=block_output.blob_gas_used, - # excess_blob_gas=block_env.excess_blob_gas, - # parent_beacon_block_root=block_env.parent_beacon_block_root, - # requests_hash=self.requests_hash, - # block_access_list_hash=self.block_access_list_hash, - # ) - # - # # TODO: perhaps change this for t8n.all_txs minus rejected_txs? - # included_txs = [] - # for key in block_output.receipt_keys: - # tx = t8n.fork.trie_get( - # block_output.transactions_trie, key - # ) - # assert tx is not None - # included_txs.append(tx) - # - # block = t8n.fork.Block( - # header=header, - # transactions=tuple(included_txs), - # ommers=(), - # withdrawals=withdrawals, - # ) - # - # assert self.requests is not None - # stateless_input = t8n.fork.build_stateless_input( - # block, - # execution_witness=self.execution_witness, - # execution_requests=tuple(self.requests), - # block_access_list=self.block_access_list, - # chain_id=block_env.chain_id, - # ) - # stateless_input_bytes = t8n.fork.serialize_stateless_input( - # stateless_input - # ) - # stateless_output_bytes = t8n.fork.run_stateless_guest( - # stateless_input_bytes - # ) - # result = t8n.fork.deserialize_stateless_output( - # stateless_output_bytes - # ) - # if t8n.txs.rejected_txs or self.block_exception: - # assert not result.successful_validation - # else: - # assert result.successful_validation, ( - # "Stateless validation failed" - # ) + if self.execution_witness is not None: + withdrawals = ( + tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () + ) + + # Build the block header from execution results. + bh = block_env.block_hashes + assert bh and bh[-1] is not None + header = t8n.fork.Header( + parent_hash=Hash32(bytes(bh[-1])), + ommers_hash=keccak256(rlp.encode([])), + coinbase=block_env.coinbase, + state_root=self.state_root, + transactions_root=self.tx_root, + receipt_root=self.receipt_root, + bloom=self.bloom, + difficulty=Uint(0), + number=block_env.number, + gas_limit=block_env.block_gas_limit, + gas_used=self.gas_used, + timestamp=block_env.time, + extra_data=Bytes(b""), + prev_randao=block_env.prev_randao, + nonce=Bytes8(b"\x00" * 8), + base_fee_per_gas=block_env.base_fee_per_gas, + withdrawals_root=self.withdrawals_root, + blob_gas_used=block_output.blob_gas_used, + excess_blob_gas=block_env.excess_blob_gas, + parent_beacon_block_root=block_env.parent_beacon_block_root, + requests_hash=self.requests_hash, + block_access_list_hash=self.block_access_list_hash, + ) + + # TODO: perhaps change this for t8n.all_txs minus rejected_txs? + included_txs = [] + for key in block_output.receipt_keys: + tx = t8n.fork.trie_get(block_output.transactions_trie, key) + assert tx is not None + included_txs.append(tx) + + block = t8n.fork.Block( + header=header, + transactions=tuple(included_txs), + ommers=(), + withdrawals=withdrawals, + ) + + assert self.requests is not None + stateless_input = t8n.fork.build_stateless_input( + block, + execution_witness=self.execution_witness, + execution_requests=tuple(self.requests), + block_access_list=self.block_access_list, + chain_id=block_env.chain_id, + ) + stateless_input_bytes = t8n.fork.serialize_stateless_input( + stateless_input + ) + stateless_output_bytes = t8n.fork.run_stateless_guest( + stateless_input_bytes + ) + result = t8n.fork.deserialize_stateless_output( + stateless_output_bytes + ) + if t8n.txs.rejected_txs or self.block_exception: + assert not result.successful_validation + else: + assert result.successful_validation, ( + "Stateless validation failed" + ) # self.stateless_input_bytes = bytes(stateless_input_bytes) # self.stateless_output_bytes = bytes(stateless_output_bytes) From a927e126f57a900ecc1c86e5b67493a868f6ef97 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 6 Mar 2026 12:46:16 +0000 Subject: [PATCH 075/265] add in stateless input and output data --- .../client_clis/cli_types.py | 5 ++- .../execution_testing/fixtures/blockchain.py | 5 ++- .../src/execution_testing/specs/blockchain.py | 31 +++++++++---------- .../evm_tools/t8n/t8n_types.py | 30 ++++++++---------- 4 files changed, 31 insertions(+), 40 deletions(-) diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index 4bd500a31d7..84485d68f3c 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -291,9 +291,8 @@ class Result(CamelModel): block_access_list: BlockAccessList | None = None block_access_list_hash: Hash | None = None execution_witness: ExecutionWitness | None = None - # TODO: Re-enable, compare fixtures will fail - # stateless_input_bytes: Bytes | None = None - # stateless_output_bytes: Bytes | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None block_exception: Annotated[ BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index b24f4cc7e20..9dbc6926ed5 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -626,9 +626,8 @@ def strip_block_number_computed_field(cls, data: Any) -> Any: withdrawals: List[FixtureWithdrawal] | None = None receipts: List[FixtureTransactionReceipt] | None = None execution_witness: ExecutionWitness | None = None - # TODO: Re-enable, compare fixtures will fail - # stateless_input_bytes: Bytes | None = None - # stateless_output_bytes: Bytes | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None block_access_list: BlockAccessList | None = Field( None, description="EIP-7928 Block Access List" ) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index e78dc9aa48c..73ab8ee664e 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -387,9 +387,8 @@ class BuiltBlock(CamelModel): fork: Fork block_access_list: BlockAccessList | None execution_witness: ExecutionWitness | None = None - # TODO: Re-enable, compare fixtures will fail - # stateless_input_bytes: Bytes | None = None - # stateless_output_bytes: Bytes | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None def get_fixture_block( self, *, include_receipts: bool = True @@ -422,13 +421,12 @@ def get_fixture_block( execution_witness=self.execution_witness if self.execution_witness else None, - # TODO: Re-enable once SSZ encoding is finalized - # stateless_input_bytes=self.stateless_input_bytes - # if self.stateless_input_bytes - # else None, - # stateless_output_bytes=self.stateless_output_bytes - # if self.stateless_output_bytes - # else None, + stateless_input_bytes=self.stateless_input_bytes + if self.stateless_input_bytes + else None, + stateless_output_bytes=self.stateless_output_bytes + if self.stateless_output_bytes + else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -795,13 +793,12 @@ def generate_block_data( execution_witness=( transition_tool_output.result.execution_witness ), - # TODO: Re-enable, compare fixtures will fail - # stateless_input_bytes=( - # transition_tool_output.result.stateless_input_bytes - # ), - # stateless_output_bytes=( - # transition_tool_output.result.stateless_output_bytes - # ), + stateless_input_bytes=( + transition_tool_output.result.stateless_input_bytes + ), + stateless_output_bytes=( + transition_tool_output.result.stateless_output_bytes + ), ) try: diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 2a8e7304aa8..0054d86de98 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -278,9 +278,8 @@ class Result: block_access_list: Optional[Any] = None block_access_list_hash: Optional[Hash32] = None execution_witness: Optional[Any] = None - # TODO: Re-enable, compare fixtures will fail - # stateless_input_bytes: Optional[bytes] = None - # stateless_output_bytes: Optional[bytes] = None + stateless_input_bytes: Optional[bytes] = None + stateless_output_bytes: Optional[bytes] = None def get_receipts_from_output( self, @@ -378,8 +377,6 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - # TODO: Re-enable stateless guest once state test handling is - # resolved. Currently fails on state tests. if self.execution_witness is not None: withdrawals = ( tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () @@ -450,8 +447,8 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: assert result.successful_validation, ( "Stateless validation failed" ) - # self.stateless_input_bytes = bytes(stateless_input_bytes) - # self.stateless_output_bytes = bytes(stateless_output_bytes) + self.stateless_input_bytes = bytes(stateless_input_bytes) + self.stateless_output_bytes = bytes(stateless_output_bytes) @staticmethod def _block_access_list_to_json(account_changes: Any) -> Any: @@ -618,15 +615,14 @@ def to_json(self) -> Any: "headers": ["0x" + h.hex() for h in ew.headers], } - # TODO: Re-enable but compare fixtures will fail - # if self.stateless_input_bytes is not None: - # data["statelessInputBytes"] = ( - # "0x" + self.stateless_input_bytes.hex() - # ) - # - # if self.stateless_output_bytes is not None: - # data["statelessOutputBytes"] = ( - # "0x" + self.stateless_output_bytes.hex() - # ) + if self.stateless_input_bytes is not None: + data["statelessInputBytes"] = ( + "0x" + self.stateless_input_bytes.hex() + ) + + if self.stateless_output_bytes is not None: + data["statelessOutputBytes"] = ( + "0x" + self.stateless_output_bytes.hex() + ) return data From fa4576c2a14fe4b23d2459b163d380b33b372805 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Fri, 6 Mar 2026 14:54:20 +0000 Subject: [PATCH 076/265] lints Signed-off-by: Ignacio Hagopian --- src/ethereum/forks/amsterdam/fork.py | 6 +++--- src/ethereum/forks/amsterdam/state.py | 1 + src/ethereum/forks/amsterdam/vm/gas.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 8c3bf5ba3eb..dca2af446a3 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -28,7 +28,7 @@ InvalidSenderError, NonceMismatchError, ) -from ethereum.forks.bpo5.blocks import Header as PreviousHeader +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.state import EMPTY_CODE_HASH, Address, BlockDiff, PreState from . import vm @@ -147,7 +147,7 @@ class ChainContext: block_hashes: List[Hash32] """Recent ancestor hashes (up to 256) for the ``BLOCKHASH`` opcode.""" - parent_header: Header | PreviousHeader + parent_header: Header | PreviousForkHeader """Parent header used for header validation and system contracts.""" @@ -422,7 +422,7 @@ def calculate_base_fee_per_gas( def validate_header( - parent_header: Header | PreviousHeader, header: Header + parent_header: Header | PreviousForkHeader, header: Header ) -> None: """ Verify a block header against its parent. diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 0425a770e4a..0c01146588e 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -168,6 +168,7 @@ def apply_changes_to_state(state: State, diff: BlockDiff) -> None: state._code_store.update(diff.code_changes) + def store_code(state: State, code: Bytes) -> Hash32: """ Store bytecode in ``State``. diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 445efa14a37..90bfae3ebcf 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -16,7 +16,7 @@ from ethereum_types.numeric import U64, U256, Uint -from ethereum.forks.bpo5.blocks import Header as PreviousHeader +from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.trace import GasAndRefund, evm_trace from ethereum.utils.numeric import ceil32, taylor_exponential @@ -307,7 +307,7 @@ def init_code_cost(init_code_length: Uint) -> Uint: def calculate_excess_blob_gas( - parent_header: Header | PreviousHeader, + parent_header: Header | PreviousForkHeader, ) -> U64: """ Calculates the excess blob gas for the current block based From fb2a4fc992b82c9dad90e2ccc30b18a50257cf57 Mon Sep 17 00:00:00 2001 From: kevaundray Date: Mon, 9 Mar 2026 13:37:02 +0000 Subject: [PATCH 077/265] add test for executionWitness, inputData and outputData (#2437) --- .../plugins/filler/tests/test_filler.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index dc9c73dc2dc..803888ca974 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -754,6 +754,80 @@ def test_fixture_output_based_on_command_line_args( assert properties["build"] == build_name +test_module_execution_witness = textwrap.dedent( + """\ + import pytest + + from execution_testing import Account, Environment, Op, Transaction + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness(state_test, pre) -> None: + contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) + state_test(env=Environment(), + pre=pre, post={contract: Account(storage={0: 1})}, + tx=Transaction(to=contract, gas_limit=100_000, sender=pre.fund_eoa())) + """ +) + + +def test_execution_witness_in_blockchain_fixture( + testdir: pytest.Testdir, +) -> None: + """ + Fill a minimal Amsterdam state_test that calls a pre-deployed contract, + then verify the resulting blockchain fixture contains execution witness + and stateless validation fields. + """ + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join("test_module_execution_witness.py") + test_module.write(test_module_execution_witness) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + result.assert_outcomes( + passed=3, + failed=0, + skipped=0, + errors=0, + ) + + output_dir = Path(default_output_directory()).absolute() + assert output_dir.exists() + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness/execution_witness.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + assert len(fixture_data) == 1, "Expected exactly one fixture" + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][0] + + # executionWitness exists with non-empty state, codes, and headers + assert "executionWitness" in block + witness = block["executionWitness"] + assert len(witness["state"]) > 0, "executionWitness.state is empty" + assert len(witness["codes"]) > 0, "executionWitness.codes is empty" + assert len(witness["headers"]) > 0, "executionWitness.headers is empty" + + # statelessInputBytes and statelessOutputBytes are non-empty hex strings + assert "statelessInputBytes" in block + sib = block["statelessInputBytes"] + assert isinstance(sib, str) and sib.startswith("0x") and len(sib) > 2 + + assert "statelessOutputBytes" in block + sob = block["statelessOutputBytes"] + assert isinstance(sob, str) and sob.startswith("0x") and len(sob) > 2 + + test_module_environment_variables = textwrap.dedent( """\ import pytest From 3468eadd9b8f6c01cbda8db845d2db7cebac1b20 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 9 Mar 2026 13:37:48 +0000 Subject: [PATCH 078/265] fix caching --- src/ethereum/forks/amsterdam/incremental_mpt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index 5e71db069a4..a82cc948ca7 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -298,11 +298,13 @@ def _encode_mutable_node_to_extended( unencoded = _encode_mutable_node(node) encoded = rlp.encode(unencoded) + node._rlp = encoded if len(encoded) < 32: return unencoded else: - return keccak256(encoded) + node._hash = keccak256(encoded) + return node._hash def _compute_node_hash_and_rlp( From b77d5555ae94072430de34f871020b8a85b4ebff Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Mon, 9 Mar 2026 15:39:09 +0000 Subject: [PATCH 079/265] doc test --- tests/json_infra/test_incremental_mpt.py | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/json_infra/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py index 0eb1fdc095a..aaad19ef751 100644 --- a/tests/json_infra/test_incremental_mpt.py +++ b/tests/json_infra/test_incremental_mpt.py @@ -444,6 +444,47 @@ def test_partial_witness_insert_into_hashed_node_fails(self) -> None: with pytest.raises(AssertionError, match="cannot be invalidated"): mpt_set(decoded_mpt, b"ba", b"new_c") + def test_partial_witness_delete_collapses_to_hashed_node(self) -> None: + """ + Delete a key whose sibling is a HashedNode. + + When a branch has two children and one is deleted, the branch + must collapse. If the remaining child is a HashedNode (from a + partial witness), _collapse_branch cannot merge the nibble into + it because its structure is unknown. This currently raises + AssertionError. + """ + fake_hash = keccak256(b"some large subtree") + hashed_child = HashedNode(_hash=fake_hash) + + # Branch with a leaf at nibble 0 and a HashedNode at nibble 1. + # Key b"\x01" -> nibbles [0, 1]: child index 0, rest_of_key [1]. + branch = MutableBranchNode( + children=[None] * 16, + value=b"", + ) + branch.children[0] = MutableLeafNode( + rest_of_key=Bytes(b"\x01"), + value=b"hello", + ) + branch.children[1] = hashed_child + + mpt: IncrementalMPT[Bytes, Bytes] = IncrementalMPT( + secured=False, + default=b"", + root_node=branch, + _data={Bytes(b"\x01"): b"hello"}, + ) + + # Deleting the leaf at nibble 0 leaves only the HashedNode, + # triggering a branch collapse. _collapse_branch calls + # _record_witness on the remaining child, which fails + # because HashedNode cannot be witnessed. + with pytest.raises( + AssertionError, match="HashedNode cannot be witnessed" + ): + mpt_set(mpt, Bytes(b"\x01"), b"") + def _encode_root_for_db( node: object, From ea590dd6bd07eef078be89c4c4279ed6b16414f9 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 10 Mar 2026 11:14:23 -0300 Subject: [PATCH 080/265] zkevm: add execution witness bytecode assertions and mutation support Signed-off-by: Ignacio Hagopian --- .../testing/src/execution_testing/__init__.py | 2 + .../src/execution_testing/specs/blockchain.py | 39 +++- .../src/execution_testing/specs/state.py | 7 + .../execution_testing/test_types/__init__.py | 8 +- .../test_types/execution_witness/__init__.py | 16 ++ .../execution_witness/exceptions.py | 7 + .../execution_witness/expectations.py | 172 ++++++++++++++++++ .../test_types/execution_witness/modifiers.py | 52 ++++++ .../types.py} | 0 .../test_witness_bytecodes.py | 35 +++- 10 files changed, 323 insertions(+), 15 deletions(-) create mode 100644 packages/testing/src/execution_testing/test_types/execution_witness/__init__.py create mode 100644 packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py create mode 100644 packages/testing/src/execution_testing/test_types/execution_witness/expectations.py create mode 100644 packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py rename packages/testing/src/execution_testing/test_types/{execution_witness.py => execution_witness/types.py} (100%) diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index c5647839ffc..a26eff21bbb 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -65,6 +65,7 @@ ConsolidationRequest, DepositRequest, Environment, + ExecutionWitnessCodesExpectation, NetworkWrappedTransaction, Removable, Requests, @@ -153,6 +154,7 @@ "CodeGasMeasure", "Conditional", "ConsolidationRequest", + "ExecutionWitnessCodesExpectation", "ExtCallGenerator", "DeploymentTestType", "DepositRequest", diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 73ab8ee664e..dee456a1064 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -88,6 +88,9 @@ BlockAccessList, BlockAccessListExpectation, ) +from execution_testing.test_types.execution_witness import ( + ExecutionWitnessCodesExpectation, +) from .base import BaseTest, FillResult, OpMode, verify_result from .debugging import print_traces @@ -270,6 +273,13 @@ class Block(Header): If set, the block access list will be verified and potentially corrupted for invalid tests. """ + expected_execution_witness_codes: ( + ExecutionWitnessCodesExpectation | None + ) = None + """ + If set, the execution witness codes will be verified and potentially + modified for invalid tests. + """ exception: BLOCK_EXCEPTION_TYPE = None # If set, the block is expected to be rejected by the client. skip_exception_verification: bool = False @@ -776,6 +786,20 @@ def generate_block_data( # If the BAL was modified, update the header hash header.block_access_list_hash = Hash(bal.rlp.keccak256()) + # If expected witness codes defined, verify against actual + t8n_witness = transition_tool_output.result.execution_witness + execution_witness = t8n_witness + if ( + block.expected_execution_witness_codes is not None + and t8n_witness is not None + ): + block.expected_execution_witness_codes.verify_against(t8n_witness) + execution_witness = ( + block.expected_execution_witness_codes.modify_if_invalid_test( + t8n_witness + ) + ) + built_block = BuiltBlock( header=header, alloc=transition_tool_output.alloc, @@ -790,9 +814,7 @@ def generate_block_data( engine_api_error_code=block.engine_api_error_code, fork=self.fork, block_access_list=bal, - execution_witness=( - transition_tool_output.result.execution_witness - ), + execution_witness=execution_witness, stateless_input_bytes=( transition_tool_output.result.stateless_input_bytes ), @@ -814,15 +836,20 @@ def generate_block_data( block.expected_block_access_list is not None and block.expected_block_access_list._modifier is not None ) + and not ( + block.expected_execution_witness_codes is not None + and block.expected_execution_witness_codes._modifier + is not None + ) ): # Only verify block level exception if: - No transaction # exception was raised, because these are not reported as block # exceptions. - No RLP modifier was specified, because the # modifier is what normally produces the block exception. - No # requests were specified, because modified requests are also - # what normally produces the block exception. - No BAL modifier - # was specified, because modified BAL also produces block - # exceptions. + # what normally produces the block exception. - No BAL or + # witness modifier was specified, because modified BAL/witness + # also produces block exceptions. built_block.verify_block_exception( transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, ) diff --git a/packages/testing/src/execution_testing/specs/state.py b/packages/testing/src/execution_testing/specs/state.py index 94f8a949641..c4d81e23cfc 100644 --- a/packages/testing/src/execution_testing/specs/state.py +++ b/packages/testing/src/execution_testing/specs/state.py @@ -53,6 +53,7 @@ Alloc, BlockAccessListExpectation, Environment, + ExecutionWitnessCodesExpectation, Transaction, ) @@ -83,6 +84,9 @@ class StateTest(BaseTest): blockchain_test_header_verify: Optional[Header] = None blockchain_test_rlp_modifier: Optional[Header] = None expected_block_access_list: Optional[BlockAccessListExpectation] = None + expected_execution_witness_codes: Optional[ + ExecutionWitnessCodesExpectation + ] = None chain_id: int = 1 supported_fixture_formats: ClassVar[ @@ -314,6 +318,9 @@ def _generate_blockchain_blocks(self) -> List[Block]: "header_verify": self.blockchain_test_header_verify, "rlp_modifier": self.blockchain_test_rlp_modifier, "expected_block_access_list": self.expected_block_access_list, + "expected_execution_witness_codes": ( + self.expected_execution_witness_codes + ), } if not self.fork.header_prev_randao_required(): kwargs["difficulty"] = self.env.difficulty diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index b1215377b4d..e1891540158 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -19,7 +19,11 @@ Withdrawal, ) from .chain_config_types import ChainConfig, ChainConfigDefaults -from .execution_witness import ExecutionWitness +from .execution_witness import ( + ExecutionWitness, + ExecutionWitnessCodesExpectation, + ExecutionWitnessValidationError, +) from .helpers import ( DETERMINISTIC_FACTORY_ADDRESS, DETERMINISTIC_FACTORY_BYTECODE, @@ -73,6 +77,8 @@ "Environment", "EnvironmentDefaults", "ExecutionWitness", + "ExecutionWitnessCodesExpectation", + "ExecutionWitnessValidationError", "NetworkWrappedTransaction", "Removable", "Requests", diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py new file mode 100644 index 00000000000..772567c4d41 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py @@ -0,0 +1,16 @@ +""" +Execution witness models for stateless validation. + +This package provides types for execution witness data and +expectation-based assertions for test writing. +""" + +from .exceptions import ExecutionWitnessValidationError +from .expectations import ExecutionWitnessCodesExpectation +from .types import ExecutionWitness + +__all__ = [ + "ExecutionWitness", + "ExecutionWitnessCodesExpectation", + "ExecutionWitnessValidationError", +] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py b/packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py new file mode 100644 index 00000000000..86dd5f59daf --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions related to execution witness validation.""" + + +class ExecutionWitnessValidationError(Exception): + """Custom exception for execution witness validation errors.""" + + pass diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py new file mode 100644 index 00000000000..71630ad48e0 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -0,0 +1,172 @@ +""" +Execution witness codes expectation classes for test validation. + +This module contains classes for defining and validating expected +execution witness codes in tests. +""" + +from typing import Callable, List + +from pydantic import Field, PrivateAttr + +from execution_testing.base_types import Bytes, CamelModel + +from .exceptions import ExecutionWitnessValidationError +from .types import ExecutionWitness + + +class ExecutionWitnessCodesExpectation(CamelModel): + """ + Execution witness codes expectation model for test writing. + + Define which bytecodes should or should not appear in + executionWitness.codes. + + Example: + expected_execution_witness_codes = ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + codes_absent=[Bytes(created_code)], + ) + + """ + + codes_present: List[Bytes] = Field( + default_factory=list, + description="Bytecodes that must be present in witness codes", + ) + codes_absent: List[Bytes] = Field( + default_factory=list, + description=("Bytecodes that must NOT be present in witness codes"), + ) + allow_unexpected: bool = Field( + default=True, + description=( + "If False, fail when witness codes contains bytecodes " + "not listed in codes_present" + ), + ) + + _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( + PrivateAttr(default=None) + ) + + def modify( + self, + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], + ) -> "ExecutionWitnessCodesExpectation": + """ + Create a new expectation with a modifier for invalid test cases. + + Args: + modifiers: One or more functions that take and return + an ExecutionWitness + + Returns: + A new ExecutionWitnessCodesExpectation with modifiers applied + + """ + new_instance = self.model_copy(deep=True) + new_instance._modifier = _compose(*modifiers) + return new_instance + + def modify_if_invalid_test( + self, t8n_witness: "ExecutionWitness" + ) -> "ExecutionWitness": + """ + Apply the modifier to the given witness if this is an invalid test. + + Args: + t8n_witness: The ExecutionWitness from the t8n tool + + Returns: + The potentially transformed ExecutionWitness for the fixture + + """ + if self._modifier: + return self._modifier(t8n_witness) + return t8n_witness + + def verify_against(self, actual_witness: "ExecutionWitness") -> None: + """ + Verify that the actual witness codes match this expectation. + + Validation steps: + 1. Structural invariants: no duplicates and sorted order + 2. Presence checks: codes_present entries exist + 3. Absence checks: codes_absent entries do not exist + 4. Exhaustiveness: if allow_unexpected=False, no extra codes + + Args: + actual_witness: The ExecutionWitness from the t8n tool + + Raises: + ExecutionWitnessValidationError: If verification fails + + """ + actual_codes = actual_witness.codes + + # 1. Structural invariants (always checked) + if len(actual_codes) != len(set(actual_codes)): + seen: set[Bytes] = set() + dupes: list[Bytes] = [] + for code in actual_codes: + if code in seen: + dupes.append(code) + seen.add(code) + raise ExecutionWitnessValidationError( + f"Witness codes contain duplicates: {[c.hex() for c in dupes]}" + ) + + if actual_codes != sorted(actual_codes): + raise ExecutionWitnessValidationError( + "Witness codes are not sorted in lexicographic order" + ) + + actual_set = set(actual_codes) + + # 2. Presence checks + for code in self.codes_present: + if code not in actual_set: + raise ExecutionWitnessValidationError( + f"Expected bytecode {code.hex()} not found " + f"in witness codes" + ) + + # 3. Absence checks + for code in self.codes_absent: + if code in actual_set: + raise ExecutionWitnessValidationError( + f"Bytecode {code.hex()} should not be in " + f"witness codes but was found" + ) + + # 4. Exhaustiveness check + if not self.allow_unexpected: + expected_set = set(self.codes_present) + unexpected = actual_set - expected_set + if unexpected: + raise ExecutionWitnessValidationError( + f"Unexpected bytecodes in witness codes: " + f"{[c.hex() for c in unexpected]}" + ) + + +def _compose( + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], +) -> Callable[["ExecutionWitness"], "ExecutionWitness"]: + """Compose multiple modifiers into a single modifier.""" + + def composed( + witness: ExecutionWitness, + ) -> ExecutionWitness: + result = witness + for modifier in modifiers: + result = modifier(result) + return result + + return composed + + +__all__ = [ + "ExecutionWitnessCodesExpectation", +] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py new file mode 100644 index 00000000000..afc046f521a --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -0,0 +1,52 @@ +""" +Execution witness modifier functions for invalid test cases. + +This module provides modifier functions that can be used to modify +execution witnesses for testing invalid block scenarios. +""" + +from typing import Callable + +from execution_testing.base_types import Bytes + +from .types import ExecutionWitness + + +def add_code( + code: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Add a bytecode entry to the witness codes list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_codes = list(witness.codes) + new_codes.append(code) + new_codes.sort() + return witness.model_copy(update={"codes": new_codes}) + + return transform + + +def remove_code( + code: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove a bytecode entry from the witness codes list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_codes = [c for c in witness.codes if c != code] + if len(new_codes) == len(witness.codes): + raise ValueError( + f"Bytecode {code.hex()} not found in witness codes to remove" + ) + return witness.model_copy(update={"codes": new_codes}) + + return transform + + +__all__ = [ + "add_code", + "remove_code", +] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness.py b/packages/testing/src/execution_testing/test_types/execution_witness/types.py similarity index 100% rename from packages/testing/src/execution_testing/test_types/execution_witness.py rename to packages/testing/src/execution_testing/test_types/execution_witness/types.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py index d83f3bd2cd4..ae9c168e194 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py @@ -6,6 +6,8 @@ Alloc, Block, BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, Initcode, Op, Transaction, @@ -25,9 +27,8 @@ def test_witness_excludes_bytecode_created_in_same_block( """ Create a contract in a block without reading its code first. - Manual witness check until execution witness assertions are supported: - - The deployed runtime code should not appear in - executionWitness.codes for this block. + The deployed runtime code should not appear in + executionWitness.codes for this block. """ runtime_code = bytes.fromhex("deadbeef") creator = pre.fund_eoa() @@ -42,7 +43,16 @@ def test_witness_excludes_bytecode_created_in_same_block( blockchain_test( pre=pre, - blocks=[Block(txs=[create_tx])], + blocks=[ + Block( + txs=[create_tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], post={ creator: Account(nonce=1), created_contract: Account( @@ -60,9 +70,8 @@ def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( """ Tx1 reads pre-state code, tx2 later deploys the same runtime code hash. - Manual witness check until execution witness assertions are supported: - - 0x60006000F3 should appear in executionWitness.codes because - tx1 CALLs an existing pre-state contract with that code. + The pre-state bytecode should appear in executionWitness.codes because + tx1 CALLs an existing pre-state contract with that code. """ runtime_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) @@ -87,7 +96,17 @@ def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( blockchain_test( pre=pre, blocks=[ - Block(txs=[tx1_read_existing_code, tx2_create_same_code_hash]) + Block( + txs=[ + tx1_read_existing_code, + tx2_create_same_code_hash, + ], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + ) + ), + ) ], post={ reader: Account(nonce=1), From d4400975ee67301a7b00ed9c4ba1a28f4f4962b2 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 10 Mar 2026 17:25:54 -0300 Subject: [PATCH 081/265] tests/zkevm: add bytecodes check for empty blocks checking for strict system contracts inclusion Signed-off-by: Ignacio Hagopian --- ...test_witness_bytecodes_system_contracts.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py new file mode 100644 index 00000000000..ffa331d9111 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py @@ -0,0 +1,63 @@ +"""Witness bytecode scenarios for system contracts.""" + +import pytest +from execution_testing import ( + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Fork, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +# System contracts called every block (addresses as ints). +SYSTEM_CONTRACT_ADDRS = [ + 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, # beacon roots + 0x00000961EF480EB55E80D19AD83579A64C007002, # withdrawal request + 0x0000BBDDC7CE488642FB579F8B00F3A590007251, # consolidation request + 0x0000F90827F1C53A10CB7A02335B175320002935, # history storage +] + + +def test_witness_codes_empty_block_has_system_contracts( + pre: Alloc, + fork: Fork, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Verify an empty block contains only system contract bytecodes. + + System contracts are called every block via process_system_call, + which calls get_code() on a tracked TransactionState. An empty + block (no user transactions) should have exactly the four system + contract bytecodes in executionWitness.codes and nothing else. + """ + alloc = fork.pre_allocation_blockchain() + + system_codes = [] + for addr in SYSTEM_CONTRACT_ADDRS: + code = alloc[addr]["code"] + if isinstance(code, str): + code = bytes.fromhex(code.removeprefix("0x")) + system_codes.append(Bytes(code)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes, + allow_unexpected=False, + ) + ), + ) + ], + post={}, + ) From 31e6d05c7e4e7e4ed0976b435ebf22f6fff89907 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 10 Mar 2026 17:33:28 -0300 Subject: [PATCH 082/265] tests/zkevm: add tests cases for bytecode execution witness for call variants Signed-off-by: Ignacio Hagopian --- .../test_witness_bytecodes_call_variants.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py new file mode 100644 index 00000000000..e86f826540c --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py @@ -0,0 +1,113 @@ +"""Witness bytecode collection for call variant opcodes.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.DELEGATECALL, id="delegatecall"), + pytest.param(Op.CALLCODE, id="callcode"), + pytest.param(Op.STATICCALL, id="staticcall"), + ], +) +def test_witness_codes_call_existing_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + Call an existing contract with each call variant. + + The target bytecode should appear in executionWitness.codes because + all call opcodes fetch code via get_code(). + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + if call_opcode in (Op.CALL, Op.CALLCODE): + caller_code = call_opcode(Op.GAS, target, 0, 0, 0, 0, 0) + else: + caller_code = call_opcode(Op.GAS, target, 0, 0, 0, 0) + caller_code += Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(target_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_nested_calls( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Nested calls A -> B -> C record all three bytecodes in witness codes. + + Each call depth fetches code via get_code(), so all accessed contract + bytecodes should appear in executionWitness.codes. + """ + code_c = Op.PUSH1(0x01) + Op.POP + Op.STOP + contract_c = pre.deploy_contract(code=code_c) + + code_b = Op.CALL(Op.GAS, contract_c, 0, 0, 0, 0, 0) + Op.STOP + contract_b = pre.deploy_contract(code=code_b) + + code_a = Op.CALL(Op.GAS, contract_b, 0, 0, 0, 0, 0) + Op.STOP + contract_a = pre.deploy_contract(code=code_a) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract_a, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(code_a)), + Bytes(bytes(code_b)), + Bytes(bytes(code_c)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) From 230614af2ba9fe0e04c4bf7c2064b9d453850ef5 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 10 Mar 2026 18:01:46 -0300 Subject: [PATCH 083/265] tests/zkevm: add tests cases for bytecode execution witness for contract creations Signed-off-by: Ignacio Hagopian --- .../test_witness_bytecodes.py | 119 ---- ...est_witness_bytecodes_contract_creation.py | 507 ++++++++++++++++++ 2 files changed, 507 insertions(+), 119 deletions(-) delete mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py deleted file mode 100644 index ae9c168e194..00000000000 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Witness bytecode collection scenarios.""" - -import pytest -from execution_testing import ( - Account, - Alloc, - Block, - BlockchainTestFiller, - Bytes, - ExecutionWitnessCodesExpectation, - Initcode, - Op, - Transaction, - compute_create_address, -) - -pytestmark = pytest.mark.valid_from("Amsterdam") - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -def test_witness_excludes_bytecode_created_in_same_block( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Create a contract in a block without reading its code first. - - The deployed runtime code should not appear in - executionWitness.codes for this block. - """ - runtime_code = bytes.fromhex("deadbeef") - creator = pre.fund_eoa() - created_contract = compute_create_address(address=creator, nonce=0) - - create_tx = Transaction( - sender=creator, - to=None, - data=Initcode(deploy_code=runtime_code), - gas_limit=500_000, - ) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[create_tx], - expected_execution_witness_codes=( - ExecutionWitnessCodesExpectation( - codes_absent=[Bytes(runtime_code)], - ) - ), - ) - ], - post={ - creator: Account(nonce=1), - created_contract: Account( - nonce=1, - code=runtime_code, - ), - }, - ) - - -def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Tx1 reads pre-state code, tx2 later deploys the same runtime code hash. - - The pre-state bytecode should appear in executionWitness.codes because - tx1 CALLs an existing pre-state contract with that code. - """ - runtime_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) - - existing_contract = pre.deploy_contract(code=runtime_code) - - reader = pre.fund_eoa() - creator = pre.fund_eoa() - created_contract = compute_create_address(address=creator, nonce=0) - - tx1_read_existing_code = Transaction( - sender=reader, - to=existing_contract, - gas_limit=200_000, - ) - tx2_create_same_code_hash = Transaction( - sender=creator, - to=None, - data=Initcode(deploy_code=runtime_code), - gas_limit=500_000, - ) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[ - tx1_read_existing_code, - tx2_create_same_code_hash, - ], - expected_execution_witness_codes=( - ExecutionWitnessCodesExpectation( - codes_present=[Bytes(runtime_code)], - ) - ), - ) - ], - post={ - reader: Account(nonce=1), - creator: Account(nonce=1), - created_contract: Account( - nonce=1, - code=runtime_code, - ), - }, - ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py new file mode 100644 index 00000000000..4daad999a9e --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py @@ -0,0 +1,507 @@ +"""Witness bytecode scenarios for contract creation.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Initcode, + Op, + Transaction, + compute_create_address, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_excludes_bytecode_created_in_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deploy a contract via CREATE with no prior code access. + + The deployed runtime code should not appear in the execution + witness. + """ + runtime_code = bytes.fromhex("deadbeef") + creator = pre.fund_eoa() + created_contract = compute_create_address( + address=creator, nonce=0 + ) + + create_tx = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[create_tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + creator: Account(nonce=1), + created_contract: Account( + nonce=1, + code=runtime_code, + ), + }, + ) + + +def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Tx1 reads pre-state code, tx2 deploys the same runtime code hash. + + The pre-state bytecode should appear in executionWitness.codes + because tx1 CALLs an existing pre-state contract with that code. + """ + runtime_code = bytes( + Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN + ) + + existing_contract = pre.deploy_contract(code=runtime_code) + + reader = pre.fund_eoa() + creator = pre.fund_eoa() + created_contract = compute_create_address( + address=creator, nonce=0 + ) + + tx1_read_existing_code = Transaction( + sender=reader, + to=existing_contract, + gas_limit=200_000, + ) + tx2_create_same_code_hash = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[ + tx1_read_existing_code, + tx2_create_same_code_hash, + ], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + reader: Account(nonce=1), + creator: Account(nonce=1), + created_contract: Account( + nonce=1, + code=runtime_code, + ), + }, + ) + + +def test_witness_codes_create2_excludes_new_bytecode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deploy a contract via CREATE2. + + The deployed runtime code should not appear in + executionWitness.codes because it had no pre-state match. + """ + runtime_code = bytes.fromhex("deadbeef") + salt = 0 + initcode = Initcode(deploy_code=runtime_code) + initcode_bytes = bytes(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE( + 0, + Op.CREATE2( + value=0, + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + salt=salt, + ), + ) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code) + sender = pre.fund_eoa() + + created = compute_create_address( + address=factory, + nonce=1, + salt=salt, + initcode=initcode_bytes, + opcode=Op.CREATE2, + ) + + tx = Transaction( + sender=sender, + to=factory, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_failed_create_includes_factory( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Execute a CREATE whose initcode fails via INVALID. + + The factory contract's code should appear in executionWitness.codes + because it was read to execute the CREATE attempt, even though the + creation failed. No new code is deployed. + """ + failing_initcode = bytes(Op.INVALID) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(failing_initcode)) + + Op.SSTORE( + 0, + Op.CREATE( + offset=32 - len(failing_initcode), + size=len(failing_initcode), + ), + ) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code) + sender = pre.fund_eoa() + + tx = Transaction( + sender=sender, + to=factory, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(factory_code)], + ) + ), + ) + ], + post={ + factory: Account( + storage={0: 0}, + ), + }, + ) + + +def test_witness_codes_create_then_call_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Create a contract in tx1 then CALL it in tx2 of the same block. + + The created contract's code should not appear in + executionWitness.codes because it was written by tx1 thus known + at tx2 execution time. + """ + runtime_code = bytes(Op.STOP) + + creator = pre.fund_eoa() + created = compute_create_address(address=creator, nonce=0) + + caller_code = Op.CALL(address=created) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + caller2 = pre.fund_eoa() + + tx1_create = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + tx2_call = Transaction( + sender=caller2, + to=caller, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1_create, tx2_call], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_create_same_hash_then_read( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Tx1 deploys a contract, tx2 calls a pre-state contract with same code. + + The pre-state contract's bytecode should appear in + executionWitness.codes because get_witness_codes checks per-address + pre-state match. The pre-state contract qualifies regardless of the + earlier CREATE writing the same hash. + + TODO(zkevm): it might be worth reconsidering how the execution witness + generation handles this case, since technically this same bytecode was + observed in tx1 thus can in theory be exploited to avoid including it + for tx2. Feels inconsistent with the other test + test_witness_codes_create_then_call_same_block(...). + """ + runtime_code = bytes(Op.STOP) + + existing_contract = pre.deploy_contract(code=runtime_code) + + creator = pre.fund_eoa() + reader = pre.fund_eoa() + + tx1_create = Transaction( + sender=creator, + to=None, + data=Initcode(deploy_code=runtime_code), + gas_limit=500_000, + ) + tx2_read = Transaction( + sender=reader, + to=existing_contract, + gas_limit=200_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1_create, tx2_read], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + reader: Account(nonce=1), + creator: Account(nonce=1), + }, + ) + + +def test_witness_codes_create_then_call_same_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Factory CREATEs a contract and then CALLs it in the same transaction. + + The newly created contract's code should not appear in + executionWitness.codes because it was read from tx-local code_writes + and has no pre-state match. + """ + runtime_code = bytes(Op.STOP) + initcode = Initcode(deploy_code=runtime_code) + initcode_bytes = bytes(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE( + 0, + Op.CREATE( + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + ), + ) + + Op.CALL(address=Op.SLOAD(0)) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code) + sender = pre.fund_eoa() + + created = compute_create_address( + address=factory, nonce=1 + ) + + tx = Transaction( + sender=sender, + to=factory, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_absent=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_initcode_calls_existing_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + CREATE where initcode CALLs a pre-state contract during deployment. + + The called contract's code should appear in executionWitness.codes. + The initcode itself should not appear because it comes from tx data + or memory and is never fetched through get_code(). + """ + callee_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) + callee = pre.deploy_contract(code=callee_code) + + runtime_code = bytes(Op.STOP) + + initcode_prefix = Op.CALL(address=callee) + Op.POP + initcode = Initcode( + deploy_code=runtime_code, + initcode_prefix=initcode_prefix, + ) + initcode_bytes = bytes(initcode) + + creator = pre.fund_eoa() + created = compute_create_address(address=creator, nonce=0) + + tx = Transaction( + sender=creator, + to=None, + data=initcode, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(callee_code)], + codes_absent=[Bytes(initcode_bytes)], + ) + ), + ) + ], + post={ + created: Account(nonce=1, code=runtime_code), + }, + ) + + +def test_witness_codes_failed_create_after_initcode_read( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + CREATE where initcode CALLs a pre-state contract, then deployment fails. + + The called contract's code should appear in executionWitness.codes + because code_reads survive rollback (snapshots share the same set). + No new code is added since the deployment failed. + """ + callee_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) + callee = pre.deploy_contract(code=callee_code) + + # Initcode that calls callee then fails via INVALID opcode + initcode_body = ( + Op.CALL(address=callee) + + Op.POP + + Op.INVALID + ) + initcode = bytes(initcode_body) + + creator = pre.fund_eoa() + + tx = Transaction( + sender=creator, + to=None, + data=initcode, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(callee_code)], + ) + ), + ) + ], + post={ + creator: Account(nonce=1), + }, + ) From f3fca6ec3a0949be4e451870b1f11a9a65e37079 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 10 Mar 2026 18:03:03 -0300 Subject: [PATCH 084/265] tests/zkevm: lints Signed-off-by: Ignacio Hagopian --- ...est_witness_bytecodes_contract_creation.py | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py index 4daad999a9e..bbc0d25b0fc 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py @@ -32,9 +32,7 @@ def test_witness_excludes_bytecode_created_in_same_block( """ runtime_code = bytes.fromhex("deadbeef") creator = pre.fund_eoa() - created_contract = compute_create_address( - address=creator, nonce=0 - ) + created_contract = compute_create_address(address=creator, nonce=0) create_tx = Transaction( sender=creator, @@ -75,17 +73,13 @@ def test_witness_keeps_prestate_code_read_even_if_later_created_with_same_hash( The pre-state bytecode should appear in executionWitness.codes because tx1 CALLs an existing pre-state contract with that code. """ - runtime_code = bytes( - Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN - ) + runtime_code = bytes(Op.PUSH1(0x00) + Op.PUSH1(0x00) + Op.RETURN) existing_contract = pre.deploy_contract(code=runtime_code) reader = pre.fund_eoa() creator = pre.fund_eoa() - created_contract = compute_create_address( - address=creator, nonce=0 - ) + created_contract = compute_create_address(address=creator, nonce=0) tx1_read_existing_code = Transaction( sender=reader, @@ -308,7 +302,7 @@ def test_witness_codes_create_same_hash_then_read( generation handles this case, since technically this same bytecode was observed in tx1 thus can in theory be exploited to avoid including it for tx2. Feels inconsistent with the other test - test_witness_codes_create_then_call_same_block(...). + test_witness_codes_create_then_call_same_block(...). """ runtime_code = bytes(Op.STOP) @@ -378,9 +372,7 @@ def test_witness_codes_create_then_call_same_tx( factory = pre.deploy_contract(code=factory_code) sender = pre.fund_eoa() - created = compute_create_address( - address=factory, nonce=1 - ) + created = compute_create_address(address=factory, nonce=1) tx = Transaction( sender=sender, @@ -473,11 +465,7 @@ def test_witness_codes_failed_create_after_initcode_read( callee = pre.deploy_contract(code=callee_code) # Initcode that calls callee then fails via INVALID opcode - initcode_body = ( - Op.CALL(address=callee) - + Op.POP - + Op.INVALID - ) + initcode_body = Op.CALL(address=callee) + Op.POP + Op.INVALID initcode = bytes(initcode_body) creator = pre.fund_eoa() From d72a4d82e477a0c0e6a081d8da40d4a4cb7c784b Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 11 Mar 2026 09:52:32 -0300 Subject: [PATCH 085/265] zkevm: optimize further bytecode in witness Signed-off-by: jsign --- src/ethereum/forks/amsterdam/state_tracker.py | 7 +- ...est_witness_bytecodes_contract_creation.py | 86 ++++++++++++++++--- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 9ffbb19b4b0..e017a23b8f0 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -164,6 +164,11 @@ def get_code( Read chain: tx code_writes -> block code_writes -> pre_state. + Only record a ``code_reads`` entry when the bytecode is actually + fetched from ``pre_state``. Reads satisfied by ``code_writes`` + (same-tx or earlier-tx CREATEs) are already available to a + stateless verifier and do not need to appear in the witness. + Parameters ---------- tx_state : @@ -181,11 +186,11 @@ def get_code( """ if code_hash == EMPTY_CODE_HASH: return b"" - tx_state.code_reads.add((address, code_hash)) if code_hash in tx_state.code_writes: return tx_state.code_writes[code_hash] if code_hash in tx_state.parent.code_writes: return tx_state.parent.code_writes[code_hash] + tx_state.code_reads.add((address, code_hash)) return tx_state.parent.pre_state.get_code(code_hash) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py index bbc0d25b0fc..a9e21eebdcd 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py @@ -293,16 +293,11 @@ def test_witness_codes_create_same_hash_then_read( """ Tx1 deploys a contract, tx2 calls a pre-state contract with same code. - The pre-state contract's bytecode should appear in - executionWitness.codes because get_witness_codes checks per-address - pre-state match. The pre-state contract qualifies regardless of the - earlier CREATE writing the same hash. - - TODO(zkevm): it might be worth reconsidering how the execution witness - generation handles this case, since technically this same bytecode was - observed in tx1 thus can in theory be exploited to avoid including it - for tx2. Feels inconsistent with the other test - test_witness_codes_create_then_call_same_block(...). + The pre-state contract's bytecode must not appear in + executionWitness.codes because the same code hash was already + written by tx1's CREATE. A stateless verifier observed + the bytecode from the CREATE transaction data, so including it + in the witness is redundant. """ runtime_code = bytes(Op.STOP) @@ -330,7 +325,7 @@ def test_witness_codes_create_same_hash_then_read( txs=[tx1_create, tx2_read], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[Bytes(runtime_code)], + codes_absent=[Bytes(runtime_code)], ) ), ) @@ -493,3 +488,72 @@ def test_witness_codes_failed_create_after_initcode_read( creator: Account(nonce=1), }, ) + + +def test_witness_codes_reverted_create_same_hash_then_read( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Factory CREATEs bytecode A then REVERTs; tx2 calls pre-state contract + with also bytecode A. + + Said differently, bytecode A was observed by a contract creation execution + but since the creation fails it isn't tracked cross-tx boundary by the + state tracker. This means that tx2 access to the pre-state contract's + code hash doesn't find it thus falls back to fetching the bytecode + from pre-state and including it in the witness. + """ + runtime_code = bytes(Op.STOP) + + existing_contract = pre.deploy_contract(code=runtime_code) + + initcode = bytes(Initcode(deploy_code=runtime_code)) + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode)) + + Op.CREATE( + offset=32 - len(initcode), + size=len(initcode), + ) + # The runtime_code was observed by the CREATE execution, + # but since the CREATE fails, it isn't tracked cross-tx + # boundary by the state tracker. This means that tx2 + # access to the pre-state contract's code hash doesn't + # find it thus falls back to fetching the bytecode from + # pre-state and including it in the witness. + + Op.POP + + Op.REVERT(offset=0, size=0) + ) + factory = pre.deploy_contract(code=factory_code) + + sender1 = pre.fund_eoa() + sender2 = pre.fund_eoa() + + tx1_reverted_create = Transaction( + sender=sender1, + to=factory, + gas_limit=500_000, + ) + tx2_read = Transaction( + sender=sender2, + to=existing_contract, + gas_limit=200_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1_reverted_create, tx2_read], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(runtime_code)], + ) + ), + ) + ], + post={ + sender1: Account(nonce=1), + sender2: Account(nonce=1), + }, + ) From 7bd7d658c1fd4cd6399fc40f2b726d4a811c6bd3 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 15:25:36 -0300 Subject: [PATCH 086/265] zkevm/tests: extract system contract helpers --- .../eip8025_optional_proofs/conftest.py | 25 +++++++++++++++++++ ...test_witness_bytecodes_system_contracts.py | 20 +-------------- 2 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 tests/amsterdam/eip8025_optional_proofs/conftest.py diff --git a/tests/amsterdam/eip8025_optional_proofs/conftest.py b/tests/amsterdam/eip8025_optional_proofs/conftest.py new file mode 100644 index 00000000000..ac094f3903c --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/conftest.py @@ -0,0 +1,25 @@ +"""Shared pytest definitions for EIP-8025 witness tests.""" + +import pytest +from execution_testing import Bytes, Fork + +# System contracts called every block (addresses as ints). +SYSTEM_CONTRACT_ADDRS = [ + 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, # beacon roots + 0x00000961EF480EB55E80D19AD83579A64C007002, # withdrawal req + 0x0000BBDDC7CE488642FB579F8B00F3A590007251, # consolidation req + 0x0000F90827F1C53A10CB7A02335B175320002935, # history storage +] + + +@pytest.fixture +def system_codes(fork: Fork) -> list[Bytes]: + """Collect system contract bytecodes from the fork pre-alloc.""" + alloc = fork.pre_allocation_blockchain() + codes: list[Bytes] = [] + for addr in SYSTEM_CONTRACT_ADDRS: + code = alloc[addr]["code"] + if isinstance(code, str): + code = bytes.fromhex(code.removeprefix("0x")) + codes.append(Bytes(code)) + return codes diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py index ffa331d9111..d6ebb80c370 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py @@ -7,7 +7,6 @@ BlockchainTestFiller, Bytes, ExecutionWitnessCodesExpectation, - Fork, ) pytestmark = pytest.mark.valid_from("Amsterdam") @@ -15,18 +14,10 @@ REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" -# System contracts called every block (addresses as ints). -SYSTEM_CONTRACT_ADDRS = [ - 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, # beacon roots - 0x00000961EF480EB55E80D19AD83579A64C007002, # withdrawal request - 0x0000BBDDC7CE488642FB579F8B00F3A590007251, # consolidation request - 0x0000F90827F1C53A10CB7A02335B175320002935, # history storage -] - def test_witness_codes_empty_block_has_system_contracts( pre: Alloc, - fork: Fork, + system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -37,15 +28,6 @@ def test_witness_codes_empty_block_has_system_contracts( block (no user transactions) should have exactly the four system contract bytecodes in executionWitness.codes and nothing else. """ - alloc = fork.pre_allocation_blockchain() - - system_codes = [] - for addr in SYSTEM_CONTRACT_ADDRS: - code = alloc[addr]["code"] - if isinstance(code, str): - code = bytes.fromhex(code.removeprefix("0x")) - system_codes.append(Bytes(code)) - blockchain_test( pre=pre, blocks=[ From d3200258de0523d816043be723cdd5e920097d3f Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 15:26:01 -0300 Subject: [PATCH 087/265] zkevm/tests: add non-delegated EOA and precompile tests reg bytecode capturing --- .../test_witness_eoa_precompiles.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_precompiles.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_precompiles.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_precompiles.py new file mode 100644 index 00000000000..7c9298612bc --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_precompiles.py @@ -0,0 +1,95 @@ +"""Witness bytecode scenarios for precompiles and EOAs (negative cases).""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +@pytest.mark.with_all_precompiles() +def test_witness_codes_call_precompile( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, + precompile: Address, +) -> None: + """ + Send a transaction directly to a precompile. + + Precompile accounts have EMPTY_CODE_HASH, code tracking returns + early without recording a code read. The witness must contain + only system contract bytecodes — nothing for the precompile. + """ + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=precompile, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes, + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_call_eoa( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Send a value transfer directly to a plain EOA. + + EOAs with no delegations have EMPTY_CODE_HASH, so code tracking + returns early without recording a code read. The witness must + contain only system contract bytecodes — nothing for the EOA. + """ + eoa_target = pre.fund_eoa() + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=eoa_target, + value=1, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes, + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) From 3d216de2246e24a0d3c48f42f40c9c953948c47e Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 15:41:48 -0300 Subject: [PATCH 088/265] zkevm/tests: add bytecode tracking tests for EXTCODE(SIZE|COPY) --- .../test_witness_extcode.py | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py new file mode 100644 index 00000000000..9d445be7abd --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py @@ -0,0 +1,355 @@ +"""Witness bytecode scenarios for EXTCODE* opcodes.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_codes_extcodesize( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODESIZE on an existing contract without calling it. + + The target bytecode should appear in executionWitness.codes because + extcodesize calls get_code(), which records the code read. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodesize_empty_code( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODESIZE on an account with empty code (an EOA). + + The target has EMPTY_CODE_HASH, so get_code() returns early without + recording a code read. Nothing should be added to + executionWitness.codes for the target. + """ + eoa_target = pre.fund_eoa() + + caller_code = Op.EXTCODESIZE(eoa_target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + + [Bytes(bytes(caller_code))], + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodecopy( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODECOPY on an existing contract without calling it. + + The target bytecode should appear in executionWitness.codes because + extcodecopy calls get_code(), which records the code read. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodecopy_zero_size( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODECOPY with size=0 on an existing contract. + + The target bytecode should appear in executionWitness.codes because + extcodecopy calls get_code() unconditionally before using size for + the memory copy. Even copying zero bytes still records the code read. + + TODO(zkevm): we will probably change this behavior since copying zero + bytes clearly doesn't need to read the code. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_extcodehash_only( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODEHASH on an existing contract without any CALL, EXTCODESIZE, + or EXTCODECOPY. + + The target bytecode should NOT appear in executionWitness.codes + because EXTCODEHASH can read the value from the account leaf not + requiring doing a code access. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODEHASH(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + ], + codes_absent=[ + Bytes(bytes(target_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +# Intrinsic tx cost (21000) + PUSH20 (3) + EXTCODESIZE cold (2600). +GAS_EXACT_EXTCODESIZE_COLD = 21_000 + 3 + 2600 + + +@pytest.mark.parametrize( + "gas_limit,expect_in_witness", + [ + pytest.param( + GAS_EXACT_EXTCODESIZE_COLD - 1, + False, + id="oog", + ), + pytest.param( + GAS_EXACT_EXTCODESIZE_COLD, + True, + id="just_enough", + ), + ], +) +def test_witness_codes_extcodesize_cold_gas_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + gas_limit: int, + expect_in_witness: bool, +) -> None: + """ + EXTCODESIZE at the exact gas boundary for cold account access. + + When gas is one short of covering PUSH20 + EXTCODESIZE-cold, the + opcode OOGs before reaching get_code() and the target code is NOT + recorded. With exactly enough gas the code read succeeds and the + target IS in the witness. The caller's code appears in both cases + because it was already read via get_code() when entering the call, + and code_reads survives transaction rollback. + """ + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=caller, + gas_limit=gas_limit, + ) + + codes_present = [Bytes(bytes(caller_code))] + codes_absent = [] + if expect_in_witness: + codes_present.append(Bytes(bytes(target_code))) + else: + codes_absent.append(Bytes(bytes(target_code))) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=codes_present, + codes_absent=codes_absent, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +@pytest.mark.with_all_precompiles() +@pytest.mark.parametrize( + "extcode_opcode", + [ + pytest.param("extcodesize", id="extcodesize"), + pytest.param("extcodecopy", id="extcodecopy"), + ], +) +def test_witness_codes_extcode_precompile( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, + precompile: Address, + extcode_opcode: str, +) -> None: + """ + Read code metadata of a precompile via EXTCODESIZE or EXTCODECOPY. + + Precompiles have EMPTY_CODE_HASH, so code tracking returns early + without recording a code read. The witness must contain only + the caller and system contract bytecodes — nothing for the + precompile. + """ + if extcode_opcode == "extcodesize": + op = Op.EXTCODESIZE(precompile) + Op.POP + else: + op = Op.EXTCODECOPY(precompile, 0, 0, 32) + + caller_code = op + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + + [Bytes(bytes(caller_code))], + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) From 057072c4c656c0d2e07c0172160206d075012480 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 15:45:02 -0300 Subject: [PATCH 089/265] lint --- tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py index 9d445be7abd..8b4cff8aa87 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py @@ -193,7 +193,7 @@ def test_witness_codes_extcodehash_only( The target bytecode should NOT appear in executionWitness.codes because EXTCODEHASH can read the value from the account leaf not - requiring doing a code access. + requiring doing a code access. """ target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP target = pre.deploy_contract(code=target_code) From dc108d80ace31a3429f5d6640e28cf990a315eb1 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 16:20:58 -0300 Subject: [PATCH 090/265] zkevm/tests: add 7702 bytecode witness tests --- .../test_witness_7702.py | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py new file mode 100644 index 00000000000..554b7e2e388 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py @@ -0,0 +1,333 @@ +"""Witness bytecode scenarios for EIP-7702 delegation.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.DELEGATECALL, id="delegatecall"), + pytest.param(Op.CALLCODE, id="callcode"), + pytest.param(Op.STATICCALL, id="staticcall"), + ], +) +def test_witness_codes_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + Call-type opcode targeting an EOA with pre-state delegation. + + Both the delegation marker code and the delegated contract's + bytecode appear in executionWitness.codes. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + + caller_code = ( + call_opcode(address=delegated_eoa) + Op.STOP + ) + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_sender_delegation_marker_included( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Transaction sent from an EOA with pre-state delegation. + + The sender's delegation marker code appears in + executionWitness.codes because transaction validation reads + the sender code . + """ + delegate_code = Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_sender = pre.fund_eoa(delegation=delegate) + + recipient = pre.fund_eoa() + tx = Transaction( + sender=delegated_sender, + to=recipient, + gas_limit=500_000, + ) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker), + ], + ) + ), + ) + ], + post={ + delegated_sender: Account(nonce=2), + }, + ) + + +def test_witness_codes_top_level_tx_to_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Top-level transaction targeting a delegated EOA directly. + + Both the delegation marker and the delegated contract's code + appear in executionWitness.codes. This is a distinct path from + opcode-driven CALL tests because the delegation is resolved at + the top-level message-call. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=delegated_eoa, + gas_limit=500_000, + ) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_delegation_set_in_same_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Auth list sets delegation in tx1, then tx2 calls the EOA. + + The delegation marker is NOT in executionWitness.codes because + it was written in tx1. The delegated contract's + bytecode IS in codes because it is a pre-state read. + + Pre-state: + alice (plain EOA, no code) + delegate (contract with code) + + tx1 (type-4, auth list): + set_delegation(alice -> delegate) + => writes marker to alice (runtime creation of marker) + + tx2: + caller --CALL--> alice + => reads alice's marker (NOT from pre-state since created in tx1) + => reads delegate's code (pre-state => code_reads) + + Witness codes: + delegate_code IN codes (pre-state read) + marker NOT IN codes (written in tx1) + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + alice = pre.fund_eoa(amount=0) + + caller_code = Op.CALL(address=alice) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + relayer = pre.fund_eoa() + sender2 = pre.fund_eoa() + + marker = Spec7702.delegation_designation(delegate) + + tx1 = Transaction( + sender=relayer, + to=alice, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate, + nonce=0, + signer=alice, + ) + ], + ) + tx2 = Transaction( + sender=sender2, + to=caller, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1, tx2], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(delegate_code)), + ], + codes_absent=[ + Bytes(marker), + ], + ) + ), + ) + ], + post={ + alice: Account( + nonce=1, + code=marker, + ), + }, + ) + + +def test_witness_codes_redelegation_old_marker_included_new_marker_excluded( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Re-delegate an EOA that already had delegation in pre-state. + + The OLD marker IS in executionWitness.codes because + set_delegation() reads it via get_code() before overwriting. + The NEW marker is NOT in codes because it is written via + set_code(). + + Pre-state: + alice (delegated to delegate_old, marker in pre-state) + delegate_old, delegate_new (contracts with code) + + tx (type-4, auth list): + set_delegation(alice -> delegate_new) + => reads alice's old marker via get_code() (pre-state) + => writes new marker to alice via set_code() + + Witness codes: + old_marker IN codes (pre-state read) + new_marker NOT IN codes (written in this tx) + """ + delegate_old_code = Op.PUSH1(0x01) + Op.POP + Op.STOP + delegate_old = pre.deploy_contract(code=delegate_old_code) + + delegate_new_code = Op.PUSH1(0x02) + Op.POP + Op.STOP + delegate_new = pre.deploy_contract(code=delegate_new_code) + + alice = pre.fund_eoa(delegation=delegate_old) + + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Spec7702.delegation_designation(delegate_old) + new_marker = Spec7702.delegation_designation(delegate_new) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate_new, + nonce=1, + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(old_marker), + ], + codes_absent=[ + Bytes(new_marker), + ], + ) + ), + ) + ], + post={ + alice: Account( + nonce=2, + code=new_marker, + ), + }, + ) + From c74ab2f5eb39ce96fdd498689560e8cde7da4ddf Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 16:41:13 -0300 Subject: [PATCH 091/265] more 7702 tests --- .../test_witness_7702.py | 329 +++++++++++++++++- 1 file changed, 326 insertions(+), 3 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py index 554b7e2e388..c0fc2771c18 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py @@ -3,6 +3,7 @@ import pytest from execution_testing import ( Account, + Address, Alloc, AuthorizationTuple, Block, @@ -46,9 +47,7 @@ def test_witness_codes_delegated_eoa( delegated_eoa = pre.fund_eoa(delegation=delegate) - caller_code = ( - call_opcode(address=delegated_eoa) + Op.STOP - ) + caller_code = call_opcode(address=delegated_eoa) + Op.STOP caller = pre.deploy_contract(code=caller_code) sender = pre.fund_eoa() @@ -331,3 +330,327 @@ def test_witness_codes_redelegation_old_marker_included_new_marker_excluded( }, ) + +@pytest.mark.parametrize( + "extcode_opcode", + [ + pytest.param("extcodesize", id="extcodesize"), + pytest.param("extcodecopy", id="extcodecopy"), + pytest.param("extcodehash", id="extcodehash"), + ], +) +def test_witness_codes_extcode_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + extcode_opcode: str, +) -> None: + """ + EXTCODE* opcode targeting a delegated EOA. + + Unlike CALL (which resolves delegation), EXTCODE* opcodes + operate on the account's own code: + + EXTCODESIZE/EXTCODECOPY: + Call get_code() on the account directly, returning the + 23-byte marker. Marker IS in witness, delegate code + is NOT. + + EXTCODEHASH: + Read code_hash from the account leaf — no get_code() + call. Neither marker nor delegate code appear in + witness. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + + if extcode_opcode == "extcodesize": + op = Op.EXTCODESIZE(delegated_eoa) + Op.POP + elif extcode_opcode == "extcodecopy": + op = Op.EXTCODECOPY(delegated_eoa, 0, 0, 23) + else: + op = Op.EXTCODEHASH(delegated_eoa) + Op.POP + + caller_code = op + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker = Spec7702.delegation_designation(delegate) + + if extcode_opcode in ("extcodesize", "extcodecopy"): + codes_present = [Bytes(marker)] + codes_absent = [Bytes(bytes(delegate_code))] + else: + codes_present = [] + codes_absent = [ + Bytes(marker), + Bytes(bytes(delegate_code)), + ] + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=codes_present, + codes_absent=codes_absent, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_delegation_chain( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Delegation pointing to another delegated account (chain). + + The EIP-7702 spec says "clients must retrieve only the first + code and then stop following the delegation chain." + + Pre-state: + alice --delegated--> bob --delegated--> charlie + + CALL alice: + calculate_delegation_cost() reads alice's marker + => get_code(alice) => marker_alice (pre-state) + Resolves to bob, then get_code(bob) => marker_bob + (pre-state, but NOT followed further) + + Witness codes: + marker_alice IN codes (pre-state read) + marker_bob IN codes (pre-state read) + charlie_code NOT IN codes (never reached) + """ + charlie_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + charlie = pre.deploy_contract(code=charlie_code) + + bob = pre.fund_eoa(delegation=charlie) + alice = pre.fund_eoa(delegation=bob) + + caller_code = Op.CALL(address=alice) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker_alice = Spec7702.delegation_designation(bob) + marker_bob = Spec7702.delegation_designation(charlie) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker_alice), + Bytes(marker_bob), + ], + codes_absent=[ + Bytes(bytes(charlie_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_reset_delegation( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Reset delegation by setting address to zero. + + The EIP-7702 spec says setting address to 0x00..00 clears + the code. set_delegation() reads the authority's current + code via get_code() to check the existing delegation is + valid, so the old marker appears in witness. + + Pre-state: + alice (delegated to delegate) + + tx (type-4, auth list address=0x00..00): + set_delegation(alice -> 0x00..00) + => reads old marker via get_code() (pre-state) + => writes empty code via set_code() + + Witness codes: + old_marker IN codes (pre-state read) + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + alice = pre.fund_eoa(delegation=delegate) + + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Spec7702.delegation_designation(delegate) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=Address(0), + nonce=1, + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + [Bytes(old_marker)], + allow_unexpected=False, + ) + ), + ) + ], + post={ + alice: Account( + nonce=2, + code=b"", + ), + }, + ) + + +def test_witness_codes_delegation_to_empty_account( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Delegation target has no code (empty account). + + The marker is read via get_code() and appears in witness. + When resolving delegation, the target's code is fetched but + has EMPTY_CODE_HASH, so get_code() returns early without + recording a code_reads entry. + + Pre-state: + alice --delegated--> empty_target (EOA, no code) + + tx to alice: + Reads alice's marker via get_code() (pre-state) + Resolves to empty_target, get_code(empty_target) + => EMPTY_CODE_HASH => returns early, no witness + + Witness codes: + marker IN codes (pre-state read) + """ + empty_target = pre.fund_eoa() + + alice = pre.fund_eoa(delegation=empty_target) + + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=alice, + gas_limit=500_000, + ) + + marker = Spec7702.delegation_designation(empty_target) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + [Bytes(marker)], + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_auth_nonce_mismatch( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Auth tuple rejected due to nonce mismatch. + + The authority nonce check happens after validating the current + authority code, so the marker must appear in the witness. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + alice = pre.fund_eoa(delegation=delegate) + + delegate_new_code = Op.PUSH1(0x99) + Op.POP + Op.STOP + delegate_new = pre.deploy_contract(code=delegate_new_code) + + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Spec7702.delegation_designation(delegate) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate_new, + nonce=99, # Just hardcode a wrong nonce to trigger the failure + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + [Bytes(old_marker)], + allow_unexpected=False, + ) + ), + ) + ], + post={ + alice: Account( + nonce=1, + code=old_marker, + ), + }, + ) From b8d532a5df81f221ec280497800f66047d3c1ca7 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 16:50:14 -0300 Subject: [PATCH 092/265] refactor --- ...{test_witness_extcode.py => test_witness_bytecodes_extcode.py} | 0 ...compiles.py => test_witness_eoa_bytecodes_precompiles_eoas.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/amsterdam/eip8025_optional_proofs/{test_witness_extcode.py => test_witness_bytecodes_extcode.py} (100%) rename tests/amsterdam/eip8025_optional_proofs/{test_witness_eoa_precompiles.py => test_witness_eoa_bytecodes_precompiles_eoas.py} (100%) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py similarity index 100% rename from tests/amsterdam/eip8025_optional_proofs/test_witness_extcode.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_precompiles.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_bytecodes_precompiles_eoas.py similarity index 100% rename from tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_precompiles.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_bytecodes_precompiles_eoas.py From 45dba7aff2e15f1d3c608b9063022c9ce3360fa4 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 16:50:44 -0300 Subject: [PATCH 093/265] refactor --- ...compiles_eoas.py => test_witness_bytecodes_eoa_precompiles.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/amsterdam/eip8025_optional_proofs/{test_witness_eoa_bytecodes_precompiles_eoas.py => test_witness_bytecodes_eoa_precompiles.py} (100%) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_bytecodes_precompiles_eoas.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py similarity index 100% rename from tests/amsterdam/eip8025_optional_proofs/test_witness_eoa_bytecodes_precompiles_eoas.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py From bfff990231c269f292755bc7c4fce4ce262bbb65 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 11 Mar 2026 16:52:53 -0300 Subject: [PATCH 094/265] zkevm/tests: add empty code target for extcodecopy with target size non zero --- .../test_witness_bytecodes_extcode.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py index 8b4cff8aa87..45ec10bd073 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py @@ -99,6 +99,46 @@ def test_witness_codes_extcodesize_empty_code( ) +def test_witness_codes_extcodecopy_empty_code( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EXTCODECOPY on an account with empty code (an EOA). + + The target has EMPTY_CODE_HASH, so get_code() returns early without + recording a code read. Nothing should be added to + executionWitness.codes for the target. + """ + eoa_target = pre.fund_eoa() + + caller_code = Op.EXTCODECOPY(eoa_target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + + [Bytes(bytes(caller_code))], + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + def test_witness_codes_extcodecopy( pre: Alloc, blockchain_test: BlockchainTestFiller, From 64592b033d177a962faf85e14905402b13d9d510 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 09:31:04 -0300 Subject: [PATCH 095/265] zkevm/tests: bytecode call variants dedup and revert --- .../test_witness_bytecodes_call_variants.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py index e86f826540c..2badfa2d791 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py @@ -111,3 +111,125 @@ def test_witness_codes_nested_calls( sender: Account(nonce=1), }, ) + + +def test_witness_codes_dedup_identical_bytecode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Two pre-state contracts with identical bytecode, both CALLed. + + Only one copy of the bytecode should appear in executionWitness.codes + because get_witness_codes() deduplicates by code hash. + """ + shared_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + contract_a = pre.deploy_contract(code=shared_code) + contract_b = pre.deploy_contract(code=shared_code) + + caller_code = ( + Op.CALL(Op.GAS, contract_a, 0, 0, 0, 0, 0) + + Op.POP + + Op.CALL(Op.GAS, contract_b, 0, 0, 0, 0, 0) + + Op.POP + + Op.STOP + ) + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(shared_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_reverted_transaction( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Transaction that fully reverts still records accessed code. + """ + target_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + target = pre.deploy_contract(code=target_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=target, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(target_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + target: Account(storage={0: 0}), + }, + ) + + +def test_witness_codes_reverted_inner_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Inner CALL that reverts while outer transaction succeeds. + + The reverted callee's code must still be in executionWitness.codes. + """ + callee_code = Op.SSTORE(0, 1) + Op.REVERT(0, 0) + callee = pre.deploy_contract(code=callee_code) + + caller_code = ( + Op.CALL(Op.GAS, callee, 0, 0, 0, 0, 0) + + Op.POP + + Op.SSTORE(0, 1) + + Op.STOP + ) + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(callee_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + callee: Account(storage={0: 0}), # Check reverted storage change + caller: Account(storage={0: 1}), + }, + ) From 21a0ea213ce187aa8aec3d1e8f8553a85cce51a8 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 09:31:17 -0300 Subject: [PATCH 096/265] zkevm/tests: bytecode selfdestruct --- .../test_witness_bytecodes_selfdestruct.py | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py new file mode 100644 index 00000000000..4ce0442a0cb --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py @@ -0,0 +1,236 @@ +"""Witness bytecode collection for SELFDESTRUCT.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Initcode, + Op, + Transaction, + compute_create_address, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_codes_selfdestruct( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Contract executing SELFDESTRUCT has its code in witness. + + The beneficiary is a contract whose code must NOT appear in the + witness because SELFDESTRUCT does not call get_code on the + beneficiary. + """ + sender = pre.fund_eoa() + + beneficiary_code = Op.STOP + beneficiary = pre.deploy_contract(code=beneficiary_code) + + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code) + + caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(target_code))], + codes_absent=[Bytes(bytes(beneficiary_code))], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_codes_create_then_selfdestruct_same_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Factory CREATEs a contract then CALLs it; created contract SELFDESTRUCTs. + + The created contract was added to created_accounts by CREATE, so + SELFDESTRUCT actually deletes the account (EIP-6780). Its runtime + code should NOT appear in executionWitness.codes because get_code() + returned it from tx-local code_writes, never from pre-state. + The factory's code IS in the witness. + """ + # PUSH0 pushes address(0) as the beneficiary for SELFDESTRUCT. + runtime_code = bytes(Op.PUSH0 + Op.SELFDESTRUCT) + initcode = Initcode(deploy_code=runtime_code) + initcode_bytes = bytes(initcode) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE( + 0, + Op.CREATE( + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + ), + ) + + Op.CALL(address=Op.SLOAD(0)) + + Op.STOP + ) + factory = pre.deploy_contract(code=factory_code, balance=10**18) + sender = pre.fund_eoa() + + created = compute_create_address(address=factory, nonce=1) + + tx = Transaction(sender=sender, to=factory, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(factory_code)), + ], + codes_absent=[ + Bytes(runtime_code), + ], + ) + ), + ) + ], + post={ + created: Account.NONEXISTENT, + factory: Account(storage={0: created}), + }, + ) + + +def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + SELFDESTRUCT with a 7702 delegated EOA as beneficiary. + + SELFDESTRUCT does not call get_code() on the beneficiary, so the + delegation marker and the delegate's bytecode must NOT appear in + executionWitness.codes. + """ + sender = pre.fund_eoa() + + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + beneficiary = pre.fund_eoa(delegation=delegate) + marker = Spec7702.delegation_designation(delegate) + + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code) + + caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(target_code)), + ], + codes_absent=[ + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize( + "beneficiary_type", + [ + pytest.param("eoa", id="eoa"), + pytest.param("nonexistent", id="nonexistent"), + ], +) +def test_witness_codes_selfdestruct_beneficiary_no_code( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, + beneficiary_type: str, +) -> None: + """ + SELFDESTRUCT where beneficiary has no code (EOA or nonexistent). + + Only system contract bytecodes, the caller's code, and the + target's code should appear in executionWitness.codes. Nothing + else should leak into the witness. + """ + sender = pre.fund_eoa() + + if beneficiary_type == "eoa": + beneficiary = pre.fund_eoa() + else: + beneficiary = Address(0xDEAD) + + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code) + + caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + + [ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + }, + ) From ca752d01f17c94ce2b6cc4e1740251b27778d41f Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 09:43:58 -0300 Subject: [PATCH 097/265] tighter post state check --- .../test_witness_bytecodes_selfdestruct.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py index 4ce0442a0cb..53c079e2480 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py @@ -62,6 +62,7 @@ def test_witness_codes_selfdestruct( ], post={ sender: Account(nonce=1), + target: Account(code=target_code), }, ) From b237c0518763659d7a013d0436ff109061403396 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 09:54:37 -0300 Subject: [PATCH 098/265] tighter witness and post-state checks --- .../test_witness_bytecodes_call_variants.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py index 2badfa2d791..a0da51c0d2f 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py @@ -115,6 +115,7 @@ def test_witness_codes_nested_calls( def test_witness_codes_dedup_identical_bytecode( pre: Alloc, + system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -123,7 +124,7 @@ def test_witness_codes_dedup_identical_bytecode( Only one copy of the bytecode should appear in executionWitness.codes because get_witness_codes() deduplicates by code hash. """ - shared_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + shared_code = Op.SSTORE(0, 1) + Op.STOP contract_a = pre.deploy_contract(code=shared_code) contract_b = pre.deploy_contract(code=shared_code) @@ -146,13 +147,20 @@ def test_witness_codes_dedup_identical_bytecode( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[Bytes(bytes(shared_code))], + codes_present=system_codes + + [ + Bytes(bytes(caller_code)), + Bytes(bytes(shared_code)), + ], + allow_unexpected=False, ) ), ) ], post={ sender: Account(nonce=1), + contract_a: Account(storage={0: 1}), + contract_b: Account(storage={0: 1}), }, ) From c96a21b4105a336b95186b19526d07950d1c525c Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 10:59:29 -0300 Subject: [PATCH 099/265] zkevm/tests: more selfdestruct tests and tighter checks --- .../test_witness_bytecodes_selfdestruct.py | 159 ++++++++++++++++-- 1 file changed, 146 insertions(+), 13 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py index 53c079e2480..d2c689d693b 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py @@ -25,6 +25,7 @@ def test_witness_codes_selfdestruct( pre: Alloc, + system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -36,11 +37,12 @@ def test_witness_codes_selfdestruct( """ sender = pre.fund_eoa() - beneficiary_code = Op.STOP + beneficiary_code = Op.PUSH1(0xAA) + Op.POP + Op.STOP beneficiary = pre.deploy_contract(code=beneficiary_code) + target_balance = 1 target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT - target = pre.deploy_contract(code=target_code) + target = pre.deploy_contract(code=target_code, balance=target_balance) caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP caller = pre.deploy_contract(code=caller_code) @@ -54,33 +56,87 @@ def test_witness_codes_selfdestruct( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[Bytes(bytes(target_code))], + codes_present=system_codes + + [ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], codes_absent=[Bytes(bytes(beneficiary_code))], + allow_unexpected=False, ) ), ) ], post={ sender: Account(nonce=1), - target: Account(code=target_code), + beneficiary: Account(balance=target_balance), + target: Account(balance=0, code=target_code), + }, + ) + + +def test_witness_codes_selfdestruct_top_level_tx( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Top-level transaction to a selfdestructing contract. + + The target bytecode should appear in the witness, the beneficiary's + bytecode should not, and the beneficiary must receive the target's + balance to prove SELFDESTRUCT actually executed. + """ + sender = pre.fund_eoa() + + beneficiary_code = Op.PUSH1(0xBB) + Op.POP + Op.STOP + beneficiary = pre.deploy_contract(code=beneficiary_code) + + target_balance = 1 + target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT + target = pre.deploy_contract(code=target_code, balance=target_balance) + + tx = Transaction(sender=sender, to=target, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes + + [ + Bytes(bytes(target_code)), + ], + codes_absent=[Bytes(bytes(beneficiary_code))], + allow_unexpected=False, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + beneficiary: Account(balance=target_balance), + target: Account(balance=0, code=target_code), }, ) def test_witness_codes_create_then_selfdestruct_same_tx( pre: Alloc, + system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ Factory CREATEs a contract then CALLs it; created contract SELFDESTRUCTs. The created contract was added to created_accounts by CREATE, so - SELFDESTRUCT actually deletes the account (EIP-6780). Its runtime + SELFDESTRUCT actually deletes the account (EIP-6780). Its runtime code should NOT appear in executionWitness.codes because get_code() returned it from tx-local code_writes, never from pre-state. The factory's code IS in the witness. """ - # PUSH0 pushes address(0) as the beneficiary for SELFDESTRUCT. runtime_code = bytes(Op.PUSH0 + Op.SELFDESTRUCT) initcode = Initcode(deploy_code=runtime_code) initcode_bytes = bytes(initcode) @@ -111,25 +167,83 @@ def test_witness_codes_create_then_selfdestruct_same_tx( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[ + codes_present=system_codes + + [ Bytes(bytes(factory_code)), ], codes_absent=[ Bytes(runtime_code), ], + allow_unexpected=False, ) ), ) ], post={ + sender: Account(nonce=1), created: Account.NONEXISTENT, factory: Account(storage={0: created}), }, ) +def test_witness_codes_selfdestruct_in_initcode( + pre: Alloc, + system_codes: list[Bytes], + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Initcode that executes SELFDESTRUCT during contract creation. + + The initcode comes from tx data and must not appear in the witness. + The beneficiary's code must also stay out of the witness, while the + beneficiary balance change proves SELFDESTRUCT executed. + """ + creator = pre.fund_eoa() + + beneficiary_code = Op.PUSH1(0xCC) + Op.POP + Op.STOP + beneficiary = pre.deploy_contract(code=beneficiary_code) + + tx_value = 7 + initcode = bytes(Op.PUSH20(beneficiary) + Op.SELFDESTRUCT) + created = compute_create_address(address=creator, nonce=0) + + tx = Transaction( + sender=creator, + to=None, + data=initcode, + value=tx_value, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=system_codes, + codes_absent=[ + Bytes(initcode), + Bytes(bytes(beneficiary_code)), + ], + allow_unexpected=False, + ) + ), + ) + ], + post={ + creator: Account(nonce=1), + beneficiary: Account(balance=tx_value), + created: Account.NONEXISTENT, + }, + ) + + def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( pre: Alloc, + system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -144,11 +258,16 @@ def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP delegate = pre.deploy_contract(code=delegate_code) - beneficiary = pre.fund_eoa(delegation=delegate) + beneficiary_initial_balance = 1 + beneficiary = pre.fund_eoa( + amount=beneficiary_initial_balance, + delegation=delegate, + ) marker = Spec7702.delegation_designation(delegate) + target_balance = 1 target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT - target = pre.deploy_contract(code=target_code) + target = pre.deploy_contract(code=target_code, balance=target_balance) caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP caller = pre.deploy_contract(code=caller_code) @@ -162,19 +281,26 @@ def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[ + codes_present=system_codes + + [ + Bytes(bytes(caller_code)), Bytes(bytes(target_code)), ], codes_absent=[ Bytes(marker), Bytes(bytes(delegate_code)), ], + allow_unexpected=False, ) ), ) ], post={ sender: Account(nonce=1), + beneficiary: Account( + balance=beneficiary_initial_balance + target_balance + ), + target: Account(balance=0, code=target_code), }, ) @@ -196,18 +322,21 @@ def test_witness_codes_selfdestruct_beneficiary_no_code( SELFDESTRUCT where beneficiary has no code (EOA or nonexistent). Only system contract bytecodes, the caller's code, and the - target's code should appear in executionWitness.codes. Nothing + target's code should appear in executionWitness.codes. Nothing else should leak into the witness. """ sender = pre.fund_eoa() + target_balance = 1 if beneficiary_type == "eoa": - beneficiary = pre.fund_eoa() + beneficiary_initial_balance = 1 + beneficiary = pre.fund_eoa(amount=beneficiary_initial_balance) else: + beneficiary_initial_balance = 0 beneficiary = Address(0xDEAD) target_code = Op.PUSH20(beneficiary) + Op.SELFDESTRUCT - target = pre.deploy_contract(code=target_code) + target = pre.deploy_contract(code=target_code, balance=target_balance) caller_code = Op.CALL(Op.GAS, target, 0, 0, 0, 0, 0) + Op.STOP caller = pre.deploy_contract(code=caller_code) @@ -233,5 +362,9 @@ def test_witness_codes_selfdestruct_beneficiary_no_code( ], post={ sender: Account(nonce=1), + beneficiary: Account( + balance=beneficiary_initial_balance + target_balance + ), + target: Account(balance=0, code=target_code), }, ) From 40fa480c309756363c02def3bb85a504464e8c60 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 12:17:21 -0300 Subject: [PATCH 100/265] lints --- .../test_witness_bytecodes_call_variants.py | 2 +- .../test_witness_bytecodes_selfdestruct.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py index a0da51c0d2f..8616a9225ef 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py @@ -237,7 +237,7 @@ def test_witness_codes_reverted_inner_call( ], post={ sender: Account(nonce=1), - callee: Account(storage={0: 0}), # Check reverted storage change + callee: Account(storage={0: 0}), # Check reverted storage change caller: Account(storage={0: 1}), }, ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py index d2c689d693b..42c3b0a37e6 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py @@ -328,6 +328,7 @@ def test_witness_codes_selfdestruct_beneficiary_no_code( sender = pre.fund_eoa() target_balance = 1 + beneficiary: Address if beneficiary_type == "eoa": beneficiary_initial_balance = 1 beneficiary = pre.fund_eoa(amount=beneficiary_initial_balance) From bad718751ec52bddee5b284c25e6d1723f26d286 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 13:14:53 -0300 Subject: [PATCH 101/265] zkevm/tests: add ExecutionWitnessHeadersExpectation for headers assertions support --- .../testing/src/execution_testing/__init__.py | 2 + .../src/execution_testing/specs/blockchain.py | 23 +++ .../src/execution_testing/specs/state.py | 7 + .../execution_testing/test_types/__init__.py | 2 + .../test_types/execution_witness/__init__.py | 6 +- .../execution_witness/expectations.py | 166 +++++++++++++++++- 6 files changed, 202 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index a26eff21bbb..9691e9ce22f 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -66,6 +66,7 @@ DepositRequest, Environment, ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, NetworkWrappedTransaction, Removable, Requests, @@ -155,6 +156,7 @@ "Conditional", "ConsolidationRequest", "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", "ExtCallGenerator", "DeploymentTestType", "DepositRequest", diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index dee456a1064..aca53696b14 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -90,6 +90,7 @@ ) from execution_testing.test_types.execution_witness import ( ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, ) from .base import BaseTest, FillResult, OpMode, verify_result @@ -280,6 +281,13 @@ class Block(Header): If set, the execution witness codes will be verified and potentially modified for invalid tests. """ + expected_execution_witness_headers: ( + ExecutionWitnessHeadersExpectation | None + ) = None + """ + If set, the execution witness headers will be verified and potentially + modified for invalid tests. + """ exception: BLOCK_EXCEPTION_TYPE = None # If set, the block is expected to be rejected by the client. skip_exception_verification: bool = False @@ -800,6 +808,21 @@ def generate_block_data( ) ) + # If expected witness headers defined, verify against actual + if ( + block.expected_execution_witness_headers is not None + and execution_witness is not None + ): + block.expected_execution_witness_headers.verify_against( + execution_witness, + parent_hash=header.parent_hash, + fork=self.fork, + ) + execution_witness = ( + block.expected_execution_witness_headers + .modify_if_invalid_test(execution_witness) + ) + built_block = BuiltBlock( header=header, alloc=transition_tool_output.alloc, diff --git a/packages/testing/src/execution_testing/specs/state.py b/packages/testing/src/execution_testing/specs/state.py index c4d81e23cfc..2f0b6c131df 100644 --- a/packages/testing/src/execution_testing/specs/state.py +++ b/packages/testing/src/execution_testing/specs/state.py @@ -54,6 +54,7 @@ BlockAccessListExpectation, Environment, ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, Transaction, ) @@ -87,6 +88,9 @@ class StateTest(BaseTest): expected_execution_witness_codes: Optional[ ExecutionWitnessCodesExpectation ] = None + expected_execution_witness_headers: Optional[ + ExecutionWitnessHeadersExpectation + ] = None chain_id: int = 1 supported_fixture_formats: ClassVar[ @@ -321,6 +325,9 @@ def _generate_blockchain_blocks(self) -> List[Block]: "expected_execution_witness_codes": ( self.expected_execution_witness_codes ), + "expected_execution_witness_headers": ( + self.expected_execution_witness_headers + ), } if not self.fork.header_prev_randao_required(): kwargs["difficulty"] = self.env.difficulty diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index e1891540158..a30e0785ef0 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -22,6 +22,7 @@ from .execution_witness import ( ExecutionWitness, ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, ExecutionWitnessValidationError, ) from .helpers import ( @@ -78,6 +79,7 @@ "EnvironmentDefaults", "ExecutionWitness", "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", "ExecutionWitnessValidationError", "NetworkWrappedTransaction", "Removable", diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py index 772567c4d41..0035a8ac40b 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py @@ -6,11 +6,15 @@ """ from .exceptions import ExecutionWitnessValidationError -from .expectations import ExecutionWitnessCodesExpectation +from .expectations import ( + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, +) from .types import ExecutionWitness __all__ = [ "ExecutionWitness", "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", "ExecutionWitnessValidationError", ] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 71630ad48e0..9a10f6f6f32 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -1,15 +1,19 @@ """ -Execution witness codes expectation classes for test validation. +Execution witness expectation classes for test validation. This module contains classes for defining and validating expected -execution witness codes in tests. +execution witness codes and headers in tests. """ +from __future__ import annotations + from typing import Callable, List +import ethereum_rlp as eth_rlp from pydantic import Field, PrivateAttr -from execution_testing.base_types import Bytes, CamelModel +from execution_testing.base_types import Bytes, CamelModel, Hash +from execution_testing.forks import Fork, Prague from .exceptions import ExecutionWitnessValidationError from .types import ExecutionWitness @@ -151,6 +155,161 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: ) +class ExecutionWitnessHeadersExpectation(CamelModel): + """ + Execution witness headers expectation model for test writing. + + Define expected properties of executionWitness.headers. + + Example: + expected_execution_witness_headers = ( + ExecutionWitnessHeadersExpectation( + expected_count=5, + ) + ) + + """ + + expected_count: int = Field( + description="Exact number of RLP-encoded headers expected", + ) + + _modifier: ( + Callable[["ExecutionWitness"], "ExecutionWitness"] | None + ) = PrivateAttr(default=None) + + def modify( + self, + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], + ) -> "ExecutionWitnessHeadersExpectation": + """ + Create a new expectation with a modifier for invalid test cases. + + Args: + modifiers: One or more functions that take and return + an ExecutionWitness + + Returns: + A new ExecutionWitnessHeadersExpectation with modifiers + applied + + """ + new_instance = self.model_copy(deep=True) + new_instance._modifier = _compose(*modifiers) + return new_instance + + def modify_if_invalid_test( + self, t8n_witness: "ExecutionWitness" + ) -> "ExecutionWitness": + """ + Apply the modifier to the given witness if this is an invalid test. + + Args: + t8n_witness: The ExecutionWitness from the t8n tool + + Returns: + The potentially transformed ExecutionWitness for the fixture + + """ + if self._modifier: + return self._modifier(t8n_witness) + return t8n_witness + + def verify_against( + self, + actual_witness: ExecutionWitness, + parent_hash: Hash, + fork: Fork, + ) -> None: + """ + Verify header count and structural invariants. + + Validation steps: + 1. Count matches expected_count + 2. No more than 256 headers + 3. Sorted ascending by block number (Prague+) + 4. Contiguous: keccak256(headers[i]) == parent_hash of + headers[i+1] (Prague+) + 5. Last header is the current block's parent: + keccak256(headers[-1]) == parent_hash (Prague+) + + Steps 3-5 require RLP decoding and are only performed for + Prague and newer forks where executionWitness.headers is + fully specified. + + Args: + actual_witness: The ExecutionWitness from the t8n tool + parent_hash: The parent hash of the current block + fork: The fork under test + + Raises: + ExecutionWitnessValidationError: If verification fails + + """ + actual_headers = actual_witness.headers + + # 1. Count check + if len(actual_headers) != self.expected_count: + raise ExecutionWitnessValidationError( + f"Expected {self.expected_count} witness headers, " + f"got {len(actual_headers)}" + ) + + # 2. Max 256 headers + if len(actual_headers) > 256: + raise ExecutionWitnessValidationError( + f"Witness headers exceed maximum of 256: " + f"got {len(actual_headers)}" + ) + + # Since Prague we have EIP-2935 which requires the parent + # block's header to be included. + if len(actual_headers) == 0 or fork < Prague: + return + + # Decode all headers to extract block numbers and parent + # hashes for structural checks. + decoded = [] + for i, rlp_header in enumerate(actual_headers): + fields = eth_rlp.decode(rlp_header) + header_parent_hash = Hash(fields[0]) + block_number = int.from_bytes(fields[8], "big") + header_hash = rlp_header.keccak256() + decoded.append( + (block_number, header_parent_hash, header_hash) + ) + + # 3. Sorted ascending by block number + block_numbers = [d[0] for d in decoded] + if block_numbers != sorted(block_numbers): + raise ExecutionWitnessValidationError( + "Witness headers are not sorted in ascending " + f"block number order: {block_numbers}" + ) + + # 4. Contiguous: keccak256(headers[i]) == parent_hash of + # headers[i+1] + for i in range(len(decoded) - 1): + _, _, current_hash = decoded[i] + _, next_parent_hash, _ = decoded[i + 1] + if current_hash != next_parent_hash: + raise ExecutionWitnessValidationError( + f"Witness headers not contiguous at index " + f"{i}: hash {current_hash.hex()} != " + f"parent_hash {next_parent_hash.hex()} of " + f"header {i + 1}" + ) + + # 5. Last header is the current block's parent + _, _, last_hash = decoded[-1] + if last_hash != parent_hash: + raise ExecutionWitnessValidationError( + f"Last witness header hash " + f"{last_hash.hex()} != current block " + f"parent_hash {parent_hash.hex()}" + ) + + def _compose( *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], ) -> Callable[["ExecutionWitness"], "ExecutionWitness"]: @@ -169,4 +328,5 @@ def composed( __all__ = [ "ExecutionWitnessCodesExpectation", + "ExecutionWitnessHeadersExpectation", ] From 6694e66d0d11f8c3d93a3c85040cd1bcc861622c Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 13:21:06 -0300 Subject: [PATCH 102/265] zkevm/tests: add tests for ancestor field in execution witness --- .../test_witness_headers.py | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py new file mode 100644 index 00000000000..7d81ef9ebaf --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -0,0 +1,250 @@ +"""Witness header collection border-case tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_headers_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test witness headers for an empty block (no user transactions). + + The only ancestor tracking comes from the EIP-2935 system contract + which unconditionally records offset = 1 (parent header). + """ + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=1, + ) + ), + ), + ], + post={}, + ) + + +@pytest.mark.parametrize("offset", [1, 2, 5, 10]) +def test_witness_headers_blockhash_at_offset( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + offset: int, +) -> None: + """ + Test witness headers when BLOCKHASH queries a block at a given offset. + + offset = 1 matches the EIP-2935 baseline. + offset > 1 verifies BLOCKHASH extends oldest_ancestor_offset beyond + the system-contract baseline. + """ + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "queried_block_code", + [ + pytest.param(Op.NUMBER, id="current_block"), + pytest.param(Op.ADD(Op.NUMBER, 1), id="future_block"), + ], +) +def test_witness_headers_blockhash_out_of_range( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + queried_block_code: Op, +) -> None: + """ + Test witness headers when BLOCKHASH queries an out-of-range block. + + BLOCKHASH returns 0 for the current or future block numbers, so + track_ancestor_access is never called by the opcode. Only the + EIP-2935 system-contract offset = 1 remains. + """ + code = Op.BLOCKHASH(queried_block_code) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=1, + ) + ), + ), + ], + post={sender: Account(nonce=1)}, + ) + + +def test_witness_headers_blockhash_in_reverted_tx( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test witness headers survive a full transaction revert. + """ + offset = 5 + code = ( + Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + + Op.POP + + Op.REVERT(0, 0) + ) + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "offsets", + [ + pytest.param([2, 8], id="ascending"), + pytest.param([8, 2], id="descending"), + pytest.param([3, 3], id="same_twice"), + ], +) +def test_witness_headers_multiple_blockhash_max_wins( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + offsets: list[int], +) -> None: + """ + Test that the maximum BLOCKHASH offset wins. + + Multiple BLOCKHASH calls in one contract: the ascending and + descending cases prove order-independence. The same_twice case + confirms idempotent tracking. + """ + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offsets[0])) + Op.POP + for o in offsets[1:]: + code += Op.BLOCKHASH(Op.SUB(Op.NUMBER, o)) + Op.POP + code += Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + expected_count = max(offsets) + blocks = [Block(txs=[]) for _ in range(expected_count)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=expected_count, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +@pytest.mark.slow +@pytest.mark.parametrize( + "offset,expected_count", + [ + pytest.param(256, 256, id="max_valid"), + pytest.param(257, 1, id="first_invalid"), + ], +) +def test_witness_headers_blockhash_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + offset: int, + expected_count: int, +) -> None: + """ + Test witness headers at the exact boundary of the 256-block window. + + At offset = 256 the BLOCKHASH range check passes and all 256 + headers appear. At offset = 257 the check fails, BLOCKHASH + returns 0, no tracking occurs, and only the EIP-2935 parent + header remains. + """ + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=expected_count, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) From 61e20bbe060fc0c894946b4acdfb7826063f167d Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 12 Mar 2026 13:30:30 -0300 Subject: [PATCH 103/265] lints --- .../src/execution_testing/specs/blockchain.py | 13 +++++-------- .../execution_witness/expectations.py | 18 ++++++++---------- .../test_witness_headers.py | 6 +----- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index aca53696b14..eff201e79d9 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -809,18 +809,15 @@ def generate_block_data( ) # If expected witness headers defined, verify against actual - if ( - block.expected_execution_witness_headers is not None - and execution_witness is not None - ): - block.expected_execution_witness_headers.verify_against( + headers_expectation = block.expected_execution_witness_headers + if headers_expectation is not None and execution_witness is not None: + headers_expectation.verify_against( execution_witness, parent_hash=header.parent_hash, fork=self.fork, ) - execution_witness = ( - block.expected_execution_witness_headers - .modify_if_invalid_test(execution_witness) + execution_witness = headers_expectation.modify_if_invalid_test( + execution_witness ) built_block = BuiltBlock( diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 9a10f6f6f32..66eb058c8d9 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -174,9 +174,9 @@ class ExecutionWitnessHeadersExpectation(CamelModel): description="Exact number of RLP-encoded headers expected", ) - _modifier: ( - Callable[["ExecutionWitness"], "ExecutionWitness"] | None - ) = PrivateAttr(default=None) + _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( + PrivateAttr(default=None) + ) def modify( self, @@ -234,8 +234,8 @@ def verify_against( keccak256(headers[-1]) == parent_hash (Prague+) Steps 3-5 require RLP decoding and are only performed for - Prague and newer forks where executionWitness.headers is - fully specified. + Prague and newer forks where EIP-2935 guarantees at least + one ancestor header (the parent) is always tracked. Args: actual_witness: The ExecutionWitness from the t8n tool @@ -270,14 +270,12 @@ def verify_against( # Decode all headers to extract block numbers and parent # hashes for structural checks. decoded = [] - for i, rlp_header in enumerate(actual_headers): - fields = eth_rlp.decode(rlp_header) + for rlp_header in actual_headers: + fields: list[bytes] = eth_rlp.decode(rlp_header) # type: ignore[assignment] header_parent_hash = Hash(fields[0]) block_number = int.from_bytes(fields[8], "big") header_hash = rlp_header.keccak256() - decoded.append( - (block_number, header_parent_hash, header_hash) - ) + decoded.append((block_number, header_parent_hash, header_hash)) # 3. Sorted ascending by block number block_numbers = [d[0] for d in decoded] diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py index 7d81ef9ebaf..d6b31c0eb64 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -128,11 +128,7 @@ def test_witness_headers_blockhash_in_reverted_tx( Test witness headers survive a full transaction revert. """ offset = 5 - code = ( - Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) - + Op.POP - + Op.REVERT(0, 0) - ) + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.REVERT(0, 0) contract = pre.deploy_contract(code=code) sender = pre.fund_eoa() tx = Transaction(sender=sender, to=contract, gas_limit=500_000) From 84d525f2e1a4fbfd24967259beab3ffd059bf755 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 16:05:34 -0300 Subject: [PATCH 104/265] zkevm/tests: add ExecutionWitnessStateExpectation component --- .../testing/src/execution_testing/__init__.py | 2 + .../src/execution_testing/specs/blockchain.py | 28 +++- .../src/execution_testing/specs/state.py | 7 + .../execution_testing/test_types/__init__.py | 2 + .../test_types/execution_witness/__init__.py | 2 + .../execution_witness/expectations.py | 136 +++++++++++++++++- .../test_types/execution_witness/modifiers.py | 36 +++++ .../tests/test_execution_witness.py | 78 ++++++++++ 8 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index 9691e9ce22f..2e504945535 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -67,6 +67,7 @@ Environment, ExecutionWitnessCodesExpectation, ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, NetworkWrappedTransaction, Removable, Requests, @@ -157,6 +158,7 @@ "ConsolidationRequest", "ExecutionWitnessCodesExpectation", "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", "ExtCallGenerator", "DeploymentTestType", "DepositRequest", diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index eff201e79d9..6f89f3fbe67 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -91,6 +91,7 @@ from execution_testing.test_types.execution_witness import ( ExecutionWitnessCodesExpectation, ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, ) from .base import BaseTest, FillResult, OpMode, verify_result @@ -281,6 +282,13 @@ class Block(Header): If set, the execution witness codes will be verified and potentially modified for invalid tests. """ + expected_execution_witness_state: ( + ExecutionWitnessStateExpectation | None + ) = None + """ + If set, the execution witness state will be verified and potentially + modified for invalid tests. + """ expected_execution_witness_headers: ( ExecutionWitnessHeadersExpectation | None ) = None @@ -794,17 +802,26 @@ def generate_block_data( # If the BAL was modified, update the header hash header.block_access_list_hash = Hash(bal.rlp.keccak256()) - # If expected witness codes defined, verify against actual + # If expected witness state/codes defined, verify against actual t8n_witness = transition_tool_output.result.execution_witness execution_witness = t8n_witness + state_expectation = block.expected_execution_witness_state + if state_expectation is not None and execution_witness is not None: + state_expectation.verify_against(t8n_witness) + execution_witness = state_expectation.modify_if_invalid_test( + execution_witness + ) + if ( block.expected_execution_witness_codes is not None and t8n_witness is not None ): - block.expected_execution_witness_codes.verify_against(t8n_witness) + block.expected_execution_witness_codes.verify_against( + t8n_witness + ) execution_witness = ( block.expected_execution_witness_codes.modify_if_invalid_test( - t8n_witness + execution_witness ) ) @@ -856,6 +873,11 @@ def generate_block_data( block.expected_block_access_list is not None and block.expected_block_access_list._modifier is not None ) + and not ( + block.expected_execution_witness_state is not None + and block.expected_execution_witness_state._modifier + is not None + ) and not ( block.expected_execution_witness_codes is not None and block.expected_execution_witness_codes._modifier diff --git a/packages/testing/src/execution_testing/specs/state.py b/packages/testing/src/execution_testing/specs/state.py index 2f0b6c131df..b875cd6ea32 100644 --- a/packages/testing/src/execution_testing/specs/state.py +++ b/packages/testing/src/execution_testing/specs/state.py @@ -55,6 +55,7 @@ Environment, ExecutionWitnessCodesExpectation, ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, Transaction, ) @@ -88,6 +89,9 @@ class StateTest(BaseTest): expected_execution_witness_codes: Optional[ ExecutionWitnessCodesExpectation ] = None + expected_execution_witness_state: Optional[ + ExecutionWitnessStateExpectation + ] = None expected_execution_witness_headers: Optional[ ExecutionWitnessHeadersExpectation ] = None @@ -325,6 +329,9 @@ def _generate_blockchain_blocks(self) -> List[Block]: "expected_execution_witness_codes": ( self.expected_execution_witness_codes ), + "expected_execution_witness_state": ( + self.expected_execution_witness_state + ), "expected_execution_witness_headers": ( self.expected_execution_witness_headers ), diff --git a/packages/testing/src/execution_testing/test_types/__init__.py b/packages/testing/src/execution_testing/test_types/__init__.py index a30e0785ef0..b53a4b0f0a1 100644 --- a/packages/testing/src/execution_testing/test_types/__init__.py +++ b/packages/testing/src/execution_testing/test_types/__init__.py @@ -23,6 +23,7 @@ ExecutionWitness, ExecutionWitnessCodesExpectation, ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, ExecutionWitnessValidationError, ) from .helpers import ( @@ -80,6 +81,7 @@ "ExecutionWitness", "ExecutionWitnessCodesExpectation", "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", "ExecutionWitnessValidationError", "NetworkWrappedTransaction", "Removable", diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py index 0035a8ac40b..38546ad0050 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/__init__.py @@ -9,6 +9,7 @@ from .expectations import ( ExecutionWitnessCodesExpectation, ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, ) from .types import ExecutionWitness @@ -16,5 +17,6 @@ "ExecutionWitness", "ExecutionWitnessCodesExpectation", "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", "ExecutionWitnessValidationError", ] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 66eb058c8d9..966935021e6 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -2,7 +2,7 @@ Execution witness expectation classes for test validation. This module contains classes for defining and validating expected -execution witness codes and headers in tests. +execution witness state, codes, and headers in tests. """ from __future__ import annotations @@ -155,6 +155,139 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: ) +class ExecutionWitnessStateExpectation(CamelModel): + """ + Execution witness state expectation model for test writing. + + Define which encoded trie nodes should or should not appear in + executionWitness.state. + + Example: + expected_execution_witness_state = ExecutionWitnessStateExpectation( + nodes_present=[Bytes(derived_node_rlp)], + ) + + """ + + nodes_present: List[Bytes] = Field( + default_factory=list, + description="Encoded trie nodes that must be present in witness state", + ) + nodes_absent: List[Bytes] = Field( + default_factory=list, + description=( + "Encoded trie nodes that must NOT be present in witness state" + ), + ) + allow_unexpected: bool = Field( + default=True, + description=( + "If False, fail when witness state contains nodes " + "not listed in nodes_present" + ), + ) + + _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( + PrivateAttr(default=None) + ) + + def modify( + self, + *modifiers: Callable[["ExecutionWitness"], "ExecutionWitness"], + ) -> "ExecutionWitnessStateExpectation": + """ + Create a new expectation with a modifier for invalid test cases. + + Args: + modifiers: One or more functions that take and return + an ExecutionWitness + + Returns: + A new ExecutionWitnessStateExpectation with modifiers applied + + """ + new_instance = self.model_copy(deep=True) + new_instance._modifier = _compose(*modifiers) + return new_instance + + def modify_if_invalid_test( + self, t8n_witness: "ExecutionWitness" + ) -> "ExecutionWitness": + """ + Apply the modifier to the given witness if this is an invalid test. + + Args: + t8n_witness: The ExecutionWitness from the t8n tool + + Returns: + The potentially transformed ExecutionWitness for the fixture + + """ + if self._modifier: + return self._modifier(t8n_witness) + return t8n_witness + + def verify_against(self, actual_witness: "ExecutionWitness") -> None: + """ + Verify that the actual witness state matches this expectation. + + Validation steps: + 1. Structural invariants: no duplicates and sorted order + 2. Presence checks: nodes_present entries exist + 3. Absence checks: nodes_absent entries do not exist + 4. Exhaustiveness: if allow_unexpected=False, no extra nodes + + Args: + actual_witness: The ExecutionWitness from the t8n tool + + Raises: + ExecutionWitnessValidationError: If verification fails + + """ + actual_nodes = actual_witness.state + + if len(actual_nodes) != len(set(actual_nodes)): + seen: set[Bytes] = set() + dupes: list[Bytes] = [] + for node in actual_nodes: + if node in seen: + dupes.append(node) + seen.add(node) + raise ExecutionWitnessValidationError( + f"Witness state contains duplicates: {[n.hex() for n in dupes]}" + ) + + if actual_nodes != sorted(actual_nodes): + raise ExecutionWitnessValidationError( + "Witness state is not sorted in lexicographic order" + ) + + actual_set = set(actual_nodes) + + for node in self.nodes_present: + if node not in actual_set: + raise ExecutionWitnessValidationError( + f"Expected trie node {node.hex()} not found " + f"in witness state" + ) + + for node in self.nodes_absent: + if node in actual_set: + raise ExecutionWitnessValidationError( + f"Trie node {node.hex()} should not be in " + f"witness state but was found" + ) + + if not self.allow_unexpected: + expected_set = set(self.nodes_present) + unexpected = actual_set - expected_set + if unexpected: + raise ExecutionWitnessValidationError( + f"Unexpected trie nodes in witness state: " + f"{[n.hex() for n in unexpected]}" + ) + + class ExecutionWitnessHeadersExpectation(CamelModel): """ Execution witness headers expectation model for test writing. @@ -327,4 +460,5 @@ def composed( __all__ = [ "ExecutionWitnessCodesExpectation", "ExecutionWitnessHeadersExpectation", + "ExecutionWitnessStateExpectation", ] diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index afc046f521a..a61e33ac9b6 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -28,6 +28,40 @@ def transform( return transform +def add_state_node( + node: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Add an encoded trie node entry to the witness state list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_state = list(witness.state) + new_state.append(node) + new_state.sort() + return witness.model_copy(update={"state": new_state}) + + return transform + + +def remove_state_node( + node: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove an encoded trie node entry from the witness state list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_state = [entry for entry in witness.state if entry != node] + if len(new_state) == len(witness.state): + raise ValueError( + f"Trie node {node.hex()} not found in witness state to remove" + ) + return witness.model_copy(update={"state": new_state}) + + return transform + + def remove_code( code: Bytes, ) -> Callable[[ExecutionWitness], ExecutionWitness]: @@ -47,6 +81,8 @@ def transform( __all__ = [ + "add_state_node", "add_code", + "remove_state_node", "remove_code", ] diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py new file mode 100644 index 00000000000..9aa5377cd95 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -0,0 +1,78 @@ +"""Tests for execution witness expectations.""" + +import pytest + +from execution_testing.base_types import Bytes +from execution_testing.test_types.execution_witness import ( + ExecutionWitness, + ExecutionWitnessStateExpectation, + ExecutionWitnessValidationError, +) +from execution_testing.test_types.execution_witness.modifiers import ( + add_state_node, + remove_state_node, +) + + +def test_execution_witness_state_expectation_accepts_sorted_subset() -> None: + """State expectations should accept sorted witnesses with extras.""" + witness = ExecutionWitness( + state=[Bytes(b"aa"), Bytes(b"bb")], + codes=[], + headers=[], + ) + + ExecutionWitnessStateExpectation( + nodes_present=[Bytes(b"aa")] + ).verify_against(witness) + + +def test_execution_witness_state_expectation_rejects_duplicates() -> None: + """Duplicate state entries should fail structural validation.""" + witness = ExecutionWitness( + state=[Bytes(b"aa"), Bytes(b"aa")], + codes=[], + headers=[], + ) + + with pytest.raises( + ExecutionWitnessValidationError, match="contains duplicates" + ): + ExecutionWitnessStateExpectation().verify_against(witness) + + +def test_execution_witness_state_expectation_rejects_unsorted_entries() -> None: + """Unsorted state entries should fail structural validation.""" + witness = ExecutionWitness( + state=[Bytes(b"bb"), Bytes(b"aa")], + codes=[], + headers=[], + ) + + with pytest.raises( + ExecutionWitnessValidationError, match="not sorted" + ): + ExecutionWitnessStateExpectation().verify_against(witness) + + +def test_execution_witness_state_expectation_rejects_missing_node() -> None: + """Missing required nodes should fail validation.""" + witness = ExecutionWitness(state=[Bytes(b"aa")], codes=[], headers=[]) + + with pytest.raises( + ExecutionWitnessValidationError, match="not found in witness state" + ): + ExecutionWitnessStateExpectation( + nodes_present=[Bytes(b"bb")] + ).verify_against(witness) + + +def test_execution_witness_state_modifiers_add_and_remove() -> None: + """State modifiers should update the witness state list.""" + witness = ExecutionWitness(state=[Bytes(b"aa")], codes=[], headers=[]) + + modified = add_state_node(Bytes(b"bb"))(witness) + assert modified.state == [Bytes(b"aa"), Bytes(b"bb")] + + restored = remove_state_node(Bytes(b"bb"))(modified) + assert restored.state == [Bytes(b"aa")] From c4300995b7d18ca5f52d0888b839204218aa6cb1 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 16:17:53 -0300 Subject: [PATCH 105/265] zkevm/tests: add state helpers so tests are less verbose --- .../eip8025_optional_proofs/state_helpers.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/state_helpers.py diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py new file mode 100644 index 00000000000..e47a578e7bc --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -0,0 +1,82 @@ +"""Helpers for `ExecutionWitness.state` tests.""" + +from collections.abc import Mapping, Sequence + +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U256 + +from execution_testing import Bytes + +from ethereum.forks.amsterdam.incremental_mpt import build_mpt, mpt_get, mpt_set + + +def large_storage_value(slot: int) -> int: + """Return a 32-byte non-zero value so the leaf node is hashed.""" + return (1 << 255) + slot + + +def build_large_storage(slots: Sequence[int]) -> dict[int, int]: + """Build a slot->value mapping with large deterministic values.""" + return {slot: large_storage_value(slot) for slot in slots} + + +def _slot(slot: int) -> Bytes32: + """Convert an integer slot into a 32-byte storage key.""" + return Bytes32(slot.to_bytes(32, byteorder="big")) + + +def _storage_mpt_input(storage: Mapping[int, int]) -> dict[Bytes32, U256]: + """Convert int-based storage into the internal MPT key/value types.""" + return {_slot(slot): U256(value) for slot, value in storage.items()} + + +def _collect_storage_node_set( + storage: Mapping[int, int], + slots: Sequence[int], +) -> set[bytes]: + """Collect hashed witness nodes for the given storage proof paths.""" + storage_mpt = build_mpt( + _storage_mpt_input(storage), secured=True, default=U256(0) + ) + for slot in slots: + mpt_get(storage_mpt, _slot(slot)) + return set(storage_mpt.witness.accessed_nodes.values()) + + +def _sorted_nodes(nodes: set[bytes]) -> list[Bytes]: + """Return nodes as sorted execution-testing bytes.""" + return [Bytes(node) for node in sorted(nodes)] + + +def collect_storage_proof_nodes( + storage: Mapping[int, int], + slots: Sequence[int], +) -> list[Bytes]: + """Collect the pre-state proof nodes for the given storage slots.""" + return _sorted_nodes(_collect_storage_node_set(storage, slots)) + + +def collect_storage_delete_auxiliary_nodes( + storage: Mapping[int, int], + slot_to_delete: int, +) -> list[Bytes]: + """Collect nodes added only because a delete compressed the trie.""" + storage_mpt = build_mpt( + _storage_mpt_input(storage), secured=True, default=U256(0) + ) + mpt_get(storage_mpt, _slot(slot_to_delete)) + before = set(storage_mpt.witness.accessed_nodes.values()) + mpt_set(storage_mpt, _slot(slot_to_delete), U256(0)) + after = set(storage_mpt.witness.accessed_nodes.values()) + return _sorted_nodes(after - before) + + +def collect_storage_path_only_nodes( + storage: Mapping[int, int], + slot: int, + relative_to_slots: Sequence[int], +) -> list[Bytes]: + """Collect nodes unique to one proof path relative to others.""" + slot_nodes = _collect_storage_node_set(storage, [slot]) + reference_nodes = _collect_storage_node_set(storage, relative_to_slots) + return _sorted_nodes(slot_nodes - reference_nodes) From 96e4aef9af94cb6a95b8f02a3876d7e94b9ea565 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 16:24:27 -0300 Subject: [PATCH 106/265] lints --- .../testing/src/execution_testing/specs/blockchain.py | 6 +++--- .../test_types/execution_witness/expectations.py | 3 ++- .../test_types/tests/test_execution_witness.py | 8 ++++---- tests/amsterdam/eip8025_optional_proofs/state_helpers.py | 7 +++++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 6f89f3fbe67..5710e086b6a 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -807,17 +807,17 @@ def generate_block_data( execution_witness = t8n_witness state_expectation = block.expected_execution_witness_state if state_expectation is not None and execution_witness is not None: - state_expectation.verify_against(t8n_witness) + state_expectation.verify_against(execution_witness) execution_witness = state_expectation.modify_if_invalid_test( execution_witness ) if ( block.expected_execution_witness_codes is not None - and t8n_witness is not None + and execution_witness is not None ): block.expected_execution_witness_codes.verify_against( - t8n_witness + execution_witness ) execution_witness = ( block.expected_execution_witness_codes.modify_if_invalid_test( diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 966935021e6..a1cad276d45 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -254,7 +254,8 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: dupes.append(node) seen.add(node) raise ExecutionWitnessValidationError( - f"Witness state contains duplicates: {[n.hex() for n in dupes]}" + "Witness state contains duplicates: " + f"{[n.hex() for n in dupes]}" ) if actual_nodes != sorted(actual_nodes): diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py index 9aa5377cd95..120a3212f7b 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -41,7 +41,9 @@ def test_execution_witness_state_expectation_rejects_duplicates() -> None: ExecutionWitnessStateExpectation().verify_against(witness) -def test_execution_witness_state_expectation_rejects_unsorted_entries() -> None: +def test_execution_witness_state_expectation_rejects_unsorted_entries() -> ( + None +): """Unsorted state entries should fail structural validation.""" witness = ExecutionWitness( state=[Bytes(b"bb"), Bytes(b"aa")], @@ -49,9 +51,7 @@ def test_execution_witness_state_expectation_rejects_unsorted_entries() -> None: headers=[], ) - with pytest.raises( - ExecutionWitnessValidationError, match="not sorted" - ): + with pytest.raises(ExecutionWitnessValidationError, match="not sorted"): ExecutionWitnessStateExpectation().verify_against(witness) diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index e47a578e7bc..bb4b3c6d1a5 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -4,10 +4,13 @@ from ethereum_types.bytes import Bytes32 from ethereum_types.numeric import U256 - from execution_testing import Bytes -from ethereum.forks.amsterdam.incremental_mpt import build_mpt, mpt_get, mpt_set +from ethereum.forks.amsterdam.incremental_mpt import ( + build_mpt, + mpt_get, + mpt_set, +) def large_storage_value(slot: int) -> int: From 9afc49fe7de6225f24a79b6e605364f39a0b69f4 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 17:01:15 -0300 Subject: [PATCH 107/265] simplify and add extra helper --- .../eip8025_optional_proofs/state_helpers.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index bb4b3c6d1a5..ae49571ffd5 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -46,9 +46,9 @@ def _collect_storage_node_set( return set(storage_mpt.witness.accessed_nodes.values()) -def _sorted_nodes(nodes: set[bytes]) -> list[Bytes]: - """Return nodes as sorted execution-testing bytes.""" - return [Bytes(node) for node in sorted(nodes)] +def _nodes(nodes: set[bytes]) -> list[Bytes]: + """Return nodes as execution-testing bytes.""" + return [Bytes(node) for node in nodes] def collect_storage_proof_nodes( @@ -56,7 +56,7 @@ def collect_storage_proof_nodes( slots: Sequence[int], ) -> list[Bytes]: """Collect the pre-state proof nodes for the given storage slots.""" - return _sorted_nodes(_collect_storage_node_set(storage, slots)) + return _nodes(_collect_storage_node_set(storage, slots)) def collect_storage_delete_auxiliary_nodes( @@ -71,7 +71,7 @@ def collect_storage_delete_auxiliary_nodes( before = set(storage_mpt.witness.accessed_nodes.values()) mpt_set(storage_mpt, _slot(slot_to_delete), U256(0)) after = set(storage_mpt.witness.accessed_nodes.values()) - return _sorted_nodes(after - before) + return _nodes(after - before) def collect_storage_path_only_nodes( @@ -82,4 +82,18 @@ def collect_storage_path_only_nodes( """Collect nodes unique to one proof path relative to others.""" slot_nodes = _collect_storage_node_set(storage, [slot]) reference_nodes = _collect_storage_node_set(storage, relative_to_slots) - return _sorted_nodes(slot_nodes - reference_nodes) + return _nodes(slot_nodes - reference_nodes) + + +def collect_storage_post_state_only_nodes( + pre_storage: Mapping[int, int], + post_storage: Mapping[int, int], + slot: int, + pre_state_reference_slots: Sequence[int], +) -> list[Bytes]: + """Collect nodes that appear only on the post-state proof path.""" + post_state_nodes = _collect_storage_node_set(post_storage, [slot]) + pre_state_nodes = _collect_storage_node_set( + pre_storage, pre_state_reference_slots + ) + return _nodes(post_state_nodes - pre_state_nodes) From ed8ce916df8d3785cfbe06ad74edf91ab2236a10 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 12 Mar 2026 17:01:31 -0300 Subject: [PATCH 108/265] zkevm/tests: add execution witness state tests --- .../test_witness_state.py | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py new file mode 100644 index 00000000000..798743da4c5 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py @@ -0,0 +1,351 @@ +"""Witness state collection scenarios.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Op, + Storage, + Transaction, +) + +from .state_helpers import ( + build_large_storage, + collect_storage_delete_auxiliary_nodes, + collect_storage_post_state_only_nodes, + collect_storage_path_only_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_structural_invariants( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A simple transfer is enough to validate the shared state invariants. + + The expectation object always checks for duplicate entries and sorted + order even when no explicit state nodes are listed. + """ + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation() + ), + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) + + +def test_witness_state_sload_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """SLOAD should include the pre-state proof for the loaded slot.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_sstore_without_explicit_read_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Dirty storage writes should still include the pre-state proof.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + new_value = large_storage_value(9) + contract = pre.deploy_contract( + code=Op.SSTORE(1, new_value) + Op.STOP, + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={1: new_value}), + }, + ) + + +def test_witness_state_sstore_delete_branch_collapse_adds_auxiliary_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting slot 1 from the `{1, 2}` trie shape forces branch collapse. + + The witness should contain both the normal proof for slot 1 and the + auxiliary node needed to preserve the untouched sibling subtree. + """ + storage = build_large_storage([1, 2]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert proof_nodes + # Double check the auxiliary node after the deletion resulted in + # exactly one MPT node. + assert len(auxiliary_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes + auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2]}), + }, + ) + + +def test_witness_state_sstore_delete_without_collapse_omits_sibling_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting slot 1 from the `{1, 2, 3}` trie shape does not collapse. + + The untouched sibling path should remain absent from the witness. + """ + storage = build_large_storage([1, 2, 3]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + sibling_only_nodes = collect_storage_path_only_nodes(storage, 2, [1]) + assert proof_nodes + assert not auxiliary_nodes + assert sibling_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=sibling_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2], 3: storage[3]}), + }, + ) + + +def test_witness_state_reverted_sload_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A reverted SLOAD should still leave its proof nodes in witness state.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.REVERT(0, 0), + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_delete_with_new_dirty_sibling_omits_post_state_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A sibling created before branch collapse is dirty and should not leak. + + The witness still needs the pre-state delete proof for slot 1 and the + pre-state absence proof for slot 2, but it must not include the node + created only after slot 2 is inserted during execution. + """ + pre_storage = build_large_storage([1]) + post_storage = {2: large_storage_value(2)} + proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=2, + pre_state_reference_slots=[1, 2], + ) + assert proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, + storage=Storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_delete_with_modified_dirty_sibling_omits_post_state_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A pre-state sibling that becomes dirty before collapse should not leak. + + The delete still requires the pre-state proof material, but the updated + surviving child is dirty and must not be re-recorded as auxiliary. + """ + pre_storage = build_large_storage([1, 2]) + post_storage = {2: large_storage_value(9)} + proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=2, + pre_state_reference_slots=[1, 2], + ) + assert proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, + storage=Storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) From e768151ff508713e47ec1015b3c6ff5c46a03fd0 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 13 Mar 2026 16:20:17 +0000 Subject: [PATCH 109/265] add witnesses for invalid blocks --- .../src/execution_testing/fixtures/blockchain.py | 12 +++++++++++- .../src/execution_testing/specs/blockchain.py | 3 +++ src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 8 ++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 9dbc6926ed5..0f12aafb754 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -666,7 +666,14 @@ class FixtureBlock(FixtureBlockBase): def without_rlp(self) -> FixtureBlockBase: """Return FixtureBlockBase without the RLP bytes set.""" return FixtureBlockBase( - **self.model_dump(exclude={"rlp"}), + **self.model_dump( + exclude={ + "rlp", + "execution_witness", + "stateless_input_bytes", + "stateless_output_bytes", + }, + ), ) @@ -686,6 +693,9 @@ class InvalidFixtureBlock(CamelModel): rlp: Bytes expect_exception: ExceptionInstanceOrList rlp_decoded: FixtureBlockBase | None = Field(None, alias="rlp_decoded") + execution_witness: ExecutionWitness | None = None + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None @post_state_validator() diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index eff201e79d9..fcd5e49a278 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -458,6 +458,9 @@ def get_fixture_block( in self.expected_exception else fixture_block.without_rlp() ), + execution_witness=self.execution_witness, + stateless_input_bytes=self.stateless_input_bytes, + stateless_output_bytes=self.stateless_output_bytes, ) return fixture_block diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index cfbcec2f539..ac61f5145d8 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -332,11 +332,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # the witness reads pre-state tries that apply_changes mutates. # This is safe because compute_state_root_and_trie_changes # does not mutate state (it makes transient copies of MPTs). - if ( - t8n.fork.has_execution_witness - and not self.block_exception - and t8n.env.block_headers - ): + if t8n.fork.has_execution_witness: self.execution_witness = t8n.fork.build_execution_witness( block_env.state, expected_post_state_root=state_root_value, @@ -372,7 +368,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_output.block_access_list ) - if self.execution_witness is not None: + if self.execution_witness is not None and not t8n.options.state_test: withdrawals = ( tuple(t8n.env.withdrawals) if t8n.env.withdrawals else () ) From 6283b9faa99160670b98e235fc72b847cfa7196f Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Fri, 13 Mar 2026 13:38:01 -0300 Subject: [PATCH 110/265] add more state tests and fix spec bug Signed-off-by: Ignacio Hagopian --- src/ethereum/forks/amsterdam/witness_state.py | 9 +- .../test_witness_state.py | 153 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index de1b1da4e55..a766030c5a6 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -264,8 +264,15 @@ def compute_state_root_and_trie_changes( secured=True, default=U256(0), ) + # Mirror host-side witness construction: replay insert/update + # writes first, then deletions, to avoid transient branch + # compressions that would incorrectly require extra witness nodes. for key, value in slots.items(): - mpt_set(storage_mpt, key, value) + if value != 0: + mpt_set(storage_mpt, key, value) + for key, value in slots.items(): + if value == 0: + mpt_set(storage_mpt, key, value) new_storage_roots[address] = mpt_root(storage_mpt) state_mpt: IncrementalMPT[Address, Optional[Account]] = ( diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py index 798743da4c5..5ac66f3cbf1 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py @@ -1,17 +1,21 @@ """Witness state collection scenarios.""" import pytest +from ethereum_types.bytes import Bytes32 from execution_testing import ( Account, Alloc, Block, BlockchainTestFiller, + Conditional, ExecutionWitnessStateExpectation, Op, Storage, Transaction, ) +from ethereum.crypto.hash import keccak256 + from .state_helpers import ( build_large_storage, collect_storage_delete_auxiliary_nodes, @@ -27,6 +31,11 @@ REFERENCE_SPEC_VERSION = "N/A" +def _secured_storage_key(slot: int) -> bytes: + """Return the secured trie key used for a storage slot.""" + return keccak256(Bytes32(slot.to_bytes(32, byteorder="big"))) + + def test_witness_state_structural_invariants( pre: Alloc, blockchain_test: BlockchainTestFiller, @@ -349,3 +358,147 @@ def test_witness_state_delete_with_modified_dirty_sibling_omits_post_state_node( contract: Account(storage=post_storage), }, ) + + +def test_witness_state_delete_then_insert_uses_insert_before_delete_order( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Post-state storage replay must insert before deleting. + + Deleting slot 1 from `{1, 2}` would normally require the clean sibling + auxiliary node for slot 2. Inserting slot 3 first avoids that collapse. + """ + delete_slot = 1 + preserved_slot = 2 + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + pre_storage = build_large_storage([delete_slot, preserved_slot]) + post_storage = { + preserved_slot: pre_storage[preserved_slot], + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes( + pre_storage, [delete_slot, insert_slot] + ) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes( + pre_storage, delete_slot + ) + assert proof_nodes + assert len(auxiliary_nodes) == 1 + assert not set(proof_nodes) & set(auxiliary_nodes) + # This slot choice also catches clients that sort secured trie keys: + # slot 1 sorts before slot 3, so a buggy key-sorted replay still + # deletes before it inserts. + assert _secured_storage_key(delete_slot) < _secured_storage_key( + insert_slot + ) + + contract = pre.deploy_contract( + code=Op.SSTORE(delete_slot, 0) + + Op.SSTORE(insert_slot, insert_value) + + Op.STOP, + storage=Storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_block_diff_delete_then_insert_uses_insert_before_delete_order( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + The same insert-before-delete rule must hold across transactions. + + The block-level diff records slot 1 before slot 3 because the delete + transaction executes first, but witness-based post-state replay must + still insert slot 3 before deleting slot 1. + """ + delete_slot = 1 + preserved_slot = 2 + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + pre_storage = build_large_storage([delete_slot, preserved_slot]) + post_storage = { + preserved_slot: pre_storage[preserved_slot], + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes( + pre_storage, [delete_slot, insert_slot] + ) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes( + pre_storage, delete_slot + ) + assert proof_nodes + assert len(auxiliary_nodes) == 1 + assert not set(proof_nodes) & set(auxiliary_nodes) + # This slot choice also catches clients that sort secured trie keys: + # slot 1 sorts before slot 3, so a buggy key-sorted replay still + # deletes before it inserts. + assert _secured_storage_key(delete_slot) < _secured_storage_key( + insert_slot + ) + + contract = pre.deploy_contract( + code=Conditional( + condition=Op.EQ(Op.CALLDATALOAD(0), 0), + if_true=Op.SSTORE(delete_slot, 0), + if_false=Op.SSTORE(insert_slot, insert_value), + ) + + Op.STOP, + storage=Storage(pre_storage), + ) + sender = pre.fund_eoa() + tx_delete = Transaction( + sender=sender, + to=contract, + gas_limit=500_000, + nonce=0, + ) + tx_insert = Transaction( + sender=sender, + to=contract, + gas_limit=500_000, + data=(1).to_bytes(32, byteorder="big"), + nonce=1, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx_delete, tx_insert], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=2), + contract: Account(storage=post_storage), + }, + ) From d905f4c60cddbe537bab7a4f518ef0817de10521 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 15:47:46 -0300 Subject: [PATCH 111/265] add more tests --- .../test_witness_state.py | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py index 5ac66f3cbf1..1d09543643f 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py @@ -261,6 +261,231 @@ def test_witness_state_reverted_sload_still_contains_storage_proof( ) +def test_witness_state_sload_absent_slot_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + SLOAD of an absent slot should include the pre-state absence proof. + + Use a multi-slot trie so the absent-slot path is meaningfully different + from the single-leaf proof covered elsewhere. + """ + storage = build_large_storage([1, 2]) + absent_slot = 3 + proof_nodes = collect_storage_proof_nodes(storage, [absent_slot]) + existing_slot_only_nodes = collect_storage_path_only_nodes( + storage, 1, [absent_slot] + ) + assert proof_nodes + assert existing_slot_only_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(absent_slot) + Op.POP + Op.STOP, + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=existing_slot_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_reverted_sstore_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A reverted SSTORE should still leave its proof nodes in witness state.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + new_value = large_storage_value(9) + contract = pre.deploy_contract( + code=Op.SSTORE(1, new_value) + Op.REVERT(0, 0), + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_sstore_new_slot_omits_post_state_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Inserting a new slot should include the pre-state absence proof only. + + Nodes created solely by the post-state insertion must not leak into the + witness. + """ + pre_storage = build_large_storage([1, 2]) + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + post_storage = { + **pre_storage, + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=insert_slot, + pre_state_reference_slots=[insert_slot], + ) + assert proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=Storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Empty pre-state storage should not require any storage proof nodes. + + The nodes created solely by the insertion are post-state material and + must not appear in the witness. + """ + insert_slot = 1 + insert_value = large_storage_value(insert_slot) + pre_storage: dict[int, int] = {} + post_storage = {insert_slot: insert_value} + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=insert_slot, + pre_state_reference_slots=[insert_slot], + ) + assert not proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=Storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_sstore_delete_last_slot_has_no_auxiliary_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting the last slot should empty the trie without auxiliary nodes. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert proof_nodes + assert not auxiliary_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=Storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={}), + }, + ) + + def test_witness_state_delete_with_new_dirty_sibling_omits_post_state_node( pre: Alloc, blockchain_test: BlockchainTestFiller, From b95ec3bf6354316f122196fe81f09e13ccdbd03d Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 16:07:04 -0300 Subject: [PATCH 112/265] zkevm/tests: add state test invariants enforced by the expectation component --- .../test_witness_state_invariants.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py new file mode 100644 index 00000000000..cf1fd84d06f --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py @@ -0,0 +1,47 @@ +"""Witness state collection scenarios for structural invariants.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_structural_invariants( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A simple transfer is enough to validate the shared state invariants. + + The expectation object always checks for duplicate entries and sorted + order even when no explicit state nodes are listed. + """ + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation() + ), + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) From 8e691498f664d1868990c059516dde9683d72e17 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 16:07:14 -0300 Subject: [PATCH 113/265] cleanup --- .../test_witness_state.py | 729 ------------------ 1 file changed, 729 deletions(-) delete mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py deleted file mode 100644 index 1d09543643f..00000000000 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state.py +++ /dev/null @@ -1,729 +0,0 @@ -"""Witness state collection scenarios.""" - -import pytest -from ethereum_types.bytes import Bytes32 -from execution_testing import ( - Account, - Alloc, - Block, - BlockchainTestFiller, - Conditional, - ExecutionWitnessStateExpectation, - Op, - Storage, - Transaction, -) - -from ethereum.crypto.hash import keccak256 - -from .state_helpers import ( - build_large_storage, - collect_storage_delete_auxiliary_nodes, - collect_storage_post_state_only_nodes, - collect_storage_path_only_nodes, - collect_storage_proof_nodes, - large_storage_value, -) - -pytestmark = pytest.mark.valid_from("Amsterdam") - -REFERENCE_SPEC_GIT_PATH = "N/A" -REFERENCE_SPEC_VERSION = "N/A" - - -def _secured_storage_key(slot: int) -> bytes: - """Return the secured trie key used for a storage slot.""" - return keccak256(Bytes32(slot.to_bytes(32, byteorder="big"))) - - -def test_witness_state_structural_invariants( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - A simple transfer is enough to validate the shared state invariants. - - The expectation object always checks for duplicate entries and sorted - order even when no explicit state nodes are listed. - """ - sender = pre.fund_eoa() - recipient = pre.fund_eoa(amount=0) - tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation() - ), - ) - ], - post={ - sender: Account(nonce=1), - recipient: Account(balance=1), - }, - ) - - -def test_witness_state_sload_contains_storage_proof( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """SLOAD should include the pre-state proof for the loaded slot.""" - storage = build_large_storage([1]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - assert proof_nodes - - contract = pre.deploy_contract( - code=Op.SLOAD(1) + Op.POP + Op.STOP, - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=storage), - }, - ) - - -def test_witness_state_sstore_without_explicit_read_contains_storage_proof( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """Dirty storage writes should still include the pre-state proof.""" - storage = build_large_storage([1]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - assert proof_nodes - - new_value = large_storage_value(9) - contract = pre.deploy_contract( - code=Op.SSTORE(1, new_value) + Op.STOP, - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage={1: new_value}), - }, - ) - - -def test_witness_state_sstore_delete_branch_collapse_adds_auxiliary_node( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Deleting slot 1 from the `{1, 2}` trie shape forces branch collapse. - - The witness should contain both the normal proof for slot 1 and the - auxiliary node needed to preserve the untouched sibling subtree. - """ - storage = build_large_storage([1, 2]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) - assert proof_nodes - # Double check the auxiliary node after the deletion resulted in - # exactly one MPT node. - assert len(auxiliary_nodes) == 1 - - contract = pre.deploy_contract( - code=Op.SSTORE(1, 0) + Op.STOP, - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes + auxiliary_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage={2: storage[2]}), - }, - ) - - -def test_witness_state_sstore_delete_without_collapse_omits_sibling_nodes( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Deleting slot 1 from the `{1, 2, 3}` trie shape does not collapse. - - The untouched sibling path should remain absent from the witness. - """ - storage = build_large_storage([1, 2, 3]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) - sibling_only_nodes = collect_storage_path_only_nodes(storage, 2, [1]) - assert proof_nodes - assert not auxiliary_nodes - assert sibling_only_nodes - - contract = pre.deploy_contract( - code=Op.SSTORE(1, 0) + Op.STOP, - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=sibling_only_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage={2: storage[2], 3: storage[3]}), - }, - ) - - -def test_witness_state_reverted_sload_still_contains_storage_proof( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """A reverted SLOAD should still leave its proof nodes in witness state.""" - storage = build_large_storage([1]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - assert proof_nodes - - contract = pre.deploy_contract( - code=Op.SLOAD(1) + Op.POP + Op.REVERT(0, 0), - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=storage), - }, - ) - - -def test_witness_state_sload_absent_slot_contains_storage_proof( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - SLOAD of an absent slot should include the pre-state absence proof. - - Use a multi-slot trie so the absent-slot path is meaningfully different - from the single-leaf proof covered elsewhere. - """ - storage = build_large_storage([1, 2]) - absent_slot = 3 - proof_nodes = collect_storage_proof_nodes(storage, [absent_slot]) - existing_slot_only_nodes = collect_storage_path_only_nodes( - storage, 1, [absent_slot] - ) - assert proof_nodes - assert existing_slot_only_nodes - - contract = pre.deploy_contract( - code=Op.SLOAD(absent_slot) + Op.POP + Op.STOP, - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=existing_slot_only_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=storage), - }, - ) - - -def test_witness_state_reverted_sstore_still_contains_storage_proof( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """A reverted SSTORE should still leave its proof nodes in witness state.""" - storage = build_large_storage([1]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - assert proof_nodes - - new_value = large_storage_value(9) - contract = pre.deploy_contract( - code=Op.SSTORE(1, new_value) + Op.REVERT(0, 0), - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=storage), - }, - ) - - -def test_witness_state_sstore_new_slot_omits_post_state_nodes( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Inserting a new slot should include the pre-state absence proof only. - - Nodes created solely by the post-state insertion must not leak into the - witness. - """ - pre_storage = build_large_storage([1, 2]) - insert_slot = 3 - insert_value = large_storage_value(insert_slot) - post_storage = { - **pre_storage, - insert_slot: insert_value, - } - proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) - post_state_only_nodes = collect_storage_post_state_only_nodes( - pre_storage=pre_storage, - post_storage=post_storage, - slot=insert_slot, - pre_state_reference_slots=[insert_slot], - ) - assert proof_nodes - assert post_state_only_nodes - - contract = pre.deploy_contract( - code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, - storage=Storage(pre_storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=post_state_only_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=post_storage), - }, - ) - - -def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Empty pre-state storage should not require any storage proof nodes. - - The nodes created solely by the insertion are post-state material and - must not appear in the witness. - """ - insert_slot = 1 - insert_value = large_storage_value(insert_slot) - pre_storage: dict[int, int] = {} - post_storage = {insert_slot: insert_value} - proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) - post_state_only_nodes = collect_storage_post_state_only_nodes( - pre_storage=pre_storage, - post_storage=post_storage, - slot=insert_slot, - pre_state_reference_slots=[insert_slot], - ) - assert not proof_nodes - assert post_state_only_nodes - - contract = pre.deploy_contract( - code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, - storage=Storage(pre_storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_absent=post_state_only_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=post_storage), - }, - ) - - -def test_witness_state_sstore_delete_last_slot_has_no_auxiliary_nodes( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Deleting the last slot should empty the trie without auxiliary nodes. - """ - storage = build_large_storage([1]) - proof_nodes = collect_storage_proof_nodes(storage, [1]) - auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) - assert proof_nodes - assert not auxiliary_nodes - - contract = pre.deploy_contract( - code=Op.SSTORE(1, 0) + Op.STOP, - storage=Storage(storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage={}), - }, - ) - - -def test_witness_state_delete_with_new_dirty_sibling_omits_post_state_node( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - A sibling created before branch collapse is dirty and should not leak. - - The witness still needs the pre-state delete proof for slot 1 and the - pre-state absence proof for slot 2, but it must not include the node - created only after slot 2 is inserted during execution. - """ - pre_storage = build_large_storage([1]) - post_storage = {2: large_storage_value(2)} - proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) - post_state_only_nodes = collect_storage_post_state_only_nodes( - pre_storage=pre_storage, - post_storage=post_storage, - slot=2, - pre_state_reference_slots=[1, 2], - ) - assert proof_nodes - assert post_state_only_nodes - - contract = pre.deploy_contract( - code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, - storage=Storage(pre_storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=post_state_only_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=post_storage), - }, - ) - - -def test_witness_state_delete_with_modified_dirty_sibling_omits_post_state_node( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - A pre-state sibling that becomes dirty before collapse should not leak. - - The delete still requires the pre-state proof material, but the updated - surviving child is dirty and must not be re-recorded as auxiliary. - """ - pre_storage = build_large_storage([1, 2]) - post_storage = {2: large_storage_value(9)} - proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) - post_state_only_nodes = collect_storage_post_state_only_nodes( - pre_storage=pre_storage, - post_storage=post_storage, - slot=2, - pre_state_reference_slots=[1, 2], - ) - assert proof_nodes - assert post_state_only_nodes - - contract = pre.deploy_contract( - code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, - storage=Storage(pre_storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=post_state_only_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=post_storage), - }, - ) - - -def test_witness_state_delete_then_insert_uses_insert_before_delete_order( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Post-state storage replay must insert before deleting. - - Deleting slot 1 from `{1, 2}` would normally require the clean sibling - auxiliary node for slot 2. Inserting slot 3 first avoids that collapse. - """ - delete_slot = 1 - preserved_slot = 2 - insert_slot = 3 - insert_value = large_storage_value(insert_slot) - pre_storage = build_large_storage([delete_slot, preserved_slot]) - post_storage = { - preserved_slot: pre_storage[preserved_slot], - insert_slot: insert_value, - } - proof_nodes = collect_storage_proof_nodes( - pre_storage, [delete_slot, insert_slot] - ) - auxiliary_nodes = collect_storage_delete_auxiliary_nodes( - pre_storage, delete_slot - ) - assert proof_nodes - assert len(auxiliary_nodes) == 1 - assert not set(proof_nodes) & set(auxiliary_nodes) - # This slot choice also catches clients that sort secured trie keys: - # slot 1 sorts before slot 3, so a buggy key-sorted replay still - # deletes before it inserts. - assert _secured_storage_key(delete_slot) < _secured_storage_key( - insert_slot - ) - - contract = pre.deploy_contract( - code=Op.SSTORE(delete_slot, 0) - + Op.SSTORE(insert_slot, insert_value) - + Op.STOP, - storage=Storage(pre_storage), - ) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=auxiliary_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=post_storage), - }, - ) - - -def test_witness_state_block_diff_delete_then_insert_uses_insert_before_delete_order( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - The same insert-before-delete rule must hold across transactions. - - The block-level diff records slot 1 before slot 3 because the delete - transaction executes first, but witness-based post-state replay must - still insert slot 3 before deleting slot 1. - """ - delete_slot = 1 - preserved_slot = 2 - insert_slot = 3 - insert_value = large_storage_value(insert_slot) - pre_storage = build_large_storage([delete_slot, preserved_slot]) - post_storage = { - preserved_slot: pre_storage[preserved_slot], - insert_slot: insert_value, - } - proof_nodes = collect_storage_proof_nodes( - pre_storage, [delete_slot, insert_slot] - ) - auxiliary_nodes = collect_storage_delete_auxiliary_nodes( - pre_storage, delete_slot - ) - assert proof_nodes - assert len(auxiliary_nodes) == 1 - assert not set(proof_nodes) & set(auxiliary_nodes) - # This slot choice also catches clients that sort secured trie keys: - # slot 1 sorts before slot 3, so a buggy key-sorted replay still - # deletes before it inserts. - assert _secured_storage_key(delete_slot) < _secured_storage_key( - insert_slot - ) - - contract = pre.deploy_contract( - code=Conditional( - condition=Op.EQ(Op.CALLDATALOAD(0), 0), - if_true=Op.SSTORE(delete_slot, 0), - if_false=Op.SSTORE(insert_slot, insert_value), - ) - + Op.STOP, - storage=Storage(pre_storage), - ) - sender = pre.fund_eoa() - tx_delete = Transaction( - sender=sender, - to=contract, - gas_limit=500_000, - nonce=0, - ) - tx_insert = Transaction( - sender=sender, - to=contract, - gas_limit=500_000, - data=(1).to_bytes(32, byteorder="big"), - nonce=1, - ) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx_delete, tx_insert], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=proof_nodes, - nodes_absent=auxiliary_nodes, - ) - ), - ) - ], - post={ - sender: Account(nonce=2), - contract: Account(storage=post_storage), - }, - ) From 50fd066d3cc0e53b87748eef4a08856d48dd70e9 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 16:19:39 -0300 Subject: [PATCH 114/265] add helper --- tests/amsterdam/eip8025_optional_proofs/state_helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index ae49571ffd5..da4877cfe5b 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -4,7 +4,7 @@ from ethereum_types.bytes import Bytes32 from ethereum_types.numeric import U256 -from execution_testing import Bytes +from execution_testing import Bytes, Storage from ethereum.forks.amsterdam.incremental_mpt import ( build_mpt, @@ -23,6 +23,11 @@ def build_large_storage(slots: Sequence[int]) -> dict[int, int]: return {slot: large_storage_value(slot) for slot in slots} +def as_storage(storage: Mapping[int, int]) -> Storage: + """Convert an int-keyed storage mapping into a `Storage`.""" + return Storage.model_validate(dict(storage.items())) + + def _slot(slot: int) -> Bytes32: """Convert an integer slot into a 32-byte storage key.""" return Bytes32(slot.to_bytes(32, byteorder="big")) From f8cd25db4ea226ec4eafe19b8c60edd8b5ee71f1 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 16:35:47 -0300 Subject: [PATCH 115/265] zkevm/tests: add state coverage for mpt branch compresion --- .../test_witness_state_deletes.py | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py new file mode 100644 index 00000000000..eeddcbd7923 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py @@ -0,0 +1,255 @@ +"""Witness state collection scenarios for storage deletes.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_delete_auxiliary_nodes, + collect_storage_path_only_nodes, + collect_storage_post_state_only_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_sstore_delete_branch_collapse_adds_auxiliary_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting slot 1 from the `{1, 2}` trie shape forces branch collapse. + + The witness should contain both the normal proof for slot 1 and the + auxiliary node needed to preserve the untouched sibling subtree. + """ + storage = build_large_storage([1, 2]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert proof_nodes + # Double check the auxiliary node after the deletion resulted in + # exactly one MPT node. + assert len(auxiliary_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes + auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2]}), + }, + ) + + +def test_witness_state_sstore_delete_without_collapse_omits_sibling_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting slot 1 from the `{1, 2, 3}` trie shape does not collapse. + + The untouched sibling paths should remain absent from the witness. + """ + storage = build_large_storage([1, 2, 3]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + slot_2_only_nodes = collect_storage_path_only_nodes(storage, 2, [1]) + slot_3_only_nodes = collect_storage_path_only_nodes(storage, 3, [1]) + assert proof_nodes + assert not auxiliary_nodes + assert slot_2_only_nodes + assert slot_3_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=slot_2_only_nodes + slot_3_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2], 3: storage[3]}), + }, + ) + + +def test_witness_state_sstore_delete_only_slot_contains_proof_and_empties_storage( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Deleting the only slot should keep its pre-state proof and empty storage. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert proof_nodes + assert not auxiliary_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={}), + }, + ) + + +def test_witness_state_delete_with_new_dirty_sibling_omits_post_state_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A sibling created before branch collapse is dirty and should not leak. + + The witness still needs the pre-state delete proof for slot 1 and the + pre-state absence proof for slot 2, but it must not include the node + created only after slot 2 is inserted during execution. + """ + pre_storage = build_large_storage([1]) + post_storage = {2: large_storage_value(2)} + proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=2, + pre_state_reference_slots=[1, 2], + ) + assert proof_nodes + assert len(post_state_only_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_delete_with_modified_dirty_sibling_omits_post( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A pre-state sibling that becomes dirty before collapse should not leak. + + The delete still requires the pre-state proof material, but the updated + surviving child is dirty and must not be re-recorded as auxiliary. + """ + pre_storage = build_large_storage([1, 2]) + post_storage = {2: large_storage_value(9)} + proof_nodes = collect_storage_proof_nodes(pre_storage, [1, 2]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=2, + pre_state_reference_slots=[1, 2], + ) + assert proof_nodes + assert len(post_state_only_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SSTORE(2, post_storage[2]) + Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) From 0e4455c55e96b714bcc69b7cb421fdae2a225f55 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 16:45:57 -0300 Subject: [PATCH 116/265] zkevm/tests: add state coverage tests for read operations --- .../test_witness_state_reads.py | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py new file mode 100644 index 00000000000..44191003152 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py @@ -0,0 +1,145 @@ +"""Witness state collection scenarios for storage reads.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_path_only_nodes, + collect_storage_proof_nodes, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_sload_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """SLOAD should include the pre-state proof for the loaded slot.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_reverted_sload_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A reverted SLOAD should still leave its proof nodes in witness state.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.REVERT(0, 0), + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_sload_absent_slot_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + SLOAD of an absent slot should include the pre-state absence proof. + + Use a multi-slot trie so the absent-slot path is meaningfully different + from a single-leaf root. + """ + storage = build_large_storage([1, 2]) + absent_slot = 3 + proof_nodes = collect_storage_proof_nodes(storage, [absent_slot]) + slot_1_only_nodes = collect_storage_path_only_nodes( + storage, 1, [absent_slot] + ) + slot_2_only_nodes = collect_storage_path_only_nodes( + storage, 2, [absent_slot] + ) + existing_slot_only_nodes = slot_1_only_nodes + slot_2_only_nodes + assert proof_nodes + assert len(slot_1_only_nodes) == 1 + assert len(slot_2_only_nodes) == 1 + + contract = pre.deploy_contract( + code=Op.SLOAD(absent_slot) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=existing_slot_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) From 970ae9039919147b7a0ac12bd61e7b62d2a974de Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 16:58:13 -0300 Subject: [PATCH 117/265] zkevm/tests: add state coverage for testing the correct post-state root calculation reg insertion+updating then deletions --- .../test_witness_state_replay_order.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py new file mode 100644 index 00000000000..a628abfd33b --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py @@ -0,0 +1,185 @@ +"""Witness state collection scenarios for storage replay ordering.""" + +import pytest +from ethereum_types.bytes import Bytes32 +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Conditional, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from ethereum.crypto.hash import keccak256 + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_delete_auxiliary_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def _secured_storage_key(slot: int) -> bytes: + """Return the secured trie key used for a storage slot.""" + return keccak256(Bytes32(slot.to_bytes(32, byteorder="big"))) + + +def test_witness_state_delete_then_insert_uses_insert_before_delete_order( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + EL post-state root calculation must insert before deleting. + + Deleting slot 1 from `{1, 2}` would normally require the clean sibling + auxiliary node for slot 2. Computing the correct post-state root avoids + that collapse by replaying the slot 3 insertion before the slot 1 + deletion. The chosen slots also catch buggy key-sorted replay: slot 1's + secured trie key sorts before slot 3's, so sorting still deletes too + early. + """ + delete_slot = 1 + preserved_slot = 2 + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + pre_storage = build_large_storage([delete_slot, preserved_slot]) + post_storage = { + preserved_slot: pre_storage[preserved_slot], + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes( + pre_storage, [delete_slot, insert_slot] + ) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes( + pre_storage, delete_slot + ) + assert proof_nodes + assert len(auxiliary_nodes) == 1 + assert not set(proof_nodes) & set(auxiliary_nodes) + # This slot choice also catches clients that sort secured trie keys: + # slot 1 sorts before slot 3, so a buggy key-sorted replay still + # deletes before it inserts. + assert _secured_storage_key(delete_slot) < _secured_storage_key( + insert_slot + ) + + contract = pre.deploy_contract( + code=Op.SSTORE(delete_slot, 0) + + Op.SSTORE(insert_slot, insert_value) + + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_block_diff_delete_insert_before_delete_order( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + This reproduces the same post-state-root ordering case as the + single-transaction test, but across transactions. + + The block-level diff records slot 1 before slot 3 because the delete + transaction executes first. Even so, EL post-state root calculation + must still insert slot 3 before deleting slot 1. The chosen slots also + catch buggy key-sorted replay because slot 1's secured trie key sorts + before slot 3's. + """ + delete_slot = 1 + preserved_slot = 2 + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + pre_storage = build_large_storage([delete_slot, preserved_slot]) + post_storage = { + preserved_slot: pre_storage[preserved_slot], + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes( + pre_storage, [delete_slot, insert_slot] + ) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes( + pre_storage, delete_slot + ) + assert proof_nodes + assert len(auxiliary_nodes) == 1 + assert not set(proof_nodes) & set(auxiliary_nodes) + # This slot choice also catches clients that sort secured trie keys: + # slot 1 sorts before slot 3, so a buggy key-sorted replay still + # deletes before it inserts. + assert _secured_storage_key(delete_slot) < _secured_storage_key( + insert_slot + ) + + contract = pre.deploy_contract( + code=Conditional( + condition=Op.EQ(Op.CALLDATALOAD(0), 0), + if_true=Op.SSTORE(delete_slot, 0), + if_false=Op.SSTORE(insert_slot, insert_value), + ) + + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx_delete = Transaction( + sender=sender, + to=contract, + gas_limit=500_000, + nonce=0, + ) + tx_insert = Transaction( + sender=sender, + to=contract, + gas_limit=500_000, + data=(1).to_bytes(32, byteorder="big"), + nonce=1, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx_delete, tx_insert], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=auxiliary_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=2), + contract: Account(storage=post_storage), + }, + ) From f831eee3b83eb6e12acf2a9854b4f3cdd6a50f6a Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 17:00:42 -0300 Subject: [PATCH 118/265] zkevm/tests: add state coverage for writes in the tries --- .../test_witness_state_writes.py | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py new file mode 100644 index 00000000000..35bba6e4549 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py @@ -0,0 +1,203 @@ +"""Witness state collection scenarios for storage writes.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_post_state_only_nodes, + collect_storage_proof_nodes, + large_storage_value, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_state_sstore_without_explicit_read_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Dirty storage writes should still include the pre-state proof.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + new_value = large_storage_value(9) + contract = pre.deploy_contract( + code=Op.SSTORE(1, new_value) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={1: new_value}), + }, + ) + + +def test_witness_state_reverted_sstore_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A reverted SSTORE should still leave its proof nodes in witness. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + new_value = large_storage_value(9) + contract = pre.deploy_contract( + code=Op.SSTORE(1, new_value) + Op.REVERT(0, 0), + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_witness_state_sstore_new_slot_omits_post_state_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Inserting a new slot should include the pre-state absence proof only. + + Nodes created solely by the post-state insertion must not leak into the + witness. + """ + pre_storage = build_large_storage([1, 2]) + insert_slot = 3 + insert_value = large_storage_value(insert_slot) + post_storage = { + **pre_storage, + insert_slot: insert_value, + } + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=insert_slot, + pre_state_reference_slots=[insert_slot], + ) + assert proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) + + +def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Empty pre-state storage should not require any storage proof nodes. + + The nodes created solely by the insertion are post-state material and + must not appear in the witness. + """ + insert_slot = 1 + insert_value = large_storage_value(insert_slot) + pre_storage: dict[int, int] = {} + post_storage = {insert_slot: insert_value} + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + post_state_only_nodes = collect_storage_post_state_only_nodes( + pre_storage=pre_storage, + post_storage=post_storage, + slot=insert_slot, + pre_state_reference_slots=[insert_slot], + ) + assert not proof_nodes + assert post_state_only_nodes + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_absent=post_state_only_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) From c061ed4886371a025702fd232620e146c758e2e6 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 17:02:05 -0300 Subject: [PATCH 119/265] improve comment Signed-off-by: jsign --- src/ethereum/forks/amsterdam/witness_state.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index a766030c5a6..01d69d49171 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -264,9 +264,8 @@ def compute_state_root_and_trie_changes( secured=True, default=U256(0), ) - # Mirror host-side witness construction: replay insert/update - # writes first, then deletions, to avoid transient branch - # compressions that would incorrectly require extra witness nodes. + # We must do insertions+updates before deletions to minimize branch + # compressions. for key, value in slots.items(): if value != 0: mpt_set(storage_mpt, key, value) From 36e799bba2ccc466a4fac04270754acc315285b2 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 17:04:48 -0300 Subject: [PATCH 120/265] lints --- .../eip8025_optional_proofs/test_witness_state_deletes.py | 2 +- .../eip8025_optional_proofs/test_witness_state_replay_order.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py index eeddcbd7923..a28e7a30945 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_deletes.py @@ -117,7 +117,7 @@ def test_witness_state_sstore_delete_without_collapse_omits_sibling_nodes( ) -def test_witness_state_sstore_delete_only_slot_contains_proof_and_empties_storage( +def test_witness_state_sstore_delete_only_slot_keeps_proof( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py index a628abfd33b..4855e07ebdc 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_replay_order.py @@ -107,7 +107,7 @@ def test_witness_state_block_diff_delete_insert_before_delete_order( blockchain_test: BlockchainTestFiller, ) -> None: """ - This reproduces the same post-state-root ordering case as the + Reproduces the same post-state-root ordering case as the single-transaction test, but across transactions. The block-level diff records slot 1 before slot 3 because the delete From 546064646d1197f891838ea79ccc3cd842a6437c Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 17:12:17 -0300 Subject: [PATCH 121/265] cleanup --- .../execution_witness/expectations.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index a1cad276d45..8a06e939851 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -179,13 +179,6 @@ class ExecutionWitnessStateExpectation(CamelModel): "Encoded trie nodes that must NOT be present in witness state" ), ) - allow_unexpected: bool = Field( - default=True, - description=( - "If False, fail when witness state contains nodes " - "not listed in nodes_present" - ), - ) _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( PrivateAttr(default=None) @@ -235,7 +228,6 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: 1. Structural invariants: no duplicates and sorted order 2. Presence checks: nodes_present entries exist 3. Absence checks: nodes_absent entries do not exist - 4. Exhaustiveness: if allow_unexpected=False, no extra nodes Args: actual_witness: The ExecutionWitness from the t8n tool @@ -279,15 +271,6 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: f"witness state but was found" ) - if not self.allow_unexpected: - expected_set = set(self.nodes_present) - unexpected = actual_set - expected_set - if unexpected: - raise ExecutionWitnessValidationError( - f"Unexpected trie nodes in witness state: " - f"{[n.hex() for n in unexpected]}" - ) - class ExecutionWitnessHeadersExpectation(CamelModel): """ From 71130af055afd52579d1f4eec7a297000b389295 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 17:16:05 -0300 Subject: [PATCH 122/265] clarify a bit better comments --- .../test_types/execution_witness/expectations.py | 12 ++++++++---- .../forks/amsterdam/stateless_host_exec_witness.py | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 8a06e939851..ebb0a59097c 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -95,7 +95,8 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: Verify that the actual witness codes match this expectation. Validation steps: - 1. Structural invariants: no duplicates and sorted order + 1. Structural invariants: no duplicates and lexicographic + ascending order 2. Presence checks: codes_present entries exist 3. Absence checks: codes_absent entries do not exist 4. Exhaustiveness: if allow_unexpected=False, no extra codes @@ -123,7 +124,8 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: if actual_codes != sorted(actual_codes): raise ExecutionWitnessValidationError( - "Witness codes are not sorted in lexicographic order" + "Witness codes are not sorted in lexicographic ascending " + "order" ) actual_set = set(actual_codes) @@ -225,7 +227,8 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: Verify that the actual witness state matches this expectation. Validation steps: - 1. Structural invariants: no duplicates and sorted order + 1. Structural invariants: no duplicates and lexicographic + ascending order 2. Presence checks: nodes_present entries exist 3. Absence checks: nodes_absent entries do not exist @@ -252,7 +255,8 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: if actual_nodes != sorted(actual_nodes): raise ExecutionWitnessValidationError( - "Witness state is not sorted in lexicographic order" + "Witness state is not sorted in lexicographic ascending " + "order" ) actual_set = set(actual_nodes) diff --git a/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py b/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py index 6b43de41bd0..8f7a68bf12a 100644 --- a/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py +++ b/src/ethereum/forks/amsterdam/stateless_host_exec_witness.py @@ -172,8 +172,8 @@ def build_execution_witness( """ Build the execution witness from block state and pre-state trie data. - Sort state and codes lexicographically, headers by block number - ascending. + Sort state and codes in lexicographic ascending order, headers by + block number ascending. """ ancestor_headers = get_witness_ancestors( blockchain_headers if blockchain_headers is not None else [], From d71e4b63235ee6b07edd28d529a8645921d811bc Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 17:23:38 -0300 Subject: [PATCH 123/265] lint --- .../test_types/execution_witness/expectations.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index ebb0a59097c..01323743381 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -124,8 +124,7 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: if actual_codes != sorted(actual_codes): raise ExecutionWitnessValidationError( - "Witness codes are not sorted in lexicographic ascending " - "order" + "Witness codes are not sorted in lexicographic ascending order" ) actual_set = set(actual_codes) @@ -255,8 +254,7 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: if actual_nodes != sorted(actual_nodes): raise ExecutionWitnessValidationError( - "Witness state is not sorted in lexicographic ascending " - "order" + "Witness state is not sorted in lexicographic ascending order" ) actual_set = set(actual_nodes) From bb5d8b6898d1048d2faf7faaafa93c7950716785 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 13 Mar 2026 18:02:05 -0300 Subject: [PATCH 124/265] add test for the bug scenario Signed-off-by: jsign --- .../test_witness_7702.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py index c0fc2771c18..75c73ceb09b 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py @@ -76,6 +76,80 @@ def test_witness_codes_delegated_eoa( ) +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param(Op.CALL, id="call"), + pytest.param(Op.CALLCODE, id="callcode"), + ], +) +def test_witness_codes_delegated_eoa_insufficient_balance( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + CALL/CALLCODE to a delegated EOA with value greater than caller balance. + + The call must fail and return 0, but delegation resolution still reads + both the marker code and the delegated bytecode into the witness before + the insufficient-balance early return. + """ + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(amount=0, delegation=delegate) + + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + call_opcode( + Op.GAS, + delegated_eoa, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker = Spec7702.delegation_designation(delegate) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(marker), + Bytes(bytes(delegate_code)), + ], + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + def test_witness_codes_sender_delegation_marker_included( pre: Alloc, blockchain_test: BlockchainTestFiller, From 7959bd08c9a67dd89d1b66157035fa9bc39130c5 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 16 Mar 2026 16:48:08 -0300 Subject: [PATCH 125/265] zkevm/tests: make execution witness bytecode expectation strict and automatically add system contracts per fork --- .../src/execution_testing/forks/base_fork.py | 11 +++ .../execution_testing/forks/forks/forks.py | 60 +++++++++++++++ .../forks/tests/test_forks.py | 16 ++++ .../src/execution_testing/specs/blockchain.py | 76 ++++++++++++++++++- .../execution_witness/expectations.py | 24 ++---- .../test_execution_witness_expectation.py | 27 +++++++ 6 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index d213a6ba3a9..3da59c208db 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -674,6 +674,17 @@ def system_contracts( """Return list of system contracts supported by the fork.""" pass + @classmethod + def execution_witness_implicit_code_addresses( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> List[Address]: + """ + Return addresses whose pre-state bytecodes are implicitly expected in + execution witnesses for block execution at this fork. + """ + del block_number, timestamp + return [] + @classmethod @abstractmethod def deterministic_factory_predeploy_address( diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index d7525f437cb..3e123e91e4a 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -2590,6 +2590,19 @@ def system_contracts( ) ] + @classmethod + def execution_witness_implicit_code_addresses( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> List[Address]: + """Cancun witnesses implicitly include beacon roots code.""" + del block_number, timestamp + return [ + Address( + 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, + label="BEACON_ROOTS_ADDRESS", + ) + ] + @classmethod def pre_allocation_blockchain( cls, *, block_number: int = 0, timestamp: int = 0 @@ -2812,6 +2825,28 @@ def system_contracts( block_number=block_number, timestamp=timestamp ) + @classmethod + def execution_witness_implicit_code_addresses( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> List[Address]: + """Prague witnesses implicitly include tracked block-level system code.""" + return [ + Address( + 0x00000961EF480EB55E80D19AD83579A64C007002, + label="WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS", + ), + Address( + 0x0000BBDDC7CE488642FB579F8B00F3A590007251, + label="CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS", + ), + Address( + 0x0000F90827F1C53A10CB7A02335B175320002935, + label="HISTORY_STORAGE_ADDRESS", + ), + ] + super(Prague, cls).execution_witness_implicit_code_addresses( + block_number=block_number, timestamp=timestamp + ) + @classmethod def max_request_type( cls, *, block_number: int = 0, timestamp: int = 0 @@ -3469,6 +3504,31 @@ def empty_block_bal_item_count( del block_number, timestamp return 15 + @classmethod + def execution_witness_implicit_code_addresses( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> List[Address]: + """Amsterdam witnesses implicitly include tracked block-level system code.""" + del block_number, timestamp + return [ + Address( + 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, + label="BEACON_ROOTS_ADDRESS", + ), + Address( + 0x00000961EF480EB55E80D19AD83579A64C007002, + label="WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS", + ), + Address( + 0x0000BBDDC7CE488642FB579F8B00F3A590007251, + label="CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS", + ), + Address( + 0x0000F90827F1C53A10CB7A02335B175320002935, + label="HISTORY_STORAGE_ADDRESS", + ), + ] + @classmethod def is_deployed(cls) -> bool: """Return True if this fork is deployed.""" diff --git a/packages/testing/src/execution_testing/forks/tests/test_forks.py b/packages/testing/src/execution_testing/forks/tests/test_forks.py index 5967b49e64b..27f1d277e72 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/forks/tests/test_forks.py @@ -218,6 +218,22 @@ def test_forks() -> None: ) +def test_amsterdam_execution_witness_implicit_code_addresses() -> None: + """Amsterdam exposes exactly the tracked ambient witness code addresses.""" + addresses = [ + address.hex() + for address in Amsterdam.execution_witness_implicit_code_addresses() + ] + + assert addresses == [ + "0x000f3df6d732807ef1319fb7b8bb8522d0beac02", + "0x00000961ef480eb55e80d19ad83579a64c007002", + "0x0000bbddc7ce488642fb579f8b00f3a590007251", + "0x0000f90827f1c53a10cb7a02335b175320002935", + ] + assert "0x00000000219ab540356cbb839cbe05303d7705fa" not in addresses + + class ForkInPydanticModel(BaseModel): """Fork in pydantic model.""" diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index d30f2916786..54c3258544a 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -148,6 +148,71 @@ def count_blobs(txs: List[Transaction]) -> int: ) +def execution_witness_implicit_codes_for_block( + *, + fork: Fork, + alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, +) -> List[Bytes]: + """ + Return ambient witness bytecodes implied by block-level execution. + + These codes are resolved from the effective pre-state for the block, not + from raw fork defaults, so test `pre` overrides are respected. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + addresses = active_fork.execution_witness_implicit_code_addresses( + block_number=block_number, + timestamp=timestamp, + ) + if not addresses: + return [] + + effective_alloc = alloc.get() if isinstance(alloc, LazyAlloc) else alloc + + codes: List[Bytes] = [] + seen: set[Bytes] = set() + for address in addresses: + if address not in effective_alloc: + continue + account = effective_alloc[address] + if account is None or len(account.code) == 0: + continue + code = Bytes(account.code) + if code in seen: + continue + codes.append(code) + seen.add(code) + return codes + + +def with_execution_witness_implicit_codes( + *, + expectation: ExecutionWitnessCodesExpectation, + fork: Fork, + alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, +) -> ExecutionWitnessCodesExpectation: + """Return a copy of the expectation with ambient block-level codes added.""" + codes_present = list(expectation.codes_present) + seen = set(codes_present) + + for code in execution_witness_implicit_codes_for_block( + fork=fork, + alloc=alloc, + block_number=block_number, + timestamp=timestamp, + ): + if code in seen: + continue + codes_present.append(code) + seen.add(code) + + return expectation.model_copy(update={"codes_present": codes_present}) + + class Header(CamelModel): """Header type used to describe block header properties in test specs.""" @@ -819,9 +884,16 @@ def generate_block_data( block.expected_execution_witness_codes is not None and execution_witness is not None ): - block.expected_execution_witness_codes.verify_against( - execution_witness + effective_codes_expectation = ( + with_execution_witness_implicit_codes( + expectation=block.expected_execution_witness_codes, + fork=self.fork, + alloc=previous_alloc, + block_number=env.number, + timestamp=env.timestamp, + ) ) + effective_codes_expectation.verify_against(execution_witness) execution_witness = ( block.expected_execution_witness_codes.modify_if_invalid_test( execution_witness diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 01323743381..4f0bcbc8ff1 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -42,13 +42,6 @@ class ExecutionWitnessCodesExpectation(CamelModel): default_factory=list, description=("Bytecodes that must NOT be present in witness codes"), ) - allow_unexpected: bool = Field( - default=True, - description=( - "If False, fail when witness codes contains bytecodes " - "not listed in codes_present" - ), - ) _modifier: Callable[["ExecutionWitness"], "ExecutionWitness"] | None = ( PrivateAttr(default=None) @@ -99,7 +92,7 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: ascending order 2. Presence checks: codes_present entries exist 3. Absence checks: codes_absent entries do not exist - 4. Exhaustiveness: if allow_unexpected=False, no extra codes + 4. Exhaustiveness: no extra codes are allowed Args: actual_witness: The ExecutionWitness from the t8n tool @@ -146,14 +139,13 @@ def verify_against(self, actual_witness: "ExecutionWitness") -> None: ) # 4. Exhaustiveness check - if not self.allow_unexpected: - expected_set = set(self.codes_present) - unexpected = actual_set - expected_set - if unexpected: - raise ExecutionWitnessValidationError( - f"Unexpected bytecodes in witness codes: " - f"{[c.hex() for c in unexpected]}" - ) + expected_set = set(self.codes_present) + unexpected = actual_set - expected_set + if unexpected: + raise ExecutionWitnessValidationError( + f"Unexpected bytecodes in witness codes: " + f"{[c.hex() for c in unexpected]}" + ) class ExecutionWitnessStateExpectation(CamelModel): diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py new file mode 100644 index 00000000000..6ac801fe0fa --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness_expectation.py @@ -0,0 +1,27 @@ +"""Unit tests for execution witness code expectations.""" + +import pytest + +from execution_testing.base_types import Bytes +from execution_testing.test_types import ( + ExecutionWitness, + ExecutionWitnessCodesExpectation, + ExecutionWitnessValidationError, +) + + +def test_codes_expectation_rejects_unexpected_codes() -> None: + """Witness code expectations are always exhaustive.""" + expected_code = Bytes(b"\x60\x00") + unexpected_code = Bytes(b"\x60\x01") + + expectation = ExecutionWitnessCodesExpectation( + codes_present=[expected_code] + ) + actual_witness = ExecutionWitness(codes=[expected_code, unexpected_code]) + + with pytest.raises( + ExecutionWitnessValidationError, + match="Unexpected bytecodes in witness codes", + ): + expectation.verify_against(actual_witness) From a360b2c7322408a42801e3f8932b248da6eb3d59 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 16 Mar 2026 16:50:26 -0300 Subject: [PATCH 126/265] clarify things a bit more --- .../test_types/execution_witness/expectations.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py index 4f0bcbc8ff1..d40ff3f18e0 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/expectations.py @@ -26,6 +26,10 @@ class ExecutionWitnessCodesExpectation(CamelModel): Define which bytecodes should or should not appear in executionWitness.codes. + Ambient block-level codes (system contracts called every block) + are automatically added to codes_present by the framework before + verification. Tests only need to declare scenario-specific codes. + Example: expected_execution_witness_codes = ExecutionWitnessCodesExpectation( codes_present=[Bytes(runtime_code)], From d530d425d7aa2ffb6730a386b4c0878168374e18 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 16 Mar 2026 16:54:04 -0300 Subject: [PATCH 127/265] fix all test reg prior changes --- .../eip8025_optional_proofs/conftest.py | 25 ----------------- .../test_witness_7702.py | 23 +++++++-------- .../test_witness_bytecodes_call_variants.py | 10 +++---- ...est_witness_bytecodes_contract_creation.py | 8 +++++- .../test_witness_bytecodes_eoa_precompiles.py | 12 ++------ .../test_witness_bytecodes_extcode.py | 15 ++-------- .../test_witness_bytecodes_selfdestruct.py | 28 ++++--------------- ...test_witness_bytecodes_system_contracts.py | 14 +++------- 8 files changed, 38 insertions(+), 97 deletions(-) delete mode 100644 tests/amsterdam/eip8025_optional_proofs/conftest.py diff --git a/tests/amsterdam/eip8025_optional_proofs/conftest.py b/tests/amsterdam/eip8025_optional_proofs/conftest.py deleted file mode 100644 index ac094f3903c..00000000000 --- a/tests/amsterdam/eip8025_optional_proofs/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Shared pytest definitions for EIP-8025 witness tests.""" - -import pytest -from execution_testing import Bytes, Fork - -# System contracts called every block (addresses as ints). -SYSTEM_CONTRACT_ADDRS = [ - 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, # beacon roots - 0x00000961EF480EB55E80D19AD83579A64C007002, # withdrawal req - 0x0000BBDDC7CE488642FB579F8B00F3A590007251, # consolidation req - 0x0000F90827F1C53A10CB7A02335B175320002935, # history storage -] - - -@pytest.fixture -def system_codes(fork: Fork) -> list[Bytes]: - """Collect system contract bytecodes from the fork pre-alloc.""" - alloc = fork.pre_allocation_blockchain() - codes: list[Bytes] = [] - for addr in SYSTEM_CONTRACT_ADDRS: - code = alloc[addr]["code"] - if isinstance(code, str): - code = bytes.fromhex(code.removeprefix("0x")) - codes.append(Bytes(code)) - return codes diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py index 75c73ceb09b..69af99621ed 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_7702.py @@ -63,6 +63,7 @@ def test_witness_codes_delegated_eoa( expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( codes_present=[ + Bytes(bytes(caller_code)), Bytes(marker), Bytes(bytes(delegate_code)), ], @@ -136,6 +137,7 @@ def test_witness_codes_delegated_eoa_insufficient_balance( expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( codes_present=[ + Bytes(bytes(caller_code)), Bytes(marker), Bytes(bytes(delegate_code)), ], @@ -309,6 +311,7 @@ def test_witness_codes_delegation_set_in_same_block( expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( codes_present=[ + Bytes(bytes(caller_code)), Bytes(bytes(delegate_code)), ], codes_absent=[ @@ -455,10 +458,13 @@ def test_witness_codes_extcode_delegated_eoa( marker = Spec7702.delegation_designation(delegate) if extcode_opcode in ("extcodesize", "extcodecopy"): - codes_present = [Bytes(marker)] + codes_present = [ + Bytes(bytes(caller_code)), + Bytes(marker), + ] codes_absent = [Bytes(bytes(delegate_code))] else: - codes_present = [] + codes_present = [Bytes(bytes(caller_code))] codes_absent = [ Bytes(marker), Bytes(bytes(delegate_code)), @@ -530,6 +536,7 @@ def test_witness_codes_delegation_chain( expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( codes_present=[ + Bytes(bytes(caller_code)), Bytes(marker_alice), Bytes(marker_bob), ], @@ -548,7 +555,6 @@ def test_witness_codes_delegation_chain( def test_witness_codes_reset_delegation( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -600,8 +606,7 @@ def test_witness_codes_reset_delegation( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes + [Bytes(old_marker)], - allow_unexpected=False, + codes_present=[Bytes(old_marker)], ) ), ) @@ -617,7 +622,6 @@ def test_witness_codes_reset_delegation( def test_witness_codes_delegation_to_empty_account( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -659,8 +663,7 @@ def test_witness_codes_delegation_to_empty_account( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes + [Bytes(marker)], - allow_unexpected=False, + codes_present=[Bytes(marker)], ) ), ) @@ -673,7 +676,6 @@ def test_witness_codes_delegation_to_empty_account( def test_witness_codes_auth_nonce_mismatch( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -715,8 +717,7 @@ def test_witness_codes_auth_nonce_mismatch( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes + [Bytes(old_marker)], - allow_unexpected=False, + codes_present=[Bytes(old_marker)], ) ), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py index 8616a9225ef..53f2daf5b3b 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_call_variants.py @@ -58,7 +58,10 @@ def test_witness_codes_call_existing_contract( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[Bytes(bytes(target_code))], + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], ) ), ) @@ -115,7 +118,6 @@ def test_witness_codes_nested_calls( def test_witness_codes_dedup_identical_bytecode( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -147,12 +149,10 @@ def test_witness_codes_dedup_identical_bytecode( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [ + codes_present=[ Bytes(bytes(caller_code)), Bytes(bytes(shared_code)), ], - allow_unexpected=False, ) ), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py index a9e21eebdcd..18d48d4cf68 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_contract_creation.py @@ -171,6 +171,7 @@ def test_witness_codes_create2_excludes_new_bytecode( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( + codes_present=[Bytes(factory_code)], codes_absent=[Bytes(runtime_code)], ) ), @@ -275,6 +276,7 @@ def test_witness_codes_create_then_call_same_block( txs=[tx1_create, tx2_call], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(caller_code))], codes_absent=[Bytes(runtime_code)], ) ), @@ -382,6 +384,7 @@ def test_witness_codes_create_then_call_same_tx( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( + codes_present=[Bytes(bytes(factory_code))], codes_absent=[Bytes(runtime_code)], ) ), @@ -547,7 +550,10 @@ def test_witness_codes_reverted_create_same_hash_then_read( txs=[tx1_reverted_create, tx2_read], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=[Bytes(runtime_code)], + codes_present=[ + Bytes(bytes(factory_code)), + Bytes(runtime_code), + ], ) ), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py index 7c9298612bc..e9623a1dc86 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py @@ -21,7 +21,6 @@ @pytest.mark.with_all_precompiles() def test_witness_codes_call_precompile( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, precompile: Address, ) -> None: @@ -41,10 +40,7 @@ def test_witness_codes_call_precompile( Block( txs=[tx], expected_execution_witness_codes=( - ExecutionWitnessCodesExpectation( - codes_present=system_codes, - allow_unexpected=False, - ) + ExecutionWitnessCodesExpectation() ), ) ], @@ -56,7 +52,6 @@ def test_witness_codes_call_precompile( def test_witness_codes_call_eoa( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -82,10 +77,7 @@ def test_witness_codes_call_eoa( Block( txs=[tx], expected_execution_witness_codes=( - ExecutionWitnessCodesExpectation( - codes_present=system_codes, - allow_unexpected=False, - ) + ExecutionWitnessCodesExpectation() ), ) ], diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py index 45ec10bd073..45eae760fe7 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py @@ -61,7 +61,6 @@ def test_witness_codes_extcodesize( def test_witness_codes_extcodesize_empty_code( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -86,9 +85,7 @@ def test_witness_codes_extcodesize_empty_code( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [Bytes(bytes(caller_code))], - allow_unexpected=False, + codes_present=[Bytes(bytes(caller_code))], ) ), ) @@ -101,7 +98,6 @@ def test_witness_codes_extcodesize_empty_code( def test_witness_codes_extcodecopy_empty_code( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -126,9 +122,7 @@ def test_witness_codes_extcodecopy_empty_code( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [Bytes(bytes(caller_code))], - allow_unexpected=False, + codes_present=[Bytes(bytes(caller_code))], ) ), ) @@ -351,7 +345,6 @@ def test_witness_codes_extcodesize_cold_gas_boundary( ) def test_witness_codes_extcode_precompile( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, precompile: Address, extcode_opcode: str, @@ -382,9 +375,7 @@ def test_witness_codes_extcode_precompile( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [Bytes(bytes(caller_code))], - allow_unexpected=False, + codes_present=[Bytes(bytes(caller_code))], ) ), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py index 42c3b0a37e6..c96a5370001 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_selfdestruct.py @@ -25,7 +25,6 @@ def test_witness_codes_selfdestruct( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -56,13 +55,11 @@ def test_witness_codes_selfdestruct( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [ + codes_present=[ Bytes(bytes(caller_code)), Bytes(bytes(target_code)), ], codes_absent=[Bytes(bytes(beneficiary_code))], - allow_unexpected=False, ) ), ) @@ -77,7 +74,6 @@ def test_witness_codes_selfdestruct( def test_witness_codes_selfdestruct_top_level_tx( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -105,12 +101,10 @@ def test_witness_codes_selfdestruct_top_level_tx( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [ + codes_present=[ Bytes(bytes(target_code)), ], codes_absent=[Bytes(bytes(beneficiary_code))], - allow_unexpected=False, ) ), ) @@ -125,7 +119,6 @@ def test_witness_codes_selfdestruct_top_level_tx( def test_witness_codes_create_then_selfdestruct_same_tx( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -167,14 +160,12 @@ def test_witness_codes_create_then_selfdestruct_same_tx( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [ + codes_present=[ Bytes(bytes(factory_code)), ], codes_absent=[ Bytes(runtime_code), ], - allow_unexpected=False, ) ), ) @@ -189,7 +180,6 @@ def test_witness_codes_create_then_selfdestruct_same_tx( def test_witness_codes_selfdestruct_in_initcode( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -223,12 +213,10 @@ def test_witness_codes_selfdestruct_in_initcode( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes, codes_absent=[ Bytes(initcode), Bytes(bytes(beneficiary_code)), ], - allow_unexpected=False, ) ), ) @@ -243,7 +231,6 @@ def test_witness_codes_selfdestruct_in_initcode( def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ @@ -281,8 +268,7 @@ def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [ + codes_present=[ Bytes(bytes(caller_code)), Bytes(bytes(target_code)), ], @@ -290,7 +276,6 @@ def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( Bytes(marker), Bytes(bytes(delegate_code)), ], - allow_unexpected=False, ) ), ) @@ -314,7 +299,6 @@ def test_witness_codes_selfdestruct_beneficiary_delegated_eoa( ) def test_witness_codes_selfdestruct_beneficiary_no_code( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, beneficiary_type: str, ) -> None: @@ -351,12 +335,10 @@ def test_witness_codes_selfdestruct_beneficiary_no_code( txs=[tx], expected_execution_witness_codes=( ExecutionWitnessCodesExpectation( - codes_present=system_codes - + [ + codes_present=[ Bytes(bytes(caller_code)), Bytes(bytes(target_code)), ], - allow_unexpected=False, ) ), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py index d6ebb80c370..e0408a149c4 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_system_contracts.py @@ -5,7 +5,6 @@ Alloc, Block, BlockchainTestFiller, - Bytes, ExecutionWitnessCodesExpectation, ) @@ -17,16 +16,14 @@ def test_witness_codes_empty_block_has_system_contracts( pre: Alloc, - system_codes: list[Bytes], blockchain_test: BlockchainTestFiller, ) -> None: """ Verify an empty block contains only system contract bytecodes. - System contracts are called every block via process_system_call, - which calls get_code() on a tracked TransactionState. An empty - block (no user transactions) should have exactly the four system - contract bytecodes in executionWitness.codes and nothing else. + System contract codes are automatically added to codes_present + by the testing framework, so an empty expectation is sufficient. + The exhaustiveness check ensures no extra codes appear. """ blockchain_test( pre=pre, @@ -34,10 +31,7 @@ def test_witness_codes_empty_block_has_system_contracts( Block( txs=[], expected_execution_witness_codes=( - ExecutionWitnessCodesExpectation( - codes_present=system_codes, - allow_unexpected=False, - ) + ExecutionWitnessCodesExpectation() ), ) ], From 7385f83d822af144f09d9c5a4581cc5a722916c1 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 16 Mar 2026 17:02:17 -0300 Subject: [PATCH 128/265] lints --- packages/testing/src/execution_testing/forks/forks/forks.py | 4 ++-- packages/testing/src/execution_testing/specs/blockchain.py | 2 +- .../test_witness_bytecodes_eoa_precompiles.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 3e123e91e4a..4f7fa3436ac 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -2829,7 +2829,7 @@ def system_contracts( def execution_witness_implicit_code_addresses( cls, *, block_number: int = 0, timestamp: int = 0 ) -> List[Address]: - """Prague witnesses implicitly include tracked block-level system code.""" + """Include tracked block-level system code.""" return [ Address( 0x00000961EF480EB55E80D19AD83579A64C007002, @@ -3508,7 +3508,7 @@ def empty_block_bal_item_count( def execution_witness_implicit_code_addresses( cls, *, block_number: int = 0, timestamp: int = 0 ) -> List[Address]: - """Amsterdam witnesses implicitly include tracked block-level system code.""" + """Include tracked block-level system code.""" del block_number, timestamp return [ Address( diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 54c3258544a..5d6d92f3df4 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -195,7 +195,7 @@ def with_execution_witness_implicit_codes( block_number: int, timestamp: int, ) -> ExecutionWitnessCodesExpectation: - """Return a copy of the expectation with ambient block-level codes added.""" + """Return expectation copy with ambient block-level codes added.""" codes_present = list(expectation.codes_present) seen = set(codes_present) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py index e9623a1dc86..0ca23d3dd99 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_eoa_precompiles.py @@ -7,7 +7,6 @@ Alloc, Block, BlockchainTestFiller, - Bytes, ExecutionWitnessCodesExpectation, Transaction, ) From 0df8ffa63437c8d7e21d6c0f778a38645bc16e96 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 18 Mar 2026 17:23:24 -0300 Subject: [PATCH 129/265] zkevm: add machinery for invalid execution witness tests --- .../plugins/filler/tests/test_filler.py | 279 ++++++++++++++++++ .../src/execution_testing/specs/blockchain.py | 206 +++++++++++-- .../test_types/execution_witness/modifiers.py | 55 ++++ .../tests/test_execution_witness.py | 21 ++ 4 files changed, 542 insertions(+), 19 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 803888ca974..0a147a38e42 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -769,6 +769,152 @@ def test_execution_witness(state_test, pre) -> None: """ ) +test_module_execution_witness_soundness = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, + ) + from execution_testing.test_types.execution_witness.modifiers import ( + remove_header_at, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_soundness( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + """ +) + +test_module_execution_witness_expected_true = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_expected_true( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + expected_stateless_validation_success=True, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + """ +) + +test_module_execution_witness_missing_expected = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, + ) + from execution_testing.test_types.execution_witness.modifiers import ( + remove_header_at, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_missing_expected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + """ +) + def test_execution_witness_in_blockchain_fixture( testdir: pytest.Testdir, @@ -827,6 +973,139 @@ def test_execution_witness_in_blockchain_fixture( sob = block["statelessOutputBytes"] assert isinstance(sob, str) and sob.startswith("0x") and len(sob) > 2 + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as EthereumBytes + + stateless_output = deserialize_stateless_output( + EthereumBytes(bytes.fromhex(sob[2:])) + ) + assert stateless_output.successful_validation is True + + +def test_execution_witness_expected_true_reuses_canonical_stateless_result( + testdir: pytest.Testdir, +) -> None: + """Explicit True expectation should preserve the canonical success path.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_expected_true.py" + ) + test_module.write(test_module_execution_witness_expected_true) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret == 0 + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness_expected_true/" + "execution_witness_expected_true.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][-1] + + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as EthereumBytes + + stateless_output = deserialize_stateless_output( + EthereumBytes(bytes.fromhex(block["statelessOutputBytes"][2:])) + ) + + assert stateless_output.successful_validation is True + + +def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( + testdir: pytest.Testdir, +) -> None: + """Mutated witness fixtures should carry mutated stateless bytes.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_soundness.py" + ) + test_module.write(test_module_execution_witness_soundness) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret == 0 + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness_soundness/" + "execution_witness_soundness.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][-1] + + assert len(block["executionWitness"]["headers"]) == 1 + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as EthereumBytes + + stateless_input = deserialize_stateless_input( + EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) + ) + stateless_output = deserialize_stateless_output( + EthereumBytes(bytes.fromhex(block["statelessOutputBytes"][2:])) + ) + + assert stateless_output.successful_validation is False + assert len(stateless_input.witness.headers) == 1 + assert [ + "0x" + bytes(header).hex() for header in stateless_input.witness.headers + ] == block["executionWitness"]["headers"] + + +def test_execution_witness_modifier_requires_explicit_guest_expectation( + testdir: pytest.Testdir, +) -> None: + """Mutated witness tests should declare the expected guest result.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_missing_expected.py" + ) + test_module.write(test_module_execution_witness_missing_expected) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret != 0 + result.stdout.fnmatch_lines( + [ + "*Mutated execution witness tests must set " + "expected_stateless_validation_success explicitly*" + ] + ) + test_module_environment_variables = textwrap.dedent( """\ diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 5d6d92f3df4..471f69149bc 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -213,6 +213,102 @@ def with_execution_witness_implicit_codes( return expectation.model_copy(update={"codes_present": codes_present}) +def rerun_amsterdam_stateless_guest_with_witness( + *, + fork: Fork, + block_number: int, + timestamp: int, + original_stateless_input_bytes: Bytes, + execution_witness: ExecutionWitness, +) -> tuple[Bytes, Bytes, bool]: + """ + Rebuild the stateless input with the emitted witness and rerun the guest. + + Amsterdam is currently the only fork with stateless guest support in this + repository, so the rerun path is kept Amsterdam-specific. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Execution witness guest rerun is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless import ( + ExecutionWitness as AmsterdamExecutionWitness, + StatelessInput as AmsterdamStatelessInput, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + run_stateless_guest, + ) + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + serialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + original_input = deserialize_stateless_input( + AmsterdamBytes(bytes(original_stateless_input_bytes)) + ) + rebuilt_witness = AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) + rebuilt_input = AmsterdamStatelessInput( + new_payload_request=original_input.new_payload_request, + witness=rebuilt_witness, + chain_config=original_input.chain_config, + public_keys=original_input.public_keys, + ) + rebuilt_input_bytes = serialize_stateless_input(rebuilt_input) + rebuilt_output_bytes = run_stateless_guest(rebuilt_input_bytes) + rebuilt_output = deserialize_stateless_output(rebuilt_output_bytes) + + return ( + Bytes(bytes(rebuilt_input_bytes)), + Bytes(bytes(rebuilt_output_bytes)), + rebuilt_output.successful_validation, + ) + + +def deserialize_amsterdam_stateless_output_success( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_output_bytes: Bytes, +) -> bool: + """ + Decode the canonical t8n stateless output and return success status. + + Amsterdam is currently the only fork with stateless guest support in this + repository, so the decode path is kept Amsterdam-specific. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless output decoding is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + stateless_output = deserialize_stateless_output( + AmsterdamBytes(bytes(stateless_output_bytes)) + ) + return stateless_output.successful_validation + + class Header(CamelModel): """Header type used to describe block header properties in test specs.""" @@ -361,6 +457,11 @@ class Block(Header): If set, the execution witness headers will be verified and potentially modified for invalid tests. """ + expected_stateless_validation_success: bool | None = None + """ + If set, assert the stateless guest result matches this expectation. This + must be set explicitly for tests that mutate the emitted execution witness. + """ exception: BLOCK_EXCEPTION_TYPE = None # If set, the block is expected to be rejected by the client. skip_exception_verification: bool = False @@ -912,6 +1013,85 @@ def generate_block_data( execution_witness ) + has_witness_modifier = ( + ( + block.expected_execution_witness_state is not None + and block.expected_execution_witness_state._modifier is not None + ) + or ( + block.expected_execution_witness_codes is not None + and block.expected_execution_witness_codes._modifier + is not None + ) + or ( + block.expected_execution_witness_headers is not None + and block.expected_execution_witness_headers._modifier + is not None + ) + ) + stateless_input_bytes = transition_tool_output.result.stateless_input_bytes + stateless_output_bytes = ( + transition_tool_output.result.stateless_output_bytes + ) + expected_success = block.expected_stateless_validation_success + if has_witness_modifier and expected_success is None: + raise AssertionError( + "Mutated execution witness tests must set " + "expected_stateless_validation_success explicitly" + ) + canonical_successful_validation: bool | None = None + if has_witness_modifier or expected_success is not None: + if stateless_output_bytes is None: + raise Exception( + "Stateless guest verification requires stateless output " + "bytes" + ) + canonical_successful_validation = ( + deserialize_amsterdam_stateless_output_success( + fork=self.fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + stateless_output_bytes=stateless_output_bytes, + ) + ) + + should_rerun_stateless_guest = has_witness_modifier + if should_rerun_stateless_guest: + if execution_witness is None or stateless_input_bytes is None: + raise Exception( + "Stateless guest rerun requires execution witness and " + "stateless input bytes" + ) + ( + stateless_input_bytes, + stateless_output_bytes, + successful_validation, + ) = rerun_amsterdam_stateless_guest_with_witness( + fork=self.fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + original_stateless_input_bytes=stateless_input_bytes, + execution_witness=execution_witness, + ) + if ( + expected_success is not None + and successful_validation != expected_success + ): + raise AssertionError( + "Stateless guest validation result mismatch: " + f"got {successful_validation}, " + f"want {expected_success}" + ) + elif ( + expected_success is not None + and canonical_successful_validation != expected_success + ): + raise AssertionError( + "Canonical stateless guest validation result mismatch: " + f"got {canonical_successful_validation}, " + f"want {expected_success}" + ) + built_block = BuiltBlock( header=header, alloc=transition_tool_output.alloc, @@ -927,12 +1107,8 @@ def generate_block_data( fork=self.fork, block_access_list=bal, execution_witness=execution_witness, - stateless_input_bytes=( - transition_tool_output.result.stateless_input_bytes - ), - stateless_output_bytes=( - transition_tool_output.result.stateless_output_bytes - ), + stateless_input_bytes=stateless_input_bytes, + stateless_output_bytes=stateless_output_bytes, ) try: @@ -948,25 +1124,17 @@ def generate_block_data( block.expected_block_access_list is not None and block.expected_block_access_list._modifier is not None ) - and not ( - block.expected_execution_witness_state is not None - and block.expected_execution_witness_state._modifier - is not None - ) - and not ( - block.expected_execution_witness_codes is not None - and block.expected_execution_witness_codes._modifier - is not None - ) + and not has_witness_modifier ): # Only verify block level exception if: - No transaction # exception was raised, because these are not reported as block # exceptions. - No RLP modifier was specified, because the # modifier is what normally produces the block exception. - No # requests were specified, because modified requests are also - # what normally produces the block exception. - No BAL or - # witness modifier was specified, because modified BAL/witness - # also produces block exceptions. + # what normally produces the block exception. - No BAL + # modifier was specified, because modified BAL produces block + # exceptions. - No witness modifier was specified, because + # witness soundness is verified separately via the guest rerun. built_block.verify_block_exception( transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, ) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index a61e33ac9b6..a712fc8596a 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -80,9 +80,64 @@ def transform( return transform +def clear_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove all header entries from the witness.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy(update={"headers": []}) + + return transform + + +def remove_header_at( + index: int, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove the header entry at `index` from the witness.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_headers = list(witness.headers) + try: + new_headers.pop(index) + except IndexError as exc: + raise IndexError( + f"Header index {index} out of range for witness headers" + ) from exc + return witness.model_copy(update={"headers": new_headers}) + + return transform + + +def replace_header_at( + index: int, + header: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Replace the header entry at `index` with `header`.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_headers = list(witness.headers) + try: + new_headers[index] = header + except IndexError as exc: + raise IndexError( + f"Header index {index} out of range for witness headers" + ) from exc + return witness.model_copy(update={"headers": new_headers}) + + return transform + + __all__ = [ "add_state_node", "add_code", + "clear_headers", "remove_state_node", "remove_code", + "remove_header_at", + "replace_header_at", ] diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py index 120a3212f7b..05e13eec84a 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -10,7 +10,10 @@ ) from execution_testing.test_types.execution_witness.modifiers import ( add_state_node, + clear_headers, remove_state_node, + remove_header_at, + replace_header_at, ) @@ -76,3 +79,21 @@ def test_execution_witness_state_modifiers_add_and_remove() -> None: restored = remove_state_node(Bytes(b"bb"))(modified) assert restored.state == [Bytes(b"aa")] + + +def test_execution_witness_header_modifiers() -> None: + """Header modifiers should update witness headers predictably.""" + witness = ExecutionWitness( + state=[], + codes=[], + headers=[Bytes(b"aa"), Bytes(b"bb")], + ) + + removed = remove_header_at(-1)(witness) + assert removed.headers == [Bytes(b"aa")] + + replaced = replace_header_at(0, Bytes(b"cc"))(witness) + assert replaced.headers == [Bytes(b"cc"), Bytes(b"bb")] + + cleared = clear_headers()(witness) + assert cleared.headers == [] From c3a110217d39dde6ecce162d0edbf4bbf6c72f3b Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 18 Mar 2026 17:25:20 -0300 Subject: [PATCH 130/265] zkevm: add basic invalid execution witness tests --- .../test_witness_soundness.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py new file mode 100644 index 00000000000..2371b3c40d8 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py @@ -0,0 +1,139 @@ +"""Execution witness soundness tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, + Op, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + remove_code, + remove_header_at, + remove_state_node, +) + +from .state_helpers import ( + as_storage, + build_large_storage, + collect_storage_proof_nodes, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_witness_soundness_missing_required_header( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the parent header from the witness should fail the guest.""" + offset = 2 + code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + contract = pre.deploy_contract(code=code) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_witness_soundness_missing_caller_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the caller bytecode should fail guest re-execution.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + caller_code_bytes = Bytes(bytes(caller_code)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + caller_code_bytes, + Bytes(bytes(target_code)), + ], + ).modify(remove_code(caller_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def test_witness_soundness_missing_storage_proof_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a required storage proof node should fail the guest.""" + read_slot = 1 + write_slot = 2 + storage = build_large_storage([read_slot]) + proof_nodes = collect_storage_proof_nodes(storage, [read_slot]) + assert proof_nodes + removed_node = sorted(proof_nodes)[0] + + contract = pre.deploy_contract( + code=Op.SSTORE(write_slot, Op.SLOAD(read_slot)) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + post_storage = storage | {write_slot: storage[read_slot]} + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=post_storage), + }, + ) From 02257b8f7ef1aaade918f8cb276dcb488a75e869 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 19 Mar 2026 12:07:54 -0300 Subject: [PATCH 131/265] zkevm: add more helpers --- .../test_types/execution_witness/modifiers.py | 35 +++++++++++++++++++ .../tests/test_execution_witness.py | 25 +++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index a712fc8596a..a805756cc16 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -80,6 +80,26 @@ def transform( return transform +def remove_code_at( + index: int, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Remove the bytecode entry at `index` from the witness codes list.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + new_codes = list(witness.codes) + try: + new_codes.pop(index) + except IndexError as exc: + raise IndexError( + f"Code index {index} out of range for witness codes" + ) from exc + return witness.model_copy(update={"codes": new_codes}) + + return transform + + def clear_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: """Remove all header entries from the witness.""" @@ -111,6 +131,19 @@ def transform( return transform +def reverse_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Reverse the order of witness headers.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"headers": list(reversed(witness.headers))} + ) + + return transform + + def replace_header_at( index: int, header: Bytes, @@ -138,6 +171,8 @@ def transform( "clear_headers", "remove_state_node", "remove_code", + "remove_code_at", "remove_header_at", + "reverse_headers", "replace_header_at", ] diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py index 05e13eec84a..15f6c1fa94a 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -9,11 +9,15 @@ ExecutionWitnessValidationError, ) from execution_testing.test_types.execution_witness.modifiers import ( + add_code, add_state_node, clear_headers, + remove_code, + remove_code_at, remove_state_node, remove_header_at, replace_header_at, + reverse_headers, ) @@ -81,6 +85,24 @@ def test_execution_witness_state_modifiers_add_and_remove() -> None: assert restored.state == [Bytes(b"aa")] +def test_execution_witness_code_modifiers() -> None: + """Code modifiers should update witness codes predictably.""" + witness = ExecutionWitness( + state=[], + codes=[Bytes(b"aa")], + headers=[], + ) + + added = add_code(Bytes(b"bb"))(witness) + assert added.codes == [Bytes(b"aa"), Bytes(b"bb")] + + removed = remove_code(Bytes(b"bb"))(added) + assert removed.codes == [Bytes(b"aa")] + + removed_by_index = remove_code_at(0)(added) + assert removed_by_index.codes == [Bytes(b"bb")] + + def test_execution_witness_header_modifiers() -> None: """Header modifiers should update witness headers predictably.""" witness = ExecutionWitness( @@ -95,5 +117,8 @@ def test_execution_witness_header_modifiers() -> None: replaced = replace_header_at(0, Bytes(b"cc"))(witness) assert replaced.headers == [Bytes(b"cc"), Bytes(b"bb")] + reversed_headers = reverse_headers()(witness) + assert reversed_headers.headers == [Bytes(b"bb"), Bytes(b"aa")] + cleared = clear_headers()(witness) assert cleared.headers == [] From e383cd92cde433b3608393f766783a5ab5114475 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 19 Mar 2026 12:19:53 -0300 Subject: [PATCH 132/265] zkevm/spec: increase scope of try catch --- src/ethereum/forks/amsterdam/stateless.py | 37 +++++++++++------------ 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 4d0a02bebeb..68b859b86dc 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -200,26 +200,27 @@ def verify_stateless_new_payload( """ Statelessly validate the execution payload. """ + new_payload_request_root = compute_new_payload_request_root(stateless_input) witness = stateless_input.witness - # Validate the headers are contiguous and compute their - # blockhashes. - decoded_headers, block_hashes = validate_headers(witness.headers) - parent_header = decoded_headers[-1] - - chain_context = ChainContext( - chain_id=stateless_input.chain_config.chain_id, - block_hashes=block_hashes, - parent_header=parent_header, - ) + try: + # Validate the headers are contiguous and compute their + # blockhashes. + decoded_headers, block_hashes = validate_headers(witness.headers) + parent_header = decoded_headers[-1] + + chain_context = ChainContext( + chain_id=stateless_input.chain_config.chain_id, + block_hashes=block_hashes, + parent_header=parent_header, + ) - pre_state = WitnessState( - _node_db=build_node_db(witness.state), - _state_root=parent_header.state_root, - _code_db=build_code_db(witness.codes), - ) + pre_state = WitnessState( + _node_db=build_node_db(witness.state), + _state_root=parent_header.state_root, + _code_db=build_code_db(witness.codes), + ) - try: execute_new_payload_request( stateless_input.new_payload_request, pre_state, @@ -230,9 +231,7 @@ def verify_stateless_new_payload( successful_validation = False return StatelessValidationResult( - new_payload_request_root=compute_new_payload_request_root( - stateless_input - ), + new_payload_request_root=new_payload_request_root, successful_validation=successful_validation, chain_config=stateless_input.chain_config, ) From 7b64cf2bf7a76693029873d264402302515ef261 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 19 Mar 2026 12:20:33 -0300 Subject: [PATCH 133/265] zkevm/tests: codes and headers tests --- .../test_soundness_codes.py | 193 ++++++++++++++++++ .../test_soundness_headers.py | 117 +++++++++++ .../test_witness_soundness.py | 68 ------ 3 files changed, 310 insertions(+), 68 deletions(-) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py b/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py new file mode 100644 index 00000000000..ecb0b93a299 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py @@ -0,0 +1,193 @@ +"""Execution witness code soundness tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + ExecutionWitnessCodesExpectation, + Op, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + remove_code, + remove_code_at, +) + +from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_soundness_codes_missing_current_frame_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the currently executing contract's code should fail.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + caller_code_bytes = Bytes(bytes(caller_code)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + caller_code_bytes, + Bytes(bytes(target_code)), + ], + ).modify(remove_code(caller_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_soundness_codes_missing_external_code_read_target( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing externally read code should fail guest execution.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + target_code_bytes = Bytes(bytes(target_code)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + target_code_bytes, + ], + ).modify(remove_code(target_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_soundness_codes_missing_implicit_system_contract_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing implicit system-contract code from an empty block should fail.""" + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation().modify( + remove_code_at(0) + ) + ), + expected_stateless_validation_success=False, + ) + ], + post={}, + ) + + +def test_soundness_codes_missing_7702_delegation_marker( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a pre-state 7702 delegation marker should fail.""" + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=delegated_eoa, + gas_limit=500_000, + ) + + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + marker, + Bytes(bytes(delegate_code)), + ], + ).modify(remove_code(marker)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_soundness_codes_missing_7702_delegated_target_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing delegated target code from a 7702 flow should fail.""" + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + + delegated_eoa = pre.fund_eoa(delegation=delegate) + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=delegated_eoa, + gas_limit=500_000, + ) + + delegate_code_bytes = Bytes(bytes(delegate_code)) + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + marker, + delegate_code_bytes, + ], + ).modify(remove_code(delegate_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py new file mode 100644 index 00000000000..7f69b42448b --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py @@ -0,0 +1,117 @@ +"""Execution witness header soundness tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + ExecutionWitnessHeadersExpectation, + Op, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + remove_header_at, + reverse_headers, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_soundness_headers_missing_parent_header( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the parent header from the witness should fail.""" + offset = 2 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(-1)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_soundness_headers_missing_oldest_blockhash_ancestor( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the oldest required BLOCKHASH ancestor should fail.""" + offset = 5 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(remove_header_at(0)) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_soundness_headers_non_contiguous_chain( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Reordering headers into a non-contiguous chain should fail.""" + offset = 5 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(reverse_headers()) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py index 2371b3c40d8..96cea0a1d31 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py @@ -31,74 +31,6 @@ REFERENCE_SPEC_VERSION = "N/A" -def test_witness_soundness_missing_required_header( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """Removing the parent header from the witness should fail the guest.""" - offset = 2 - code = Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP - contract = pre.deploy_contract(code=code) - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blocks = [Block(txs=[]) for _ in range(offset)] - blocks.append( - Block( - txs=[tx], - expected_execution_witness_headers=( - ExecutionWitnessHeadersExpectation( - expected_count=offset, - ).modify(remove_header_at(-1)) - ), - expected_stateless_validation_success=False, - ) - ) - - blockchain_test( - pre=pre, - blocks=blocks, - post={sender: Account(nonce=1)}, - ) - - -def test_witness_soundness_missing_caller_code( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """Removing the caller bytecode should fail guest re-execution.""" - target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP - target = pre.deploy_contract(code=target_code) - - caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP - caller = pre.deploy_contract(code=caller_code) - - sender = pre.fund_eoa() - tx = Transaction(sender=sender, to=caller, gas_limit=500_000) - caller_code_bytes = Bytes(bytes(caller_code)) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_codes=( - ExecutionWitnessCodesExpectation( - codes_present=[ - caller_code_bytes, - Bytes(bytes(target_code)), - ], - ).modify(remove_code(caller_code_bytes)) - ), - expected_stateless_validation_success=False, - ) - ], - post={ - sender: Account(nonce=1), - }, - ) - - def test_witness_soundness_missing_storage_proof_node( pre: Alloc, blockchain_test: BlockchainTestFiller, From 258c3c16b6cc06e6b40e0b82c186b00d22869b41 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Thu, 19 Mar 2026 15:49:24 -0300 Subject: [PATCH 134/265] lints --- .../plugins/filler/tests/test_filler.py | 3 ++- .../testing/src/execution_testing/specs/blockchain.py | 11 +++++++++-- .../test_types/tests/test_execution_witness.py | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 0a147a38e42..6a8094401dd 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1078,7 +1078,8 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( assert stateless_output.successful_validation is False assert len(stateless_input.witness.headers) == 1 assert [ - "0x" + bytes(header).hex() for header in stateless_input.witness.headers + "0x" + bytes(header).hex() + for header in stateless_input.witness.headers ] == block["executionWitness"]["headers"] diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 471f69149bc..e13154a33ef 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -235,6 +235,8 @@ def rerun_amsterdam_stateless_guest_with_witness( from ethereum.forks.amsterdam.stateless import ( ExecutionWitness as AmsterdamExecutionWitness, + ) + from ethereum.forks.amsterdam.stateless import ( StatelessInput as AmsterdamStatelessInput, ) from ethereum.forks.amsterdam.stateless_guest import ( @@ -1016,7 +1018,10 @@ def generate_block_data( has_witness_modifier = ( ( block.expected_execution_witness_state is not None - and block.expected_execution_witness_state._modifier is not None + and ( + block.expected_execution_witness_state._modifier + is not None + ) ) or ( block.expected_execution_witness_codes is not None @@ -1029,7 +1034,9 @@ def generate_block_data( is not None ) ) - stateless_input_bytes = transition_tool_output.result.stateless_input_bytes + stateless_input_bytes = ( + transition_tool_output.result.stateless_input_bytes + ) stateless_output_bytes = ( transition_tool_output.result.stateless_output_bytes ) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py index 05e13eec84a..184b0ad0571 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -11,8 +11,8 @@ from execution_testing.test_types.execution_witness.modifiers import ( add_state_node, clear_headers, - remove_state_node, remove_header_at, + remove_state_node, replace_header_at, ) From a5e6ea3454180e790c4d451e77c6923915581424 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Mar 2026 17:13:41 -0300 Subject: [PATCH 135/265] add helpers and tests --- .../test_types/execution_witness/modifiers.py | 28 +++++++++++++++++++ .../tests/test_execution_witness.py | 19 +++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index a805756cc16..6dc106c17a8 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -100,6 +100,19 @@ def transform( return transform +def reverse_codes() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Reverse the order of witness codes.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"codes": list(reversed(witness.codes))} + ) + + return transform + + def clear_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: """Remove all header entries from the witness.""" @@ -131,6 +144,19 @@ def transform( return transform +def prepend_header( + header: Bytes, +) -> Callable[[ExecutionWitness], ExecutionWitness]: + """Prepend a header entry to the witness.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy(update={"headers": [header, *witness.headers]}) + + return transform + + def reverse_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: """Reverse the order of witness headers.""" @@ -172,6 +198,8 @@ def transform( "remove_state_node", "remove_code", "remove_code_at", + "reverse_codes", + "prepend_header", "remove_header_at", "reverse_headers", "replace_header_at", diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py index 02657d62db0..1f397919e27 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -12,12 +12,13 @@ add_code, add_state_node, clear_headers, + prepend_header, remove_code, remove_code_at, - remove_state_node, remove_header_at, remove_state_node, replace_header_at, + reverse_codes, reverse_headers, ) @@ -90,18 +91,21 @@ def test_execution_witness_code_modifiers() -> None: """Code modifiers should update witness codes predictably.""" witness = ExecutionWitness( state=[], - codes=[Bytes(b"aa")], + codes=[Bytes(b"aa"), Bytes(b"bb")], headers=[], ) - added = add_code(Bytes(b"bb"))(witness) - assert added.codes == [Bytes(b"aa"), Bytes(b"bb")] + added = add_code(Bytes(b"cc"))(witness) + assert added.codes == [Bytes(b"aa"), Bytes(b"bb"), Bytes(b"cc")] removed = remove_code(Bytes(b"bb"))(added) - assert removed.codes == [Bytes(b"aa")] + assert removed.codes == [Bytes(b"aa"), Bytes(b"cc")] removed_by_index = remove_code_at(0)(added) - assert removed_by_index.codes == [Bytes(b"bb")] + assert removed_by_index.codes == [Bytes(b"bb"), Bytes(b"cc")] + + reversed_codes = reverse_codes()(witness) + assert reversed_codes.codes == [Bytes(b"bb"), Bytes(b"aa")] def test_execution_witness_header_modifiers() -> None: @@ -118,6 +122,9 @@ def test_execution_witness_header_modifiers() -> None: replaced = replace_header_at(0, Bytes(b"cc"))(witness) assert replaced.headers == [Bytes(b"cc"), Bytes(b"bb")] + prepended = prepend_header(Bytes(b"00"))(witness) + assert prepended.headers == [Bytes(b"00"), Bytes(b"aa"), Bytes(b"bb")] + reversed_headers = reverse_headers()(witness) assert reversed_headers.headers == [Bytes(b"bb"), Bytes(b"aa")] From 53774a67685634fb9dc49c67e13799595b15df14 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Mar 2026 17:27:47 -0300 Subject: [PATCH 136/265] add extra witness headers tests --- .../test_witness_headers.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py index d6b31c0eb64..9222662e979 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -1,15 +1,25 @@ """Witness header collection border-case tests.""" +from copy import deepcopy + import pytest from execution_testing import ( Account, Alloc, Block, + BlockchainTest, BlockchainTestFiller, + Bytes, ExecutionWitnessHeadersExpectation, Op, Transaction, ) +from execution_testing.client_clis import TransitionTool +from execution_testing.fixtures import BlockchainFixture +from execution_testing.forks import Amsterdam +from execution_testing.test_types.execution_witness.modifiers import ( + prepend_header, +) pytestmark = pytest.mark.valid_from("Amsterdam") @@ -200,6 +210,143 @@ def test_witness_headers_multiple_blockhash_max_wins( ) +def test_witness_headers_max_wins_across_multiple_transactions( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + The deepest BLOCKHASH across all transactions drives the witness. + """ + offset_small = 2 + offset_large = 5 + + contract_small = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset_small)) + Op.POP + Op.STOP + ) + contract_large = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset_large)) + Op.POP + Op.STOP + ) + + sender_small = pre.fund_eoa() + sender_large = pre.fund_eoa() + tx_small = Transaction( + sender=sender_small, + to=contract_small, + gas_limit=500_000, + ) + tx_large = Transaction( + sender=sender_large, + to=contract_large, + gas_limit=500_000, + ) + + blocks = [Block(txs=[]) for _ in range(offset_large)] + blocks.append( + Block( + txs=[tx_small, tx_large], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset_large, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={ + sender_small: Account(nonce=1), + sender_large: Account(nonce=1), + }, + ) + + +def test_witness_headers_blockhash_in_reverted_inner_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Header access in a reverted inner call should still be witnessed. + """ + offset = 5 + callee = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.REVERT(0, 0) + ) + caller = pre.deploy_contract( + code=Op.CALL(Op.GAS, callee, 0, 0, 0, 0, 0) + Op.POP + Op.STOP + ) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ) + ), + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) + + +def test_witness_headers_extra_unused_older_ancestor( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + t8n: TransitionTool, +) -> None: + """ + A contiguous extra older ancestor should still validate. + """ + offset = 3 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + post = {sender: Account(nonce=1)} + + probe_fixture = ( + BlockchainTest( + fork=Amsterdam, + pre=deepcopy(pre), + blocks=[Block(txs=[])], + post={}, + ) + .generate(t8n=t8n, fixture_format=BlockchainFixture) + .fixture + ) + assert isinstance(probe_fixture, BlockchainFixture) + extra_header = probe_fixture.blocks[0].header.rlp + + blocks = [Block(txs=[]) for _ in range(offset + 1)] + blocks.append( + Block( + txs=[Transaction(sender=sender, to=contract, gas_limit=500_000)], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(prepend_header(extra_header)) + ), + expected_stateless_validation_success=True, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post=post, + ) + + @pytest.mark.slow @pytest.mark.parametrize( "offset,expected_count", From 84ded05c2591a201179efd5d5e28dcbfa97518b6 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Mar 2026 21:13:36 -0300 Subject: [PATCH 137/265] more header tests --- .../test_soundness_headers.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py index 7f69b42448b..3a50b63b6fd 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py @@ -6,12 +6,15 @@ Alloc, Block, BlockchainTestFiller, + Bytes, ExecutionWitnessHeadersExpectation, Op, Transaction, ) from execution_testing.test_types.execution_witness.modifiers import ( + clear_headers, remove_header_at, + replace_header_at, reverse_headers, ) @@ -115,3 +118,57 @@ def test_soundness_headers_non_contiguous_chain( blocks=blocks, post={sender: Account(nonce=1)}, ) + + +def test_soundness_headers_empty_block_missing_mandatory_parent( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the mandatory parent header from an empty block should fail.""" + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=1, + ).modify(clear_headers()) + ), + expected_stateless_validation_success=False, + ) + ], + post={}, + ) + + +def test_soundness_headers_malformed_rlp_header( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Replacing a required header with malformed RLP should fail.""" + offset = 5 + contract = pre.deploy_contract( + code=Op.BLOCKHASH(Op.SUB(Op.NUMBER, offset)) + Op.POP + Op.STOP + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blocks = [Block(txs=[]) for _ in range(offset)] + blocks.append( + Block( + txs=[tx], + expected_execution_witness_headers=( + ExecutionWitnessHeadersExpectation( + expected_count=offset, + ).modify(replace_header_at(-1, Bytes(b"\xff"))) + ), + expected_stateless_validation_success=False, + ) + ) + + blockchain_test( + pre=pre, + blocks=blocks, + post={sender: Account(nonce=1)}, + ) From 5b15533ec53f5788ab4b7888da1746f129464c8d Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Mar 2026 22:06:01 -0300 Subject: [PATCH 138/265] more tests --- .../test_soundness_codes.py | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py b/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py index ecb0b93a299..68903217a8d 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py @@ -4,6 +4,7 @@ from execution_testing import ( Account, Alloc, + AuthorizationTuple, Block, BlockchainTestFiller, Bytes, @@ -12,8 +13,10 @@ Transaction, ) from execution_testing.test_types.execution_witness.modifiers import ( + add_code, remove_code, remove_code_at, + reverse_codes, ) from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 @@ -191,3 +194,264 @@ def test_soundness_codes_missing_7702_delegated_target_code( ], post={sender: Account(nonce=1)}, ) + + +def test_soundness_codes_missing_sender_delegation_marker( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the sender's delegation marker should fail.""" + delegate = pre.deploy_contract(code=Op.STOP) + delegated_sender = pre.fund_eoa(delegation=delegate) + + recipient = pre.fund_eoa() + tx = Transaction( + sender=delegated_sender, + to=recipient, + gas_limit=500_000, + ) + + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[marker], + ).modify(remove_code(marker)) + ), + expected_stateless_validation_success=False, + ) + ], + post={delegated_sender: Account(nonce=2)}, + ) + + +def test_soundness_codes_missing_redelegation_old_marker( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the old marker read during re-delegation should fail.""" + delegate_old = pre.deploy_contract(code=Op.PUSH1(0x01) + Op.POP + Op.STOP) + delegate_new = pre.deploy_contract(code=Op.PUSH1(0x02) + Op.POP + Op.STOP) + + alice = pre.fund_eoa(delegation=delegate_old) + relayer = pre.fund_eoa() + recipient = pre.fund_eoa() + + old_marker = Bytes(Spec7702.delegation_designation(delegate_old)) + new_marker = Spec7702.delegation_designation(delegate_new) + + tx = Transaction( + sender=relayer, + to=recipient, + gas_limit=500_000, + authorization_list=[ + AuthorizationTuple( + address=delegate_new, + nonce=1, + signer=alice, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[old_marker], + codes_absent=[Bytes(new_marker)], + ).modify(remove_code(old_marker)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + alice: Account( + nonce=2, + code=new_marker, + ), + }, + ) + + +def test_soundness_codes_missing_delegated_code_on_insufficient_balance_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing delegated code on an insufficient-balance CALL should fail.""" + delegate_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + delegate = pre.deploy_contract(code=delegate_code) + delegated_eoa = pre.fund_eoa(amount=0, delegation=delegate) + + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + Op.CALL( + Op.GAS, + delegated_eoa, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + delegate_code_bytes = Bytes(bytes(delegate_code)) + marker = Bytes(Spec7702.delegation_designation(delegate)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + marker, + delegate_code_bytes, + ], + ).modify(remove_code(delegate_code_bytes)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + +def test_soundness_codes_missing_second_marker_in_delegation_chain( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the second marker in a delegation chain should fail.""" + charlie_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + charlie = pre.deploy_contract(code=charlie_code) + + bob = pre.fund_eoa(delegation=charlie) + alice = pre.fund_eoa(delegation=bob) + + caller_code = Op.CALL(address=alice) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + marker_alice = Bytes(Spec7702.delegation_designation(bob)) + marker_bob = Bytes(Spec7702.delegation_designation(charlie)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + marker_alice, + marker_bob, + ], + codes_absent=[ + Bytes(bytes(charlie_code)), + ], + ).modify(remove_code(marker_bob)) + ), + expected_stateless_validation_success=False, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_soundness_codes_extra_unused_bytecode( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Adding an unused bytecode preimage should still validate.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + unused_code = Bytes(bytes(Op.PUSH1(0x99) + Op.PUSH1(0x01) + Op.STOP)) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ).modify(add_code(unused_code)) + ), + expected_stateless_validation_success=True, + ) + ], + post={sender: Account(nonce=1)}, + ) + + +def test_witness_codes_unsorted_but_complete( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Reordering complete witness codes should still validate.""" + target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP + target = pre.deploy_contract(code=target_code) + + caller_code = Op.EXTCODECOPY(target, 0, 0, 32) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_codes=( + ExecutionWitnessCodesExpectation( + codes_present=[ + Bytes(bytes(caller_code)), + Bytes(bytes(target_code)), + ], + ).modify(reverse_codes()) + ), + expected_stateless_validation_success=True, + ) + ], + post={sender: Account(nonce=1)}, + ) From fd53de118b211936761cd6ce04735d4437b3dbdb Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Mar 2026 22:20:31 -0300 Subject: [PATCH 139/265] lints --- .../test_types/execution_witness/modifiers.py | 4 +++- src/ethereum/forks/amsterdam/stateless.py | 4 +++- .../eip8025_optional_proofs/test_soundness_codes.py | 4 +++- .../eip8025_optional_proofs/test_witness_headers.py | 1 - .../eip8025_optional_proofs/test_witness_soundness.py | 5 ----- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index 6dc106c17a8..1e020373ffa 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -152,7 +152,9 @@ def prepend_header( def transform( witness: ExecutionWitness, ) -> ExecutionWitness: - return witness.model_copy(update={"headers": [header, *witness.headers]}) + return witness.model_copy( + update={"headers": [header, *witness.headers]} + ) return transform diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 68b859b86dc..b261d089b88 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -200,7 +200,9 @@ def verify_stateless_new_payload( """ Statelessly validate the execution payload. """ - new_payload_request_root = compute_new_payload_request_root(stateless_input) + new_payload_request_root = compute_new_payload_request_root( + stateless_input + ) witness = stateless_input.witness try: diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py b/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py index 68903217a8d..fcf35a38538 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py @@ -101,7 +101,9 @@ def test_soundness_codes_missing_implicit_system_contract_code( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: - """Removing implicit system-contract code from an empty block should fail.""" + """ + Removing implicit system-contract code from an empty block should fail. + """ blockchain_test( pre=pre, blocks=[ diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py index 9222662e979..67a0e3c1520 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -9,7 +9,6 @@ Block, BlockchainTest, BlockchainTestFiller, - Bytes, ExecutionWitnessHeadersExpectation, Op, Transaction, diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py index 96cea0a1d31..99120d7ebc5 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py @@ -6,16 +6,11 @@ Alloc, Block, BlockchainTestFiller, - Bytes, - ExecutionWitnessCodesExpectation, - ExecutionWitnessHeadersExpectation, ExecutionWitnessStateExpectation, Op, Transaction, ) from execution_testing.test_types.execution_witness.modifiers import ( - remove_code, - remove_header_at, remove_state_node, ) From 81cb6cf2870f4c9305122099b78077221f4bc9e1 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 23 Mar 2026 22:31:45 -0300 Subject: [PATCH 140/265] refactor --- ...es.py => test_witness_validation_codes.py} | 24 +++++++++---------- ....py => test_witness_validation_headers.py} | 12 +++++----- ...ss.py => test_witness_validation_state.py} | 4 ++-- 3 files changed, 20 insertions(+), 20 deletions(-) rename tests/amsterdam/eip8025_optional_proofs/{test_soundness_codes.py => test_witness_validation_codes.py} (94%) rename tests/amsterdam/eip8025_optional_proofs/{test_soundness_headers.py => test_witness_validation_headers.py} (93%) rename tests/amsterdam/eip8025_optional_proofs/{test_witness_soundness.py => test_witness_validation_state.py} (94%) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_codes.py similarity index 94% rename from tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_validation_codes.py index fcf35a38538..5d98d0665d2 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_soundness_codes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_codes.py @@ -1,4 +1,4 @@ -"""Execution witness code soundness tests.""" +"""Execution witness code validation tests.""" import pytest from execution_testing import ( @@ -27,7 +27,7 @@ REFERENCE_SPEC_VERSION = "N/A" -def test_soundness_codes_missing_current_frame_code( +def test_validation_codes_missing_current_frame_code( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -62,7 +62,7 @@ def test_soundness_codes_missing_current_frame_code( ) -def test_soundness_codes_missing_external_code_read_target( +def test_validation_codes_missing_external_code_read_target( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -97,7 +97,7 @@ def test_soundness_codes_missing_external_code_read_target( ) -def test_soundness_codes_missing_implicit_system_contract_code( +def test_validation_codes_missing_implicit_system_contract_code( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -121,7 +121,7 @@ def test_soundness_codes_missing_implicit_system_contract_code( ) -def test_soundness_codes_missing_7702_delegation_marker( +def test_validation_codes_missing_7702_delegation_marker( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -159,7 +159,7 @@ def test_soundness_codes_missing_7702_delegation_marker( ) -def test_soundness_codes_missing_7702_delegated_target_code( +def test_validation_codes_missing_7702_delegated_target_code( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -198,7 +198,7 @@ def test_soundness_codes_missing_7702_delegated_target_code( ) -def test_soundness_codes_missing_sender_delegation_marker( +def test_validation_codes_missing_sender_delegation_marker( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -232,7 +232,7 @@ def test_soundness_codes_missing_sender_delegation_marker( ) -def test_soundness_codes_missing_redelegation_old_marker( +def test_validation_codes_missing_redelegation_old_marker( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -283,7 +283,7 @@ def test_soundness_codes_missing_redelegation_old_marker( ) -def test_soundness_codes_missing_delegated_code_on_insufficient_balance_call( +def test_validation_codes_missing_delegated_code_on_insufficient_balance_call( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -345,7 +345,7 @@ def test_soundness_codes_missing_delegated_code_on_insufficient_balance_call( ) -def test_soundness_codes_missing_second_marker_in_delegation_chain( +def test_validation_codes_missing_second_marker_in_delegation_chain( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -389,7 +389,7 @@ def test_soundness_codes_missing_second_marker_in_delegation_chain( ) -def test_soundness_codes_extra_unused_bytecode( +def test_validation_codes_extra_unused_bytecode( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -425,7 +425,7 @@ def test_soundness_codes_extra_unused_bytecode( ) -def test_witness_codes_unsorted_but_complete( +def test_validation_codes_unsorted_but_complete( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: diff --git a/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_headers.py similarity index 93% rename from tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_validation_headers.py index 3a50b63b6fd..7c3c580d89d 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_soundness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_headers.py @@ -1,4 +1,4 @@ -"""Execution witness header soundness tests.""" +"""Execution witness header validation tests.""" import pytest from execution_testing import ( @@ -24,7 +24,7 @@ REFERENCE_SPEC_VERSION = "N/A" -def test_soundness_headers_missing_parent_header( +def test_validation_headers_missing_parent_header( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -56,7 +56,7 @@ def test_soundness_headers_missing_parent_header( ) -def test_soundness_headers_missing_oldest_blockhash_ancestor( +def test_validation_headers_missing_oldest_blockhash_ancestor( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -88,7 +88,7 @@ def test_soundness_headers_missing_oldest_blockhash_ancestor( ) -def test_soundness_headers_non_contiguous_chain( +def test_validation_headers_non_contiguous_chain( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -120,7 +120,7 @@ def test_soundness_headers_non_contiguous_chain( ) -def test_soundness_headers_empty_block_missing_mandatory_parent( +def test_validation_headers_empty_block_missing_mandatory_parent( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -142,7 +142,7 @@ def test_soundness_headers_empty_block_missing_mandatory_parent( ) -def test_soundness_headers_malformed_rlp_header( +def test_validation_headers_malformed_rlp_header( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py similarity index 94% rename from tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 99120d7ebc5..dfd576ce2b6 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_soundness.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -1,4 +1,4 @@ -"""Execution witness soundness tests.""" +"""Execution witness state validation tests.""" import pytest from execution_testing import ( @@ -26,7 +26,7 @@ REFERENCE_SPEC_VERSION = "N/A" -def test_witness_soundness_missing_storage_proof_node( +def test_validation_state_missing_storage_proof_node( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: From ca6bdde95793f23b7fecfe0c6c36abad97734b68 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Mar 2026 09:22:37 -0300 Subject: [PATCH 141/265] lint --- .../eip8025_optional_proofs/test_witness_headers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py index 67a0e3c1520..b5f344c96b7 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -15,6 +15,7 @@ ) from execution_testing.client_clis import TransitionTool from execution_testing.fixtures import BlockchainFixture +from execution_testing.fixtures.blockchain import FixtureBlock from execution_testing.forks import Amsterdam from execution_testing.test_types.execution_witness.modifiers import ( prepend_header, @@ -324,7 +325,9 @@ def test_witness_headers_extra_unused_older_ancestor( .fixture ) assert isinstance(probe_fixture, BlockchainFixture) - extra_header = probe_fixture.blocks[0].header.rlp + probe_block = probe_fixture.blocks[0] + assert isinstance(probe_block, FixtureBlock) + extra_header = probe_block.header.rlp blocks = [Block(txs=[]) for _ in range(offset + 1)] blocks.append( From 821bf9b22e91938f883ce77f2f88d488964090c4 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Mar 2026 19:52:27 -0300 Subject: [PATCH 142/265] add extra helpers --- .../test_types/execution_witness/modifiers.py | 14 ++++++++++++++ .../test_types/tests/test_execution_witness.py | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index 1e020373ffa..51307cebc7d 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -113,6 +113,19 @@ def transform( return transform +def reverse_state_nodes() -> Callable[[ExecutionWitness], ExecutionWitness]: + """Reverse the order of witness state nodes.""" + + def transform( + witness: ExecutionWitness, + ) -> ExecutionWitness: + return witness.model_copy( + update={"state": list(reversed(witness.state))} + ) + + return transform + + def clear_headers() -> Callable[[ExecutionWitness], ExecutionWitness]: """Remove all header entries from the witness.""" @@ -201,6 +214,7 @@ def transform( "remove_code", "remove_code_at", "reverse_codes", + "reverse_state_nodes", "prepend_header", "remove_header_at", "reverse_headers", diff --git a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py index 1f397919e27..9df9226705a 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_execution_witness.py @@ -20,6 +20,7 @@ replace_header_at, reverse_codes, reverse_headers, + reverse_state_nodes, ) @@ -86,6 +87,9 @@ def test_execution_witness_state_modifiers_add_and_remove() -> None: restored = remove_state_node(Bytes(b"bb"))(modified) assert restored.state == [Bytes(b"aa")] + reversed_state = reverse_state_nodes()(modified) + assert reversed_state.state == [Bytes(b"bb"), Bytes(b"aa")] + def test_execution_witness_code_modifiers() -> None: """Code modifiers should update witness codes predictably.""" From a198b463247e0ab7bef41479a424a56557bf96c7 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Mar 2026 21:11:45 -0300 Subject: [PATCH 143/265] improve stateless trie checks --- .../forks/amsterdam/incremental_mpt.py | 22 ++- src/ethereum/forks/amsterdam/witness_state.py | 135 ++++++++---------- 2 files changed, 77 insertions(+), 80 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index a82cc948ca7..496b034af3b 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -886,6 +886,7 @@ def compact_to_nibbles(compact: Bytes) -> Tuple[Bytes, bool]: def _resolve_child_ref( node_db: Dict[Bytes, Bytes], child_ref: Extended, + secured: bool, ) -> MutableNode: """ Resolve a child reference from an RLP-decoded trie node. @@ -902,15 +903,18 @@ def _resolve_child_ref( f"Unexpected child ref length: {len(ref_bytes)}" ) if ref_bytes in node_db: - return _decode_witness_node(node_db, node_db[ref_bytes]) + return _decode_witness_node( + node_db, node_db[ref_bytes], secured + ) return HashedNode(_hash=ref_bytes) else: - return _decode_witness_node(node_db, rlp.encode(child_ref)) + return _decode_witness_node(node_db, rlp.encode(child_ref), secured) def _decode_witness_node( node_db: Dict[Bytes, Bytes], rlp_bytes: Bytes, + secured: bool, ) -> MutableNode: """ Decode an RLP-encoded trie node into a ``MutableNode``. @@ -950,7 +954,11 @@ def _decode_witness_node( _rlp=rlp_bytes, ) else: - child = _resolve_child_ref(node_db, decoded[1]) + assert len(nibbles) > 0 or not secured, ( + "ExtensionNode must have a non-empty path " + "in state/storage trie" + ) + child = _resolve_child_ref(node_db, decoded[1], secured) assert isinstance(child, (MutableBranchNode, HashedNode)), ( "ExtensionNode child must be a BranchNode" ) @@ -964,13 +972,15 @@ def _decode_witness_node( elif len(decoded) == 17: children: List[Optional[MutableNode]] = [] for i in range(16): - children.append(_resolve_child_ref(node_db, decoded[i])) + children.append(_resolve_child_ref(node_db, decoded[i], secured)) value_raw = decoded[16] if isinstance(value_raw, (bytes, bytearray)): value = Bytes(value_raw) else: value = b"" - # TODO: value is always empty in practice; refactor + assert value == b"" or not secured, ( + "BranchNode value must be empty in state/storage trie" + ) occupied = 16 - children.count(None) + (value != b"") assert occupied >= 2, "BranchNode must have at least 2 children" return MutableBranchNode( @@ -1022,7 +1032,7 @@ def decode_witness_to_mpt( ) root_rlp = node_db[root_hash] - root_node = _decode_witness_node(node_db, root_rlp) + root_node = _decode_witness_node(node_db, root_rlp, secured) return IncrementalMPT( secured=secured, diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index 01d69d49171..fc6d8d5fc59 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -21,8 +21,12 @@ ) from .incremental_mpt import ( + HashedNode, IncrementalMPT, - compact_to_nibbles, + MutableBranchNode, + MutableExtensionNode, + MutableLeafNode, + MutableNode, decode_witness_to_mpt, mpt_root, mpt_set, @@ -46,96 +50,56 @@ def build_code_db(code_entries: Tuple[Bytes, ...]) -> Dict[Hash32, Bytes]: return db -def _resolve_node_ref( - node_db: Dict[Bytes, Bytes], - ref: object, -) -> object: - """ - Resolve a node reference to a decoded RLP object. - - Handle inline nodes (lists), 32-byte hash references (look up - in node_db), and empty bytes (absent node). - """ - if isinstance(ref, list): - return ref - if isinstance(ref, (bytes, bytearray)): - ref_bytes = Bytes(ref) - if len(ref_bytes) == 0: - return None - if len(ref_bytes) == 32: - if ref_bytes not in node_db: - return None - return rlp.decode(node_db[ref_bytes]) - return rlp.decode(ref_bytes) - return None - - def _trie_lookup( - node_db: Dict[Bytes, Bytes], - root_hash: Root, + root_node: MutableNode, key_hash: Hash32, ) -> Optional[Bytes]: """ - Walk MPT from root following nibblized key_hash. + Walk a decoded MPT from root following nibblized key_hash. Return leaf value or ``None`` if not found. - Parameters - ---------- - node_db : - Mapping from node hash to RLP-encoded node data. - root_hash : - Root hash of the trie. - key_hash : - Hashed key to look up (will be nibblized). - """ - if root_hash == EMPTY_TRIE_ROOT: - return None - if root_hash not in node_db: - return None - nibbles = bytearray() for byte in key_hash: nibbles.append(byte >> 4) nibbles.append(byte & 0x0F) - node = _resolve_node_ref(node_db, root_hash) + node = root_node pos = 0 while node is not None: - if not isinstance(node, list): + if isinstance(node, HashedNode): + raise AssertionError( + "Encountered unresolved HashedNode during witness lookup" + ) + + if isinstance(node, MutableLeafNode): + if bytes(nibbles[pos:]) == node.rest_of_key: + return node.value return None - if len(node) == 2: - path = node[0] - assert isinstance(path, (bytes, bytearray)) - segment, is_leaf = compact_to_nibbles(Bytes(path)) - remaining = bytes(nibbles[pos:]) - - if is_leaf: - if remaining == segment: - value = node[1] - assert isinstance(value, (bytes, bytearray)) - return Bytes(value) + if isinstance(node, MutableExtensionNode): + segment = node.key_segment + if bytes(nibbles[pos : pos + len(segment)]) != segment: return None - else: - if not remaining[: len(segment)] == segment: - return None - pos += len(segment) - node = _resolve_node_ref(node_db, node[1]) - - elif len(node) == 17: - if pos >= len(nibbles): - value = node[16] - if isinstance(value, (bytes, bytearray)) and len(value) > 0: - return Bytes(value) - return None - idx = nibbles[pos] - pos += 1 - node = _resolve_node_ref(node_db, node[idx]) - else: - return None + pos += len(segment) + node = node.child + continue + + assert isinstance(node, MutableBranchNode), ( + f"Unexpected node type {type(node)}" + ) + + assert pos <= len(nibbles), ( + "BranchNode cannot appear past the end of a key " + "in state/storage trie" + ) + if pos == len(nibbles): + return node.value or None + idx = nibbles[pos] + pos += 1 + node = node.children[idx] return None @@ -180,6 +144,25 @@ class WitnessState: _state_root: Root _code_db: Dict[Hash32, Bytes] _storage_root_cache: Dict[Address, Root] = field(default_factory=dict) + _decoded_secure_roots: Dict[Root, MutableNode] = field( + default_factory=dict + ) + + def _get_decoded_secure_root(self, root_hash: Root) -> MutableNode: + """Decode and cache a secured trie root for read-only lookups.""" + if root_hash == EMPTY_TRIE_ROOT: + return None + if root_hash not in self._decoded_secure_roots: + decoded_mpt: IncrementalMPT[Bytes, Bytes] = ( + decode_witness_to_mpt( + self._node_db, + root_hash, + secured=True, + default=b"", + ) + ) + self._decoded_secure_roots[root_hash] = decoded_mpt.root_node + return self._decoded_secure_roots[root_hash] def get_account_optional(self, address: Address) -> Optional[Account]: """ @@ -188,7 +171,9 @@ def get_account_optional(self, address: Address) -> Optional[Account]: Return ``None`` if there is no account at the address. """ key_hash = keccak256(address) - leaf = _trie_lookup(self._node_db, self._state_root, key_hash) + leaf = _trie_lookup( + self._get_decoded_secure_root(self._state_root), key_hash + ) if leaf is None: self._storage_root_cache[address] = EMPTY_TRIE_ROOT return None @@ -209,7 +194,9 @@ def get_storage(self, address: Address, key: Bytes32) -> U256: return U256(0) key_hash = keccak256(key) - leaf = _trie_lookup(self._node_db, storage_root, key_hash) + leaf = _trie_lookup( + self._get_decoded_secure_root(storage_root), key_hash + ) if leaf is None: return U256(0) From f379dceb83a853f81b9de214d2d0c1e06f3c8e55 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 24 Mar 2026 21:19:54 -0300 Subject: [PATCH 144/265] add more helpers --- .../eip8025_optional_proofs/state_helpers.py | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index da4877cfe5b..8cd1d1c54e5 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -2,15 +2,25 @@ from collections.abc import Mapping, Sequence +from ethereum.crypto.hash import Hash32, keccak256 from ethereum_types.bytes import Bytes32 -from ethereum_types.numeric import U256 -from execution_testing import Bytes, Storage +from ethereum_types.numeric import U256, Uint +from execution_testing import Account, Alloc, Bytes, Storage +from execution_testing.forks import Amsterdam from ethereum.forks.amsterdam.incremental_mpt import ( build_mpt, mpt_get, + mpt_root, mpt_set, ) +from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT +from ethereum.state import ( + EMPTY_CODE_HASH, + Account as StateAccount, + Address as StateAddress, + Root, +) def large_storage_value(slot: int) -> int: @@ -102,3 +112,69 @@ def collect_storage_post_state_only_nodes( pre_storage, pre_state_reference_slots ) return _nodes(post_state_nodes - pre_state_nodes) + + +def merge_with_amsterdam_pre_alloc(pre: Alloc) -> Alloc: + """Merge test-local accounts with Amsterdam's implicit pre-allocation.""" + return Alloc.merge( + Alloc.model_validate(Amsterdam.pre_allocation_blockchain()), + pre, + ) + + +def _storage_root_for_account(account: Account) -> Root: + """Compute the storage root for one execution-testing account.""" + if not account.storage.root: + return EMPTY_TRIE_ROOT + storage_mpt = build_mpt( + { + _slot(int(slot)): U256(int(value)) + for slot, value in account.storage.root.items() + }, + secured=True, + default=U256(0), + ) + return mpt_root(storage_mpt) + + +def _state_account(account: Account) -> StateAccount: + """Convert an execution-testing account into the spec account type.""" + code = bytes(account.code) + code_hash = ( + EMPTY_CODE_HASH if len(code) == 0 else Hash32(keccak256(code)) + ) + return StateAccount( + nonce=Uint(int(account.nonce)), + balance=U256(int(account.balance)), + code_hash=code_hash, + ) + + +def collect_account_proof_nodes( + alloc: Alloc, + addresses: Sequence[StateAddress | bytes], +) -> list[Bytes]: + """Collect account-trie proof nodes for the given addresses.""" + storage_roots: dict[StateAddress, Root] = {} + accounts: dict[StateAddress, StateAccount | None] = {} + + for address, account in alloc.items(): + state_address = StateAddress(bytes(address)) + if account is None: + accounts[state_address] = None + continue + storage_roots[state_address] = _storage_root_for_account(account) + accounts[state_address] = _state_account(account) + + def get_storage_root(address: StateAddress) -> Root: + return storage_roots.get(address, EMPTY_TRIE_ROOT) + + account_mpt = build_mpt( + accounts, + secured=True, + default=None, + get_storage_root=get_storage_root, + ) + for address in addresses: + mpt_get(account_mpt, StateAddress(bytes(address))) + return _nodes(set(account_mpt.witness.accessed_nodes.values())) From 9c9d6fd34809c1610676d175ac4202aa815d06ba Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 25 Mar 2026 11:22:52 -0300 Subject: [PATCH 145/265] add more mpt unit tests --- tests/json_infra/test_incremental_mpt.py | 139 +++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/json_infra/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py index aaad19ef751..dab8d792b9f 100644 --- a/tests/json_infra/test_incremental_mpt.py +++ b/tests/json_infra/test_incremental_mpt.py @@ -195,6 +195,21 @@ def _build_trie_and_collect_nodes( return expected_root, node_db +def _decode_root_from_rlp( + root_rlp: Bytes, + *, + secured: bool = False, +) -> IncrementalMPT[Bytes, Bytes]: + """Decode a single synthetic witness root from its RLP bytes.""" + root_hash = Root(keccak256(root_rlp)) + return decode_witness_to_mpt( + {Bytes(root_hash): root_rlp}, + root_hash, + secured=secured, + default=b"", + ) + + class TestDecodeWitnessToMpt: """Test decode_witness_to_mpt with synthetic witness data.""" @@ -219,6 +234,130 @@ def test_single_entry(self) -> None: ) assert mpt_root(mpt) == expected_root + +class TestMalformedWitnessNodes: + """Test malformed witness node decoding failures.""" + + def test_malformed_rlp_bytes(self) -> None: + """Malformed RLP should fail before node-shape validation.""" + with pytest.raises(rlp.DecodingError): + _decode_root_from_rlp(Bytes(b"\xc1")) + + def test_nonempty_byte_string_node(self) -> None: + """A decoded byte string node must be the empty node only.""" + with pytest.raises(AssertionError, match="Expected empty node"): + _decode_root_from_rlp(Bytes(rlp.encode(b"\x01"))) + + def test_invalid_rlp_node_length(self) -> None: + """Only 2-item and 17-item node lists are valid.""" + with pytest.raises( + AssertionError, match="Invalid RLP node length: 3" + ): + _decode_root_from_rlp(Bytes(rlp.encode([b"\x01", b"\x02", b"\x03"]))) + + def test_extension_with_empty_child_ref(self) -> None: + """Extension nodes must point to a branch child.""" + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b"\x01"), False), b""]) + ) + + with pytest.raises( + AssertionError, match="ExtensionNode child must be a BranchNode" + ): + _decode_root_from_rlp(root_rlp) + + def test_extension_pointing_to_leaf(self) -> None: + """Extensions may not point directly to leaf nodes.""" + leaf = [nibble_list_to_compact(Bytes(b"\x02"), True), b"value"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b"\x01"), False), leaf]) + ) + + with pytest.raises( + AssertionError, match="ExtensionNode child must be a BranchNode" + ): + _decode_root_from_rlp(root_rlp) + + def test_extension_pointing_to_extension(self) -> None: + """Extensions may not point directly to other extensions.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x03"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x04"), True), b"right"] + inner_extension = [ + nibble_list_to_compact(Bytes(b"\x02"), False), + branch, + ] + root_rlp = Bytes( + rlp.encode( + [ + nibble_list_to_compact(Bytes(b"\x01"), False), + inner_extension, + ] + ) + ) + + with pytest.raises( + AssertionError, match="ExtensionNode child must be a BranchNode" + ): + _decode_root_from_rlp(root_rlp) + + def test_extension_child_raw_nonzero_non_hash_bytes(self) -> None: + """Non-empty byte refs inside extensions must be 32-byte hashes.""" + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b"\x01"), False), b"\x01"]) + ) + + with pytest.raises(AssertionError, match="Unexpected child ref length"): + _decode_root_from_rlp(root_rlp) + + def test_branch_with_zero_occupied_entries(self) -> None: + """A branch node must encode at least two occupied entries.""" + with pytest.raises( + AssertionError, match="BranchNode must have at least 2 children" + ): + _decode_root_from_rlp(Bytes(rlp.encode([b""] * 17))) + + def test_branch_with_single_occupied_entry(self) -> None: + """A branch node with only one child is non-canonical.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"value"] + + with pytest.raises( + AssertionError, match="BranchNode must have at least 2 children" + ): + _decode_root_from_rlp(Bytes(rlp.encode(branch))) + + def test_secured_branch_with_value(self) -> None: + """Secured tries must not use the branch value slot.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[16] = b"terminal" + + with pytest.raises( + AssertionError, + match="BranchNode value must be empty in state/storage trie", + ): + _decode_root_from_rlp(Bytes(rlp.encode(branch)), secured=True) + + def test_secured_extension_with_empty_path(self) -> None: + """Secured tries must reject empty extension segments.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b""), False), branch]) + ) + + with pytest.raises( + AssertionError, + match="ExtensionNode must have a non-empty path", + ): + _decode_root_from_rlp(root_rlp, secured=True) + + +class TestDecodeWitnessToMptMore: + """Additional decode_witness_to_mpt roundtrip coverage.""" + def test_multiple_entries(self) -> None: """Decode a trie with multiple entries and verify root.""" data = { From 09df009b01766dae1067cf9992a4946eee18ef01 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 26 Mar 2026 22:06:51 -0300 Subject: [PATCH 146/265] json infra tests about invalid mpt node constructions --- tests/json_infra/test_witness_state.py | 163 ++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/tests/json_infra/test_witness_state.py b/tests/json_infra/test_witness_state.py index 28ee014c73e..738efe3f89e 100644 --- a/tests/json_infra/test_witness_state.py +++ b/tests/json_infra/test_witness_state.py @@ -2,17 +2,24 @@ from typing import Optional +import pytest +from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U256, Uint from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.forks.amsterdam.fork_types import encode_account from ethereum.forks.amsterdam.incremental_mpt import ( IncrementalMPT, build_mpt, mpt_get, mpt_root, ) -from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT +from ethereum.forks.amsterdam.trie import ( + EMPTY_TRIE_ROOT, + bytes_to_nibble_list, + nibble_list_to_compact, +) from ethereum.forks.amsterdam.witness_state import ( WitnessState, build_code_db, @@ -81,6 +88,25 @@ def _make_ws( ) +def _root_witness(root_rlp: Bytes) -> tuple[Root, dict[Bytes, Bytes]]: + """Build a synthetic witness DB keyed by one root node.""" + root_hash = Root(keccak256(root_rlp)) + return root_hash, {Bytes(root_hash): root_rlp} + + +def _single_account_state_witness( + *, + address: Address = _ADDR1, + storage_root: Root = EMPTY_TRIE_ROOT, +) -> tuple[Root, dict[Bytes, Bytes]]: + """Build a valid one-account state witness with a custom storage root.""" + account_leaf = [ + nibble_list_to_compact(bytes_to_nibble_list(keccak256(address)), True), + encode_account(_acct(), storage_root), + ] + return _root_witness(Bytes(rlp.encode(account_leaf))) + + class TestBuildNodeDb: """Test build_node_db.""" @@ -244,3 +270,138 @@ def test_no_changes_preserves_root(self) -> None: ) new_root, _ = witness_state.compute_state_root_and_trie_changes({}, {}) assert new_root == state_root + + +class TestCanonicalSecureTrieValidation: + """Test state/storage-trie canonicality checks in WitnessState.""" + + def test_account_trie_rejects_nonempty_branch_value(self) -> None: + """Secured account tries must not use the branch value slot.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[16] = b"terminal" + state_root, node_db = _root_witness(Bytes(rlp.encode(branch))) + witness_state = WitnessState( + _node_db=node_db, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="BranchNode value must be empty in state/storage trie", + ): + witness_state.get_account_optional(_ADDR1) + + def test_storage_trie_rejects_nonempty_branch_value(self) -> None: + """Secured storage tries must not use the branch value slot.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[16] = b"terminal" + storage_root, storage_node_db = _root_witness(Bytes(rlp.encode(branch))) + state_root, state_node_db = _single_account_state_witness( + storage_root=storage_root + ) + witness_state = WitnessState( + _node_db={**state_node_db, **storage_node_db}, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="BranchNode value must be empty in state/storage trie", + ): + witness_state.get_storage(_ADDR1, _SLOT1) + + def test_account_trie_rejects_zero_length_extension_path(self) -> None: + """Secured account tries must reject empty extension segments.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b""), False), branch]) + ) + state_root, node_db = _root_witness(root_rlp) + witness_state = WitnessState( + _node_db=node_db, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="ExtensionNode must have a non-empty path", + ): + witness_state.get_account_optional(_ADDR1) + + def test_storage_trie_rejects_zero_length_extension_path(self) -> None: + """Secured storage tries must reject empty extension segments.""" + branch = [b""] * 17 + branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] + branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] + root_rlp = Bytes( + rlp.encode([nibble_list_to_compact(Bytes(b""), False), branch]) + ) + storage_root, storage_node_db = _root_witness(root_rlp) + state_root, state_node_db = _single_account_state_witness( + storage_root=storage_root + ) + witness_state = WitnessState( + _node_db={**state_node_db, **storage_node_db}, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="ExtensionNode must have a non-empty path", + ): + witness_state.get_storage(_ADDR1, _SLOT1) + + def test_account_trie_rejects_unresolved_hashed_node(self) -> None: + """Secured account lookups must not silently pass unresolved hashes.""" + fake_hash = Bytes(b"\x11" * 32) + key_nibbles = bytes_to_nibble_list(keccak256(_ADDR1)) + root_rlp = Bytes( + rlp.encode( + [nibble_list_to_compact(key_nibbles[:1], False), fake_hash] + ) + ) + state_root, node_db = _root_witness(root_rlp) + witness_state = WitnessState( + _node_db=node_db, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="Encountered unresolved HashedNode during witness lookup", + ): + witness_state.get_account_optional(_ADDR1) + + def test_storage_trie_rejects_unresolved_hashed_node(self) -> None: + """Secured storage lookups must not silently pass unresolved hashes.""" + fake_hash = Bytes(b"\x22" * 32) + key_nibbles = bytes_to_nibble_list(keccak256(_SLOT1)) + root_rlp = Bytes( + rlp.encode( + [nibble_list_to_compact(key_nibbles[:1], False), fake_hash] + ) + ) + storage_root, storage_node_db = _root_witness(root_rlp) + state_root, state_node_db = _single_account_state_witness( + storage_root=storage_root + ) + witness_state = WitnessState( + _node_db={**state_node_db, **storage_node_db}, + _state_root=state_root, + _code_db={}, + ) + + with pytest.raises( + AssertionError, + match="Encountered unresolved HashedNode during witness lookup", + ): + witness_state.get_storage(_ADDR1, _SLOT1) From 24586add9462f64366e62c981c8538b3f6abb2aa Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 27 Mar 2026 18:01:12 -0300 Subject: [PATCH 147/265] add missing completenes test --- .../test_witness_state_reads.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py index 44191003152..4fb4ec13d5b 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py @@ -94,6 +94,46 @@ def test_witness_state_reverted_sload_still_contains_storage_proof( ) +def test_witness_state_reverted_inner_sload_still_contains_storage_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A reverted inner-call SLOAD should still leave its proof nodes in witness. + """ + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + assert proof_nodes + + callee = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.REVERT(0, 0), + storage=as_storage(storage), + ) + caller = pre.deploy_contract( + code=Op.CALL(Op.GAS, callee, 0, 0, 0, 0, 0) + Op.POP + Op.STOP, + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + callee: Account(storage=storage), + }, + ) + + def test_witness_state_sload_absent_slot_contains_storage_proof( pre: Alloc, blockchain_test: BlockchainTestFiller, From bd193b81ac472dfdebae7c22dd8e3f114ce5b10f Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 27 Mar 2026 20:07:30 -0300 Subject: [PATCH 148/265] add extra helpers --- .../eip8025_optional_proofs/state_helpers.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index 8cd1d1c54e5..b674523c2d3 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -5,7 +5,7 @@ from ethereum.crypto.hash import Hash32, keccak256 from ethereum_types.bytes import Bytes32 from ethereum_types.numeric import U256, Uint -from execution_testing import Account, Alloc, Bytes, Storage +from execution_testing import Account, Address, Alloc, Bytes, Storage from execution_testing.forks import Amsterdam from ethereum.forks.amsterdam.incremental_mpt import ( @@ -114,6 +114,21 @@ def collect_storage_post_state_only_nodes( return _nodes(post_state_nodes - pre_state_nodes) +def find_account_with_shared_secured_nibble( + target: Address, + excluded: set[Address], +) -> Address: + """Return an occupied address that deepens one account absence proof.""" + target_nibble = keccak256(bytes(target))[0] >> 4 + for value in range(0x100, 0x1000): + address = Address(value) + if address in excluded: + continue + if (keccak256(bytes(address))[0] >> 4) == target_nibble: + return address + raise AssertionError("failed to find non-precompile sibling address") + + def merge_with_amsterdam_pre_alloc(pre: Alloc) -> Alloc: """Merge test-local accounts with Amsterdam's implicit pre-allocation.""" return Alloc.merge( @@ -150,11 +165,11 @@ def _state_account(account: Account) -> StateAccount: ) -def collect_account_proof_nodes( +def _collect_account_node_set( alloc: Alloc, addresses: Sequence[StateAddress | bytes], -) -> list[Bytes]: - """Collect account-trie proof nodes for the given addresses.""" +) -> set[bytes]: + """Collect hashed witness nodes for the given account proof paths.""" storage_roots: dict[StateAddress, Root] = {} accounts: dict[StateAddress, StateAccount | None] = {} @@ -177,4 +192,23 @@ def get_storage_root(address: StateAddress) -> Root: ) for address in addresses: mpt_get(account_mpt, StateAddress(bytes(address))) - return _nodes(set(account_mpt.witness.accessed_nodes.values())) + return set(account_mpt.witness.accessed_nodes.values()) + + +def collect_account_proof_nodes( + alloc: Alloc, + addresses: Sequence[StateAddress | bytes], +) -> list[Bytes]: + """Collect account-trie proof nodes for the given addresses.""" + return _nodes(_collect_account_node_set(alloc, addresses)) + + +def collect_account_path_only_nodes( + alloc: Alloc, + address: StateAddress | bytes, + relative_to_addresses: Sequence[StateAddress | bytes], +) -> list[Bytes]: + """Collect nodes unique to one account proof path relative to others.""" + address_nodes = _collect_account_node_set(alloc, [address]) + reference_nodes = _collect_account_node_set(alloc, relative_to_addresses) + return _nodes(address_nodes - reference_nodes) From 2a25a76b7bd8f22ccca93bf60afdacfed263c4c5 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 27 Mar 2026 20:07:50 -0300 Subject: [PATCH 149/265] add reverted CALL execution witness state completeness test --- .../test_witness_state_reads.py | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py index 4fb4ec13d5b..d68e85a22a1 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py @@ -1,4 +1,4 @@ -"""Witness state collection scenarios for storage reads.""" +"""Witness state collection scenarios for state reads.""" import pytest from execution_testing import ( @@ -10,12 +10,15 @@ Op, Transaction, ) +from execution_testing.forks import Amsterdam from .state_helpers import ( as_storage, build_large_storage, + collect_account_proof_nodes, collect_storage_path_only_nodes, collect_storage_proof_nodes, + merge_with_amsterdam_pre_alloc, ) pytestmark = pytest.mark.valid_from("Amsterdam") @@ -134,6 +137,60 @@ def test_witness_state_reverted_inner_sload_still_contains_storage_proof( ) +def test_witness_state_failed_call_still_contains_target_account_proof( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A failed CALL should still include the target account proof.""" + target = pre.fund_eoa() + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + Op.CALL( + Op.GAS, + target, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + sender = pre.fund_eoa() + full_alloc = merge_with_amsterdam_pre_alloc(pre) + proof_nodes = collect_account_proof_nodes(full_alloc, [target]) + assert proof_nodes + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ) + ), + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + def test_witness_state_sload_absent_slot_contains_storage_proof( pre: Alloc, blockchain_test: BlockchainTestFiller, From 194cd5439ef9da0debb4ed56c1c2bbbf5ecf2d16 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 27 Mar 2026 20:08:05 -0300 Subject: [PATCH 150/265] add invalid execution witness state tests --- .../test_witness_validation_state.py | 390 +++++++++++++++++- 1 file changed, 388 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index dfd576ce2b6..6098d2057ab 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -1,23 +1,37 @@ """Execution witness state validation tests.""" import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes as TrieBytes from execution_testing import ( Account, Alloc, Block, BlockchainTestFiller, + Bytes, ExecutionWitnessStateExpectation, Op, Transaction, ) +from execution_testing.forks import Amsterdam from execution_testing.test_types.execution_witness.modifiers import ( + add_state_node, remove_state_node, + reverse_state_nodes, ) +from ethereum.forks.amsterdam.incremental_mpt import compact_to_nibbles + from .state_helpers import ( as_storage, + collect_account_path_only_nodes, build_large_storage, + collect_account_proof_nodes, + collect_storage_delete_auxiliary_nodes, collect_storage_proof_nodes, + find_account_with_shared_secured_nibble, + large_storage_value, + merge_with_amsterdam_pre_alloc, ) pytestmark = pytest.mark.valid_from("Amsterdam") @@ -26,6 +40,29 @@ REFERENCE_SPEC_VERSION = "N/A" +def _required_node(nodes: list[Bytes]) -> Bytes: + """Pick one required trie node from a proof set.""" + assert nodes + return sorted(nodes)[0] + + +def _leaf_node(nodes: list[Bytes]) -> Bytes: + """Pick the unique leaf node from a proof set.""" + leaves: list[Bytes] = [] + for node in nodes: + decoded = rlp.decode(bytes(node)) + if not isinstance(decoded, list) or len(decoded) != 2: + continue + path_bytes = decoded[0] + assert isinstance(path_bytes, (bytes, bytearray)) + _, is_leaf = compact_to_nibbles(TrieBytes(path_bytes)) + if is_leaf: + leaves.append(node) + + assert len(leaves) == 1 + return leaves[0] + + def test_validation_state_missing_storage_proof_node( pre: Alloc, blockchain_test: BlockchainTestFiller, @@ -35,8 +72,7 @@ def test_validation_state_missing_storage_proof_node( write_slot = 2 storage = build_large_storage([read_slot]) proof_nodes = collect_storage_proof_nodes(storage, [read_slot]) - assert proof_nodes - removed_node = sorted(proof_nodes)[0] + removed_node = _required_node(proof_nodes) contract = pre.deploy_contract( code=Op.SSTORE(write_slot, Op.SLOAD(read_slot)) + Op.STOP, @@ -64,3 +100,353 @@ def test_validation_state_missing_storage_proof_node( contract: Account(storage=post_storage), }, ) + + +def test_validation_state_missing_absent_slot_proof_leaf_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the absent-slot proof leaf should fail insertion.""" + # These slots produce a multi-node absence proof for slot 1: + # keccak(1) and keccak(24) share the first two nibbles ("b1"), while + # keccak(14) shares only the first nibble ("b"). That yields an + # extension -> branch -> leaf path, so this test can remove the leaf, + # thus the proof of absence is invalid due to missing data. + pre_storage = build_large_storage([14, 24]) + insert_slot = 1 + insert_value = large_storage_value(insert_slot) + proof_nodes = collect_storage_proof_nodes(pre_storage, [insert_slot]) + assert len(proof_nodes) == 3 + removed_node = _leaf_node(proof_nodes) + + contract = pre.deploy_contract( + code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, + storage=as_storage(pre_storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account( + storage={**pre_storage, insert_slot: insert_value}, + ), + }, + ) + + +def test_validation_state_missing_delete_auxiliary_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the delete-collapse auxiliary node should fail.""" + storage = build_large_storage([1, 2]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + auxiliary_nodes = collect_storage_delete_auxiliary_nodes(storage, 1) + assert len(auxiliary_nodes) == 1 + removed_node = auxiliary_nodes[0] + + contract = pre.deploy_contract( + code=Op.SSTORE(1, 0) + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes + auxiliary_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage={2: storage[2]}), + }, + ) + + +def test_validation_state_missing_account_trie_proof_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a required account proof node should fail a transfer.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + full_alloc = merge_with_amsterdam_pre_alloc(pre) + proof_nodes = collect_account_proof_nodes(full_alloc, [sender, recipient]) + removed_node = _required_node(proof_nodes) + + tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) + + +def test_validation_state_missing_absent_account_proof_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing a recipient-only absent-account proof node should fail.""" + sender = pre.fund_eoa() + recipient = pre.empty_account() + # Add one untouched sibling under the recipient's secured-trie prefix so + # the absence proof extends below the shared root node. + sibling = find_account_with_shared_secured_nibble( + recipient, + { + sender, + recipient, + *Amsterdam.execution_witness_implicit_code_addresses(), + }, + ) + pre.fund_address(sibling, 1) + full_alloc = merge_with_amsterdam_pre_alloc(pre) + recipient_only_nodes = sorted( + collect_account_path_only_nodes( + full_alloc, + recipient, + [sender, *Amsterdam.execution_witness_implicit_code_addresses()], + ) + ) + assert len(recipient_only_nodes) == 1 + proof_nodes = collect_account_proof_nodes(full_alloc, [recipient]) + removed_node = recipient_only_nodes[0] + + tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) + + +def test_validation_state_missing_failed_call_target_account_proof_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the failed CALL target proof should fail witness replay.""" + target = pre.fund_eoa() + caller_balance = 100 + transfer_value = 1_000 + caller_code = ( + Op.SSTORE( + 0, + Op.CALL( + Op.GAS, + target, + transfer_value, + 0, + 0, + 0, + 0, + ), + ) + + Op.STOP + ) + caller = pre.deploy_contract( + code=caller_code, + balance=caller_balance, + storage={0: 1}, + ) + sender = pre.fund_eoa() + full_alloc = merge_with_amsterdam_pre_alloc(pre) + proof_nodes = collect_account_proof_nodes(full_alloc, [target]) + assert proof_nodes + removed_node = _required_node(proof_nodes) + + tx = Transaction(sender=sender, to=caller, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + caller: Account(balance=caller_balance, storage={0: 0}), + }, + ) + + +def test_validation_state_missing_account_node_for_storage_resolution( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Removing the account proof that gates storage-root lookup should fail.""" + read_slot = 1 + storage = build_large_storage([read_slot]) + + contract = pre.deploy_contract( + code=Op.SLOAD(read_slot) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + full_alloc = merge_with_amsterdam_pre_alloc(pre) + account_proof_nodes = collect_account_proof_nodes(full_alloc, [contract]) + storage_proof_nodes = collect_storage_proof_nodes(storage, [read_slot]) + reference_account_nodes = collect_account_proof_nodes( + full_alloc, + [ + sender, + *Amsterdam.execution_witness_implicit_code_addresses(), + ], + ) + contract_only_account_nodes = sorted( + set(account_proof_nodes) - set(reference_account_nodes) + ) + + # The contract remaining node in the MPT proof is the account leaf node. + assert len(contract_only_account_nodes) == 1 + removed_node = contract_only_account_nodes[0] + + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=( + account_proof_nodes + storage_proof_nodes + ), + ).modify(remove_state_node(removed_node)) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_validation_state_extra_unused_trie_node( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Adding an unused state node should still validate.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(add_state_node(Bytes(b"\x81\x99"))) + ), + expected_stateless_validation_success=True, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) + + +def test_validation_state_unsorted_but_complete( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """Reordering a complete state witness should still validate.""" + storage = build_large_storage([1]) + proof_nodes = collect_storage_proof_nodes(storage, [1]) + + contract = pre.deploy_contract( + code=Op.SLOAD(1) + Op.POP + Op.STOP, + storage=as_storage(storage), + ) + sender = pre.fund_eoa() + tx = Transaction(sender=sender, to=contract, gas_limit=500_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_execution_witness_state=( + ExecutionWitnessStateExpectation( + nodes_present=proof_nodes, + ).modify(reverse_state_nodes()) + ), + expected_stateless_validation_success=True, + ) + ], + post={ + sender: Account(nonce=1), + contract: Account(storage=storage), + }, + ) From c18e120a36f611db2fca91e988ffbed993536a74 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 27 Mar 2026 20:21:10 -0300 Subject: [PATCH 151/265] lints --- .../forks/amsterdam/incremental_mpt.py | 6 ++-- src/ethereum/forks/amsterdam/witness_state.py | 12 ++++---- .../eip8025_optional_proofs/state_helpers.py | 16 ++++++----- .../test_witness_headers.py | 5 +++- .../test_witness_state_reads.py | 1 - .../test_witness_validation_state.py | 4 +-- tests/json_infra/test_incremental_mpt.py | 28 +++++++++++-------- tests/json_infra/test_witness_state.py | 14 ++++++---- 8 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index 496b034af3b..5a69faeff6c 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -903,9 +903,7 @@ def _resolve_child_ref( f"Unexpected child ref length: {len(ref_bytes)}" ) if ref_bytes in node_db: - return _decode_witness_node( - node_db, node_db[ref_bytes], secured - ) + return _decode_witness_node(node_db, node_db[ref_bytes], secured) return HashedNode(_hash=ref_bytes) else: return _decode_witness_node(node_db, rlp.encode(child_ref), secured) @@ -925,6 +923,8 @@ def _decode_witness_node( Mapping from node hash to RLP-encoded node data. rlp_bytes : The RLP-encoded node to decode. + secured : + Whether the trie uses secured (hashed) keys. """ node_hash: Optional[Bytes] = None diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index fc6d8d5fc59..2b7f0912bb2 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -153,13 +153,11 @@ def _get_decoded_secure_root(self, root_hash: Root) -> MutableNode: if root_hash == EMPTY_TRIE_ROOT: return None if root_hash not in self._decoded_secure_roots: - decoded_mpt: IncrementalMPT[Bytes, Bytes] = ( - decode_witness_to_mpt( - self._node_db, - root_hash, - secured=True, - default=b"", - ) + decoded_mpt: IncrementalMPT[Bytes, Bytes] = decode_witness_to_mpt( + self._node_db, + root_hash, + secured=True, + default=b"", ) self._decoded_secure_roots[root_hash] = decoded_mpt.root_node return self._decoded_secure_roots[root_hash] diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index b674523c2d3..82b5b9d8046 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -2,12 +2,12 @@ from collections.abc import Mapping, Sequence -from ethereum.crypto.hash import Hash32, keccak256 from ethereum_types.bytes import Bytes32 from ethereum_types.numeric import U256, Uint from execution_testing import Account, Address, Alloc, Bytes, Storage from execution_testing.forks import Amsterdam +from ethereum.crypto.hash import Hash32, keccak256 from ethereum.forks.amsterdam.incremental_mpt import ( build_mpt, mpt_get, @@ -17,9 +17,13 @@ from ethereum.forks.amsterdam.trie import EMPTY_TRIE_ROOT from ethereum.state import ( EMPTY_CODE_HASH, + Root, +) +from ethereum.state import ( Account as StateAccount, +) +from ethereum.state import ( Address as StateAddress, - Root, ) @@ -155,9 +159,7 @@ def _storage_root_for_account(account: Account) -> Root: def _state_account(account: Account) -> StateAccount: """Convert an execution-testing account into the spec account type.""" code = bytes(account.code) - code_hash = ( - EMPTY_CODE_HASH if len(code) == 0 else Hash32(keccak256(code)) - ) + code_hash = EMPTY_CODE_HASH if len(code) == 0 else Hash32(keccak256(code)) return StateAccount( nonce=Uint(int(account.nonce)), balance=U256(int(account.balance)), @@ -190,8 +192,8 @@ def get_storage_root(address: StateAddress) -> Root: default=None, get_storage_root=get_storage_root, ) - for address in addresses: - mpt_get(account_mpt, StateAddress(bytes(address))) + for addr in addresses: + mpt_get(account_mpt, StateAddress(bytes(addr))) return set(account_mpt.witness.accessed_nodes.values()) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py index 67a0e3c1520..b5f344c96b7 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_headers.py @@ -15,6 +15,7 @@ ) from execution_testing.client_clis import TransitionTool from execution_testing.fixtures import BlockchainFixture +from execution_testing.fixtures.blockchain import FixtureBlock from execution_testing.forks import Amsterdam from execution_testing.test_types.execution_witness.modifiers import ( prepend_header, @@ -324,7 +325,9 @@ def test_witness_headers_extra_unused_older_ancestor( .fixture ) assert isinstance(probe_fixture, BlockchainFixture) - extra_header = probe_fixture.blocks[0].header.rlp + probe_block = probe_fixture.blocks[0] + assert isinstance(probe_block, FixtureBlock) + extra_header = probe_block.header.rlp blocks = [Block(txs=[]) for _ in range(offset + 1)] blocks.append( diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py index d68e85a22a1..178ffbe503c 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_reads.py @@ -10,7 +10,6 @@ Op, Transaction, ) -from execution_testing.forks import Amsterdam from .state_helpers import ( as_storage, diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 6098d2057ab..57a2171ef26 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -24,8 +24,8 @@ from .state_helpers import ( as_storage, - collect_account_path_only_nodes, build_large_storage, + collect_account_path_only_nodes, collect_account_proof_nodes, collect_storage_delete_auxiliary_nodes, collect_storage_proof_nodes, @@ -331,7 +331,7 @@ def test_validation_state_missing_account_node_for_storage_resolution( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: - """Removing the account proof that gates storage-root lookup should fail.""" + """Removing the account proof gating storage-root lookup fails.""" read_slot = 1 storage = build_large_storage([read_slot]) diff --git a/tests/json_infra/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py index dab8d792b9f..7d868bd9049 100644 --- a/tests/json_infra/test_incremental_mpt.py +++ b/tests/json_infra/test_incremental_mpt.py @@ -1,5 +1,7 @@ """Tests for the incremental MPT witness decoding and HashedNode.""" +from typing import Any + import pytest from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -250,10 +252,10 @@ def test_nonempty_byte_string_node(self) -> None: def test_invalid_rlp_node_length(self) -> None: """Only 2-item and 17-item node lists are valid.""" - with pytest.raises( - AssertionError, match="Invalid RLP node length: 3" - ): - _decode_root_from_rlp(Bytes(rlp.encode([b"\x01", b"\x02", b"\x03"]))) + with pytest.raises(AssertionError, match="Invalid RLP node length: 3"): + _decode_root_from_rlp( + Bytes(rlp.encode([b"\x01", b"\x02", b"\x03"])) + ) def test_extension_with_empty_child_ref(self) -> None: """Extension nodes must point to a branch child.""" @@ -280,10 +282,10 @@ def test_extension_pointing_to_leaf(self) -> None: def test_extension_pointing_to_extension(self) -> None: """Extensions may not point directly to other extensions.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x03"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x04"), True), b"right"] - inner_extension = [ + inner_extension: list[Any] = [ nibble_list_to_compact(Bytes(b"\x02"), False), branch, ] @@ -304,10 +306,14 @@ def test_extension_pointing_to_extension(self) -> None: def test_extension_child_raw_nonzero_non_hash_bytes(self) -> None: """Non-empty byte refs inside extensions must be 32-byte hashes.""" root_rlp = Bytes( - rlp.encode([nibble_list_to_compact(Bytes(b"\x01"), False), b"\x01"]) + rlp.encode( + [nibble_list_to_compact(Bytes(b"\x01"), False), b"\x01"] + ) ) - with pytest.raises(AssertionError, match="Unexpected child ref length"): + with pytest.raises( + AssertionError, match="Unexpected child ref length" + ): _decode_root_from_rlp(root_rlp) def test_branch_with_zero_occupied_entries(self) -> None: @@ -319,7 +325,7 @@ def test_branch_with_zero_occupied_entries(self) -> None: def test_branch_with_single_occupied_entry(self) -> None: """A branch node with only one child is non-canonical.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"value"] with pytest.raises( @@ -329,7 +335,7 @@ def test_branch_with_single_occupied_entry(self) -> None: def test_secured_branch_with_value(self) -> None: """Secured tries must not use the branch value slot.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[16] = b"terminal" @@ -341,7 +347,7 @@ def test_secured_branch_with_value(self) -> None: def test_secured_extension_with_empty_path(self) -> None: """Secured tries must reject empty extension segments.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] root_rlp = Bytes( diff --git a/tests/json_infra/test_witness_state.py b/tests/json_infra/test_witness_state.py index 738efe3f89e..dc7f9c88e74 100644 --- a/tests/json_infra/test_witness_state.py +++ b/tests/json_infra/test_witness_state.py @@ -1,6 +1,6 @@ """Tests for WitnessState.""" -from typing import Optional +from typing import Any, Optional import pytest from ethereum_rlp import rlp @@ -277,7 +277,7 @@ class TestCanonicalSecureTrieValidation: def test_account_trie_rejects_nonempty_branch_value(self) -> None: """Secured account tries must not use the branch value slot.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[16] = b"terminal" state_root, node_db = _root_witness(Bytes(rlp.encode(branch))) @@ -295,10 +295,12 @@ def test_account_trie_rejects_nonempty_branch_value(self) -> None: def test_storage_trie_rejects_nonempty_branch_value(self) -> None: """Secured storage tries must not use the branch value slot.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[16] = b"terminal" - storage_root, storage_node_db = _root_witness(Bytes(rlp.encode(branch))) + storage_root, storage_node_db = _root_witness( + Bytes(rlp.encode(branch)) + ) state_root, state_node_db = _single_account_state_witness( storage_root=storage_root ) @@ -316,7 +318,7 @@ def test_storage_trie_rejects_nonempty_branch_value(self) -> None: def test_account_trie_rejects_zero_length_extension_path(self) -> None: """Secured account tries must reject empty extension segments.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] root_rlp = Bytes( @@ -337,7 +339,7 @@ def test_account_trie_rejects_zero_length_extension_path(self) -> None: def test_storage_trie_rejects_zero_length_extension_path(self) -> None: """Secured storage tries must reject empty extension segments.""" - branch = [b""] * 17 + branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] root_rlp = Bytes( From 90113007bc366910dc62b102afa33185d1a27771 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 12:39:04 -0300 Subject: [PATCH 152/265] simplify --- .../forks/amsterdam/incremental_mpt.py | 23 +++------- tests/json_infra/test_incremental_mpt.py | 18 ++------ tests/json_infra/test_witness_state.py | 45 +------------------ 3 files changed, 12 insertions(+), 74 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index 5a69faeff6c..f42398b66f6 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -886,7 +886,6 @@ def compact_to_nibbles(compact: Bytes) -> Tuple[Bytes, bool]: def _resolve_child_ref( node_db: Dict[Bytes, Bytes], child_ref: Extended, - secured: bool, ) -> MutableNode: """ Resolve a child reference from an RLP-decoded trie node. @@ -903,16 +902,15 @@ def _resolve_child_ref( f"Unexpected child ref length: {len(ref_bytes)}" ) if ref_bytes in node_db: - return _decode_witness_node(node_db, node_db[ref_bytes], secured) + return _decode_witness_node(node_db, node_db[ref_bytes]) return HashedNode(_hash=ref_bytes) else: - return _decode_witness_node(node_db, rlp.encode(child_ref), secured) + return _decode_witness_node(node_db, rlp.encode(child_ref)) def _decode_witness_node( node_db: Dict[Bytes, Bytes], rlp_bytes: Bytes, - secured: bool, ) -> MutableNode: """ Decode an RLP-encoded trie node into a ``MutableNode``. @@ -923,9 +921,6 @@ def _decode_witness_node( Mapping from node hash to RLP-encoded node data. rlp_bytes : The RLP-encoded node to decode. - secured : - Whether the trie uses secured (hashed) keys. - """ node_hash: Optional[Bytes] = None if len(rlp_bytes) >= 32: @@ -954,11 +949,10 @@ def _decode_witness_node( _rlp=rlp_bytes, ) else: - assert len(nibbles) > 0 or not secured, ( - "ExtensionNode must have a non-empty path " - "in state/storage trie" + assert len(nibbles) > 0, ( + "ExtensionNode must have a non-empty path" ) - child = _resolve_child_ref(node_db, decoded[1], secured) + child = _resolve_child_ref(node_db, decoded[1]) assert isinstance(child, (MutableBranchNode, HashedNode)), ( "ExtensionNode child must be a BranchNode" ) @@ -972,15 +966,12 @@ def _decode_witness_node( elif len(decoded) == 17: children: List[Optional[MutableNode]] = [] for i in range(16): - children.append(_resolve_child_ref(node_db, decoded[i], secured)) + children.append(_resolve_child_ref(node_db, decoded[i])) value_raw = decoded[16] if isinstance(value_raw, (bytes, bytearray)): value = Bytes(value_raw) else: value = b"" - assert value == b"" or not secured, ( - "BranchNode value must be empty in state/storage trie" - ) occupied = 16 - children.count(None) + (value != b"") assert occupied >= 2, "BranchNode must have at least 2 children" return MutableBranchNode( @@ -1032,7 +1023,7 @@ def decode_witness_to_mpt( ) root_rlp = node_db[root_hash] - root_node = _decode_witness_node(node_db, root_rlp, secured) + root_node = _decode_witness_node(node_db, root_rlp) return IncrementalMPT( secured=secured, diff --git a/tests/json_infra/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py index 7d868bd9049..3ec76312a31 100644 --- a/tests/json_infra/test_incremental_mpt.py +++ b/tests/json_infra/test_incremental_mpt.py @@ -333,20 +333,8 @@ def test_branch_with_single_occupied_entry(self) -> None: ): _decode_root_from_rlp(Bytes(rlp.encode(branch))) - def test_secured_branch_with_value(self) -> None: - """Secured tries must not use the branch value slot.""" - branch: list[Any] = [b""] * 17 - branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] - branch[16] = b"terminal" - - with pytest.raises( - AssertionError, - match="BranchNode value must be empty in state/storage trie", - ): - _decode_root_from_rlp(Bytes(rlp.encode(branch)), secured=True) - - def test_secured_extension_with_empty_path(self) -> None: - """Secured tries must reject empty extension segments.""" + def test_extension_with_empty_path(self) -> None: + """Tries must reject empty extension segments.""" branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] @@ -358,7 +346,7 @@ def test_secured_extension_with_empty_path(self) -> None: AssertionError, match="ExtensionNode must have a non-empty path", ): - _decode_root_from_rlp(root_rlp, secured=True) + _decode_root_from_rlp(root_rlp) class TestDecodeWitnessToMptMore: diff --git a/tests/json_infra/test_witness_state.py b/tests/json_infra/test_witness_state.py index dc7f9c88e74..f2770b85666 100644 --- a/tests/json_infra/test_witness_state.py +++ b/tests/json_infra/test_witness_state.py @@ -275,49 +275,8 @@ def test_no_changes_preserves_root(self) -> None: class TestCanonicalSecureTrieValidation: """Test state/storage-trie canonicality checks in WitnessState.""" - def test_account_trie_rejects_nonempty_branch_value(self) -> None: - """Secured account tries must not use the branch value slot.""" - branch: list[Any] = [b""] * 17 - branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] - branch[16] = b"terminal" - state_root, node_db = _root_witness(Bytes(rlp.encode(branch))) - witness_state = WitnessState( - _node_db=node_db, - _state_root=state_root, - _code_db={}, - ) - - with pytest.raises( - AssertionError, - match="BranchNode value must be empty in state/storage trie", - ): - witness_state.get_account_optional(_ADDR1) - - def test_storage_trie_rejects_nonempty_branch_value(self) -> None: - """Secured storage tries must not use the branch value slot.""" - branch: list[Any] = [b""] * 17 - branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] - branch[16] = b"terminal" - storage_root, storage_node_db = _root_witness( - Bytes(rlp.encode(branch)) - ) - state_root, state_node_db = _single_account_state_witness( - storage_root=storage_root - ) - witness_state = WitnessState( - _node_db={**state_node_db, **storage_node_db}, - _state_root=state_root, - _code_db={}, - ) - - with pytest.raises( - AssertionError, - match="BranchNode value must be empty in state/storage trie", - ): - witness_state.get_storage(_ADDR1, _SLOT1) - def test_account_trie_rejects_zero_length_extension_path(self) -> None: - """Secured account tries must reject empty extension segments.""" + """Account tries must reject empty extension segments.""" branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] @@ -338,7 +297,7 @@ def test_account_trie_rejects_zero_length_extension_path(self) -> None: witness_state.get_account_optional(_ADDR1) def test_storage_trie_rejects_zero_length_extension_path(self) -> None: - """Secured storage tries must reject empty extension segments.""" + """Storage tries must reject empty extension segments.""" branch: list[Any] = [b""] * 17 branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"left"] branch[1] = [nibble_list_to_compact(Bytes(b"\x02"), True), b"right"] From ce24b36b2b351d5698ebbff36020aff5f50312e2 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 12:43:35 -0300 Subject: [PATCH 153/265] improve assertion message --- src/ethereum/forks/amsterdam/incremental_mpt.py | 4 +++- tests/json_infra/test_incremental_mpt.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index f42398b66f6..37e6f022f15 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -973,7 +973,9 @@ def _decode_witness_node( else: value = b"" occupied = 16 - children.count(None) + (value != b"") - assert occupied >= 2, "BranchNode must have at least 2 children" + assert occupied >= 2, ( + "BranchNode must have at least 2 occupied entries" + ) return MutableBranchNode( children=children, value=value, diff --git a/tests/json_infra/test_incremental_mpt.py b/tests/json_infra/test_incremental_mpt.py index 3ec76312a31..699e2be5573 100644 --- a/tests/json_infra/test_incremental_mpt.py +++ b/tests/json_infra/test_incremental_mpt.py @@ -319,7 +319,8 @@ def test_extension_child_raw_nonzero_non_hash_bytes(self) -> None: def test_branch_with_zero_occupied_entries(self) -> None: """A branch node must encode at least two occupied entries.""" with pytest.raises( - AssertionError, match="BranchNode must have at least 2 children" + AssertionError, + match="BranchNode must have at least 2 occupied entries", ): _decode_root_from_rlp(Bytes(rlp.encode([b""] * 17))) @@ -329,7 +330,8 @@ def test_branch_with_single_occupied_entry(self) -> None: branch[0] = [nibble_list_to_compact(Bytes(b"\x01"), True), b"value"] with pytest.raises( - AssertionError, match="BranchNode must have at least 2 children" + AssertionError, + match="BranchNode must have at least 2 occupied entries", ): _decode_root_from_rlp(Bytes(rlp.encode(branch))) From b0c4bed6a5f61b34d08924f78172ec33d904a31b Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 13:26:22 -0300 Subject: [PATCH 154/265] remove case since previous if for extension node make this impossible --- src/ethereum/forks/amsterdam/witness_state.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index 2b7f0912bb2..2ab1724549b 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -91,10 +91,6 @@ def _trie_lookup( f"Unexpected node type {type(node)}" ) - assert pos <= len(nibbles), ( - "BranchNode cannot appear past the end of a key " - "in state/storage trie" - ) if pos == len(nibbles): return node.value or None idx = nibbles[pos] From 4e9549e1c7c36c74ffd05ec2d9790be6ebb37531 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 13:45:42 -0300 Subject: [PATCH 155/265] better naming --- .../eip8025_optional_proofs/state_helpers.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py index 82b5b9d8046..dcddebf69dc 100644 --- a/tests/amsterdam/eip8025_optional_proofs/state_helpers.py +++ b/tests/amsterdam/eip8025_optional_proofs/state_helpers.py @@ -167,11 +167,11 @@ def _state_account(account: Account) -> StateAccount: ) -def _collect_account_node_set( +def _collect_account_proof_node_rlps( alloc: Alloc, addresses: Sequence[StateAddress | bytes], ) -> set[bytes]: - """Collect hashed witness nodes for the given account proof paths.""" + """Collect witness node RLPs for hashed account proof-path nodes.""" storage_roots: dict[StateAddress, Root] = {} accounts: dict[StateAddress, StateAccount | None] = {} @@ -202,7 +202,7 @@ def collect_account_proof_nodes( addresses: Sequence[StateAddress | bytes], ) -> list[Bytes]: """Collect account-trie proof nodes for the given addresses.""" - return _nodes(_collect_account_node_set(alloc, addresses)) + return _nodes(_collect_account_proof_node_rlps(alloc, addresses)) def collect_account_path_only_nodes( @@ -211,6 +211,8 @@ def collect_account_path_only_nodes( relative_to_addresses: Sequence[StateAddress | bytes], ) -> list[Bytes]: """Collect nodes unique to one account proof path relative to others.""" - address_nodes = _collect_account_node_set(alloc, [address]) - reference_nodes = _collect_account_node_set(alloc, relative_to_addresses) + address_nodes = _collect_account_proof_node_rlps(alloc, [address]) + reference_nodes = _collect_account_proof_node_rlps( + alloc, relative_to_addresses + ) return _nodes(address_nodes - reference_nodes) From 792c7fea6c3d1aee45d1f9ee91ee3cdb30c16ce4 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 13:50:35 -0300 Subject: [PATCH 156/265] lint --- src/ethereum/forks/amsterdam/incremental_mpt.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index 37e6f022f15..d0893057d2f 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -921,6 +921,7 @@ def _decode_witness_node( Mapping from node hash to RLP-encoded node data. rlp_bytes : The RLP-encoded node to decode. + """ node_hash: Optional[Bytes] = None if len(rlp_bytes) >= 32: @@ -949,9 +950,7 @@ def _decode_witness_node( _rlp=rlp_bytes, ) else: - assert len(nibbles) > 0, ( - "ExtensionNode must have a non-empty path" - ) + assert len(nibbles) > 0, "ExtensionNode must have a non-empty path" child = _resolve_child_ref(node_db, decoded[1]) assert isinstance(child, (MutableBranchNode, HashedNode)), ( "ExtensionNode child must be a BranchNode" From eba127e7684c7d6f972e77f60946c2a9a6df8b76 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 14:13:17 -0300 Subject: [PATCH 157/265] improve comment --- .../test_witness_validation_state.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 57a2171ef26..3ed57d3a86a 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -107,11 +107,29 @@ def test_validation_state_missing_absent_slot_proof_leaf_node( blockchain_test: BlockchainTestFiller, ) -> None: """Removing the absent-slot proof leaf should fail insertion.""" + # The contract will insert a value to a non-existent slot 1. + # These slots produce a multi-node absence proof for slot 1: - # keccak(1) and keccak(24) share the first two nibbles ("b1"), while - # keccak(14) shares only the first nibble ("b"). That yields an - # extension -> branch -> leaf path, so this test can remove the leaf, - # thus the proof of absence is invalid due to missing data. + # + # keccak(1) -> b1... + # keccak(24) -> b1... + # keccak(14) -> bx... (x != 1) + # + # ext("b") (root) + # | + # branch + # / \ + # [x] [1] + # | | + # leaf leaf + # (14) (24) + # ^ + # | + # absent slot 1 follows this edge, then diverges inside the leaf + # + # The proof for slot 1 is therefore `extension -> branch -> leaf`. + # Removing that leaf makes the absence proof invalid due to missing + # data. pre_storage = build_large_storage([14, 24]) insert_slot = 1 insert_value = large_storage_value(insert_slot) From 6f143cb6a1f764fd209ac49ff959c97462d47fc4 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 14:34:08 -0300 Subject: [PATCH 158/265] use better selector of node to remove --- .../eip8025_optional_proofs/test_witness_validation_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 3ed57d3a86a..26ff091b21f 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -321,7 +321,7 @@ def test_validation_state_missing_failed_call_target_account_proof_node( full_alloc = merge_with_amsterdam_pre_alloc(pre) proof_nodes = collect_account_proof_nodes(full_alloc, [target]) assert proof_nodes - removed_node = _required_node(proof_nodes) + removed_node = _leaf_node(proof_nodes) tx = Transaction(sender=sender, to=caller, gas_limit=500_000) From 29f0668dd83911220bc0acfde0493bd6cdfab96a Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 14:52:08 -0300 Subject: [PATCH 159/265] improve test setup --- .../test_witness_validation_state.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 26ff091b21f..746cdda722d 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -204,16 +204,22 @@ def test_validation_state_missing_delete_auxiliary_node( ) -def test_validation_state_missing_account_trie_proof_node( +def test_validation_state_missing_sender_account_proof_leaf_node( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: - """Removing a required account proof node should fail a transfer.""" + """Removing the sender account proof leaf should fail a transfer.""" sender = pre.fund_eoa() - recipient = pre.fund_eoa(amount=0) + recipient = pre.fund_eoa(amount=1) full_alloc = merge_with_amsterdam_pre_alloc(pre) proof_nodes = collect_account_proof_nodes(full_alloc, [sender, recipient]) - removed_node = _required_node(proof_nodes) + sender_only_nodes = collect_account_path_only_nodes( + full_alloc, + sender, + [recipient, *Amsterdam.execution_witness_implicit_code_addresses()], + ) + assert len(sender_only_nodes) == 1 + removed_node = _leaf_node(sender_only_nodes) tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) @@ -232,7 +238,7 @@ def test_validation_state_missing_account_trie_proof_node( ], post={ sender: Account(nonce=1), - recipient: Account(balance=1), + recipient: Account(balance=2), }, ) From 9a67939e07b1391cc394af34612437f81bf6b094 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 15:08:19 -0300 Subject: [PATCH 160/265] remove test since doesn't strictly create a new failure case --- .../test_witness_validation_state.py | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 746cdda722d..9b76d4e14a7 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -351,60 +351,6 @@ def test_validation_state_missing_failed_call_target_account_proof_node( ) -def test_validation_state_missing_account_node_for_storage_resolution( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """Removing the account proof gating storage-root lookup fails.""" - read_slot = 1 - storage = build_large_storage([read_slot]) - - contract = pre.deploy_contract( - code=Op.SLOAD(read_slot) + Op.POP + Op.STOP, - storage=as_storage(storage), - ) - sender = pre.fund_eoa() - full_alloc = merge_with_amsterdam_pre_alloc(pre) - account_proof_nodes = collect_account_proof_nodes(full_alloc, [contract]) - storage_proof_nodes = collect_storage_proof_nodes(storage, [read_slot]) - reference_account_nodes = collect_account_proof_nodes( - full_alloc, - [ - sender, - *Amsterdam.execution_witness_implicit_code_addresses(), - ], - ) - contract_only_account_nodes = sorted( - set(account_proof_nodes) - set(reference_account_nodes) - ) - - # The contract remaining node in the MPT proof is the account leaf node. - assert len(contract_only_account_nodes) == 1 - removed_node = contract_only_account_nodes[0] - - tx = Transaction(sender=sender, to=contract, gas_limit=500_000) - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_execution_witness_state=( - ExecutionWitnessStateExpectation( - nodes_present=( - account_proof_nodes + storage_proof_nodes - ), - ).modify(remove_state_node(removed_node)) - ), - expected_stateless_validation_success=False, - ) - ], - post={ - sender: Account(nonce=1), - contract: Account(storage=storage), - }, - ) - def test_validation_state_extra_unused_trie_node( pre: Alloc, From 17e69cefd090622c7d887c3d936701049b50818e Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 30 Mar 2026 15:08:59 -0300 Subject: [PATCH 161/265] lint --- .../eip8025_optional_proofs/test_witness_validation_state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index 9b76d4e14a7..7d2a4016a7c 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -351,7 +351,6 @@ def test_validation_state_missing_failed_call_target_account_proof_node( ) - def test_validation_state_extra_unused_trie_node( pre: Alloc, blockchain_test: BlockchainTestFiller, From e2ff4cc00ca94cb5872090e0f813894e231f6be3 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 3 Apr 2026 18:27:40 -0300 Subject: [PATCH 162/265] lints --- .../testing/src/execution_testing/specs/blockchain.py | 10 +++++----- src/ethereum/forks/amsterdam/incremental_mpt.py | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 508252b857d..7b9995fe4c2 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -89,12 +89,12 @@ BlockAccessList, BlockAccessListExpectation, ) +from execution_testing.test_types.chain_config_types import ChainConfigDefaults from execution_testing.test_types.execution_witness import ( ExecutionWitnessCodesExpectation, ExecutionWitnessHeadersExpectation, ExecutionWitnessStateExpectation, ) -from execution_testing.test_types.chain_config_types import ChainConfigDefaults from .base import BaseTest, FillResult, OpMode, verify_result from .debugging import print_traces @@ -989,7 +989,7 @@ def generate_block_data( effective_codes_expectation = ( with_execution_witness_implicit_codes( expectation=block.expected_execution_witness_codes, - fork=self.fork, + fork=fork, alloc=previous_alloc, block_number=env.number, timestamp=env.timestamp, @@ -1008,7 +1008,7 @@ def generate_block_data( headers_expectation.verify_against( execution_witness, parent_hash=header.parent_hash, - fork=self.fork, + fork=fork, ) execution_witness = headers_expectation.modify_if_invalid_test( execution_witness @@ -1054,7 +1054,7 @@ def generate_block_data( ) canonical_successful_validation = ( deserialize_amsterdam_stateless_output_success( - fork=self.fork, + fork=fork, block_number=int(env.number), timestamp=int(env.timestamp), stateless_output_bytes=stateless_output_bytes, @@ -1073,7 +1073,7 @@ def generate_block_data( stateless_output_bytes, successful_validation, ) = rerun_amsterdam_stateless_guest_with_witness( - fork=self.fork, + fork=fork, block_number=int(env.number), timestamp=int(env.timestamp), original_stateless_input_bytes=stateless_input_bytes, diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index d0893057d2f..3d2e655ab4c 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -449,6 +449,8 @@ def mpt_set( assert get_storage_root is not None address = Address(key) encoded_value = encode_node(value, get_storage_root(address)) + elif value is None: + raise AssertionError("cannot encode `None`") else: encoded_value = encode_node(value) From 1b731e350d2dc64e792deeab4a3f6914c10997e5 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 13 Apr 2026 11:02:38 -0300 Subject: [PATCH 163/265] feat(tests): add execution witness support to rest of formats --- .../test_formats/blockchain_test_engine.md | 5 ++++ .../test_formats/blockchain_test_engine_x.md | 5 +++- .../test_formats/blockchain_test_sync.md | 5 ++++ .../plugins/filler/tests/test_filler.py | 30 +++++++++++++++++-- .../execution_testing/fixtures/blockchain.py | 3 ++ .../fixtures/tests/test_blockchain.py | 11 +++++++ .../src/execution_testing/specs/blockchain.py | 1 + 7 files changed, 57 insertions(+), 3 deletions(-) diff --git a/docs/running_tests/test_formats/blockchain_test_engine.md b/docs/running_tests/test_formats/blockchain_test_engine.md index 6a525a9eba3..75aad79c19c 100644 --- a/docs/running_tests/test_formats/blockchain_test_engine.md +++ b/docs/running_tests/test_formats/blockchain_test_engine.md @@ -104,6 +104,11 @@ They can mismatch the hashes of the versioned blobs in the execution payload, fo Hash of the parent beacon block root. +#### - `executionWitness`: Optional execution witness object + +Optional fixture metadata for stateless validation. When present, contains +`state`, `codes`, and `headers` byte lists associated with this payload. + #### - `validationError`: [`TransactionException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.TransactionException)` | `[`BlockException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.BlockException) Validation error expected when executing the payload. diff --git a/docs/running_tests/test_formats/blockchain_test_engine_x.md b/docs/running_tests/test_formats/blockchain_test_engine_x.md index c9dc90a4caf..e6de61481d4 100644 --- a/docs/running_tests/test_formats/blockchain_test_engine_x.md +++ b/docs/running_tests/test_formats/blockchain_test_engine_x.md @@ -134,7 +134,10 @@ Optional; present from Cancun on. Maps forks to their blob schedule configuratio ### `FixtureEngineNewPayload` -Engine API payload structure identical to the one defined in [Blockchain Engine Tests](./blockchain_test_engine.md#fixtureenginenewpayload). Includes execution payload, versioned hashes, parent beacon block root, validation errors, version, and error codes. +Engine API payload structure identical to the one defined in [Blockchain Engine +Tests](./blockchain_test_engine.md#fixtureenginenewpayload). Includes +execution payload, optional execution witness metadata, versioned hashes, +parent beacon block root, validation errors, version, and error codes. ## Usage Notes diff --git a/docs/running_tests/test_formats/blockchain_test_sync.md b/docs/running_tests/test_formats/blockchain_test_sync.md index 5afbaa0a6d2..fc1f08f9aa9 100644 --- a/docs/running_tests/test_formats/blockchain_test_sync.md +++ b/docs/running_tests/test_formats/blockchain_test_sync.md @@ -127,6 +127,11 @@ List of hashes of the versioned blobs that are part of the execution payload. Hash of the parent beacon block root. +#### - `executionWitness`: Optional execution witness object + +Optional fixture metadata for stateless validation. When present, contains +`state`, `codes`, and `headers` byte lists associated with this payload. + #### - `validationError`: [`Optional`](./common_types.md#optional)`[`[`TransactionException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.TransactionException)` | `[`BlockException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.BlockException)`]` For sync tests, this field should not be present as sync tests only work with valid chains. Invalid blocks cannot be synced. diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index e7761a978b0..1d9fd1a6de8 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -993,8 +993,8 @@ def test_execution_witness_in_blockchain_fixture( ) -> None: """ Fill a minimal Amsterdam state_test that calls a pre-deployed contract, - then verify the resulting blockchain fixture contains execution witness - and stateless validation fields. + then verify the resulting blockchain and engine fixtures contain + execution witness data. """ tests_dir = testdir.mkdir("tests") amsterdam_tests_dir = tests_dir.mkdir("amsterdam") @@ -1055,6 +1055,32 @@ def test_execution_witness_in_blockchain_fixture( ) assert stateless_output.successful_validation is True + engine_fixture_path = Path( + "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" + "module_execution_witness/execution_witness.json" + ) + assert engine_fixture_path.exists(), f"{engine_fixture_path} does not exist" + + with open(engine_fixture_path, "r") as f: + engine_fixture_data = json.load(f) + + assert len(engine_fixture_data) == 1, "Expected exactly one engine fixture" + engine_fixture = next(iter(engine_fixture_data.values())) + engine_payload = engine_fixture["engineNewPayloads"][0] + + assert "executionWitness" in engine_payload + engine_witness = engine_payload["executionWitness"] + assert ( + len(engine_witness["state"]) > 0 + ), "engine executionWitness.state is empty" + assert ( + len(engine_witness["codes"]) > 0 + ), "engine executionWitness.codes is empty" + assert ( + len(engine_witness["headers"]) > 0 + ), "engine executionWitness.headers is empty" + assert engine_witness == witness + def test_execution_witness_expected_true_reuses_canonical_stateless_result( testdir: pytest.Testdir, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 2bd47d133af..1b7437c70a9 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -457,6 +457,7 @@ class FixtureEngineNewPayload(CamelModel): params: EngineNewPayloadParameters new_payload_version: Number forkchoice_updated_version: Number + execution_witness: ExecutionWitness | None = None validation_error: ExceptionInstanceOrList | None = None error_code: ( Annotated[ @@ -482,6 +483,7 @@ def from_fixture_header( withdrawals: List[Withdrawal] | None, requests: List[Bytes] | None, block_access_list: Bytes | None = None, + execution_witness: ExecutionWitness | None = None, **kwargs: Any, ) -> Self: """Create `FixtureEngineNewPayload` from a `FixtureHeader`.""" @@ -534,6 +536,7 @@ def from_fixture_header( params=payload_params, new_payload_version=new_payload_version, forkchoice_updated_version=forkchoice_updated_version, + execution_witness=execution_witness, **kwargs, ) diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py index e8c68fd7ea8..7a070248714 100644 --- a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py @@ -29,6 +29,7 @@ AuthorizationTuple, ConsolidationRequest, DepositRequest, + ExecutionWitness, Requests, Transaction, Withdrawal, @@ -737,6 +738,11 @@ target_pubkey=BLSPublicKey(2), ), ).requests_list, + execution_witness=ExecutionWitness( + state=[Bytes(b"state")], + codes=[Bytes(b"code")], + headers=[Bytes(b"header")], + ), validation_error=[ BlockException.INCORRECT_BLOCK_FORMAT, TransactionException.INTRINSIC_GAS_TOO_LOW, @@ -821,6 +827,11 @@ ).requests_list ], ], + "executionWitness": { + "state": [Bytes(b"state").hex()], + "codes": [Bytes(b"code").hex()], + "headers": [Bytes(b"header").hex()], + }, "forkchoiceUpdatedVersion": "3", "newPayloadVersion": "4", "validationError": "BlockException.INCORRECT_BLOCK_FORMAT" diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 7b9995fe4c2..40b19bb096d 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -658,6 +658,7 @@ def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: block_access_list=self.block_access_list.rlp if self.block_access_list else None, + execution_witness=self.execution_witness, validation_error=self.expected_exception, error_code=self.engine_api_error_code, ) From 3ffbe59bd3fd24c4506f91eebbbd6bcc3d0eacd7 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 13 Apr 2026 11:17:24 -0300 Subject: [PATCH 164/265] lints --- .../plugins/filler/tests/test_filler.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 1d9fd1a6de8..bb41faf5b21 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1059,7 +1059,9 @@ def test_execution_witness_in_blockchain_fixture( "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" "module_execution_witness/execution_witness.json" ) - assert engine_fixture_path.exists(), f"{engine_fixture_path} does not exist" + assert engine_fixture_path.exists(), ( + f"{engine_fixture_path} does not exist" + ) with open(engine_fixture_path, "r") as f: engine_fixture_data = json.load(f) @@ -1070,15 +1072,15 @@ def test_execution_witness_in_blockchain_fixture( assert "executionWitness" in engine_payload engine_witness = engine_payload["executionWitness"] - assert ( - len(engine_witness["state"]) > 0 - ), "engine executionWitness.state is empty" - assert ( - len(engine_witness["codes"]) > 0 - ), "engine executionWitness.codes is empty" - assert ( - len(engine_witness["headers"]) > 0 - ), "engine executionWitness.headers is empty" + assert len(engine_witness["state"]) > 0, ( + "engine executionWitness.state is empty" + ) + assert len(engine_witness["codes"]) > 0, ( + "engine executionWitness.codes is empty" + ) + assert len(engine_witness["headers"]) > 0, ( + "engine executionWitness.headers is empty" + ) assert engine_witness == witness From cf4416c6db2c65fd72d38800f0ce7558b73c7e07 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 27 Apr 2026 09:18:58 -0300 Subject: [PATCH 165/265] fix: fix execution request representation in NewPayloadRequest to be compliant with consensus-specs --- .../amsterdam/execution_engine/__init__.py | 2 + .../amsterdam/execution_engine/new_payload.py | 13 +- .../amsterdam/execution_engine/requests.py | 220 ++++++++++++++++++ .../forks/amsterdam/execution_engine/types.py | 2 +- .../execution_engine/validation_helpers.py | 13 +- src/ethereum/forks/amsterdam/stateless.py | 5 +- .../forks/amsterdam/stateless_host.py | 5 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 165 +++++++++++-- .../evm_tools/loaders/fork_loader.py | 7 + .../evm_tools/t8n/t8n_types.py | 16 +- tests/json_loader/test_stateless_guest.py | 37 ++- 11 files changed, 447 insertions(+), 38 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/execution_engine/requests.py diff --git a/src/ethereum/forks/amsterdam/execution_engine/__init__.py b/src/ethereum/forks/amsterdam/execution_engine/__init__.py index 8d410d1a65e..c37f9365aa0 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/__init__.py +++ b/src/ethereum/forks/amsterdam/execution_engine/__init__.py @@ -28,6 +28,7 @@ BlobsBundle, ExecutionEngine, ExecutionPayload, + ExecutionRequests, GetPayloadResponse, NewPayloadRequest, PayloadAttributes, @@ -38,6 +39,7 @@ "BlobsBundle", "ExecutionEngine", "ExecutionPayload", + "ExecutionRequests", "GetPayloadResponse", "NewPayloadRequest", "PayloadAttributes", diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py index a606bc275b0..1a7d5b2a288 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -2,7 +2,7 @@ Payload verification. """ -from typing import Sequence, Tuple +from typing import Tuple from ethereum_rlp import rlp @@ -15,6 +15,7 @@ from ..fork_types import VersionedHash from ..state import apply_changes_to_state from ..transactions import BlobTransaction, decode_transaction +from .requests import ExecutionRequests from .types import ExecutionEngine, ExecutionPayload, NewPayloadRequest from .validation_helpers import _payload_block, _payload_header @@ -22,7 +23,7 @@ def is_valid_block_hash( execution_payload: ExecutionPayload, parent_beacon_block_root: Root, - execution_requests_list: Sequence[bytes], + execution_requests: ExecutionRequests, ) -> bool: """ Return ``True`` if and only if ``execution_payload.block_hash`` is @@ -32,7 +33,7 @@ def is_valid_block_hash( header = _payload_header( execution_payload, parent_beacon_block_root, - tuple(execution_requests_list), + execution_requests, ) except Exception: # Any decoding or conversion failure means the payload @@ -98,7 +99,7 @@ def execute_new_payload_request( """ payload = new_payload_request.execution_payload parent_beacon_block_root = new_payload_request.parent_beacon_block_root - execution_requests_list = new_payload_request.execution_requests + execution_requests = new_payload_request.execution_requests if b"" in payload.transactions: raise InvalidBlock("Empty transaction in payload") @@ -106,7 +107,7 @@ def execute_new_payload_request( if not is_valid_block_hash( payload, parent_beacon_block_root, - execution_requests_list, + execution_requests, ): raise InvalidBlock("Invalid block hash") @@ -116,7 +117,7 @@ def execute_new_payload_request( block = _payload_block( payload, parent_beacon_block_root, - tuple(execution_requests_list), + execution_requests, ) block_diff = execute_block(block, pre_state, chain_context) return block_diff, block diff --git a/src/ethereum/forks/amsterdam/execution_engine/requests.py b/src/ethereum/forks/amsterdam/execution_engine/requests.py new file mode 100644 index 00000000000..743bd1da525 --- /dev/null +++ b/src/ethereum/forks/amsterdam/execution_engine/requests.py @@ -0,0 +1,220 @@ +""" +Typed execution-layer requests and engine-API wire-form codecs. + +The consensus layer defines ``ExecutionRequests`` as a typed Container +holding ``deposits``, ``withdrawals``, and ``consolidations`` lists. At +the engine-API boundary, that Container is flattened to a tuple of +opaque blobs of the form ``TYPE_BYTE || concat(serialize(item))``, +ordered ascending by type. This module exposes the typed Container and +the boundary codecs (mirrors CL's ``get_execution_requests_list()``). +""" + +from dataclasses import dataclass +from typing import Sequence, Tuple + +from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64 + +from ethereum.exceptions import InvalidBlock +from ethereum.state import Address + +from ..requests import ( + CONSOLIDATION_REQUEST_TYPE, + DEPOSIT_REQUEST_TYPE, + WITHDRAWAL_REQUEST_TYPE, +) + +DEPOSIT_REQUEST_SIZE = 48 + 32 + 8 + 96 + 8 +WITHDRAWAL_REQUEST_SIZE = 20 + 48 + 8 +CONSOLIDATION_REQUEST_SIZE = 20 + 48 + 48 + + +@slotted_freezable +@dataclass +class DepositRequest: + """A single EIP-6110 deposit request.""" + + pubkey: Bytes48 + withdrawal_credentials: Bytes32 + amount: U64 + signature: Bytes96 + index: U64 + + +@slotted_freezable +@dataclass +class WithdrawalRequest: + """A single EIP-7002 withdrawal request.""" + + source_address: Address + validator_pubkey: Bytes48 + amount: U64 + + +@slotted_freezable +@dataclass +class ConsolidationRequest: + """A single EIP-7251 consolidation request.""" + + source_address: Address + source_pubkey: Bytes48 + target_pubkey: Bytes48 + + +@slotted_freezable +@dataclass +class ExecutionRequests: + """ + Typed engine-API container of execution-layer triggered requests. + + Mirrors the consensus-layer ``ExecutionRequests`` Container. + """ + + deposits: Tuple[DepositRequest, ...] + withdrawals: Tuple[WithdrawalRequest, ...] + consolidations: Tuple[ConsolidationRequest, ...] + + +def _encode_deposit(d: DepositRequest) -> Bytes: + return Bytes( + bytes(d.pubkey) + + bytes(d.withdrawal_credentials) + + bytes(d.amount.to_le_bytes8()) + + bytes(d.signature) + + bytes(d.index.to_le_bytes8()) + ) + + +def _encode_withdrawal(w: WithdrawalRequest) -> Bytes: + return Bytes( + bytes(w.source_address) + + bytes(w.validator_pubkey) + + bytes(w.amount.to_le_bytes8()) + ) + + +def _encode_consolidation(c: ConsolidationRequest) -> Bytes: + return Bytes( + bytes(c.source_address) + + bytes(c.source_pubkey) + + bytes(c.target_pubkey) + ) + + +def _decode_deposit(payload: Bytes) -> DepositRequest: + return DepositRequest( + pubkey=Bytes48(payload[0:48]), + withdrawal_credentials=Bytes32(payload[48:80]), + amount=U64.from_le_bytes(payload[80:88]), + signature=Bytes96(payload[88:184]), + index=U64.from_le_bytes(payload[184:192]), + ) + + +def _decode_withdrawal(payload: Bytes) -> WithdrawalRequest: + return WithdrawalRequest( + source_address=Address(payload[0:20]), + validator_pubkey=Bytes48(payload[20:68]), + amount=U64.from_le_bytes(payload[68:76]), + ) + + +def _decode_consolidation(payload: Bytes) -> ConsolidationRequest: + return ConsolidationRequest( + source_address=Address(payload[0:20]), + source_pubkey=Bytes48(payload[20:68]), + target_pubkey=Bytes48(payload[68:116]), + ) + + +def encode_execution_requests( + requests: ExecutionRequests, +) -> Tuple[Bytes, ...]: + """ + Flatten a typed ``ExecutionRequests`` into the engine-API wire form. + + Each non-empty list is emitted as a single blob + ``TYPE_BYTE || concat(serialize(item) for item)``, in ascending + type order. Empty lists are omitted. Mirrors CL's + ``get_execution_requests_list()``. + """ + output: list[Bytes] = [] + if requests.deposits: + body = b"".join(_encode_deposit(d) for d in requests.deposits) + output.append(Bytes(DEPOSIT_REQUEST_TYPE + body)) + if requests.withdrawals: + body = b"".join(_encode_withdrawal(w) for w in requests.withdrawals) + output.append(Bytes(WITHDRAWAL_REQUEST_TYPE + body)) + if requests.consolidations: + body = b"".join( + _encode_consolidation(c) for c in requests.consolidations + ) + output.append(Bytes(CONSOLIDATION_REQUEST_TYPE + body)) + return tuple(output) + + +def decode_execution_requests( + wire: Sequence[Bytes], +) -> ExecutionRequests: + """ + Parse the engine-API wire form into a typed ``ExecutionRequests``. + + Validates strict ascending type order, no duplicate type bytes, no + unknown type bytes, and that each payload's length is a multiple of + the per-type item size. + """ + deposits: Tuple[DepositRequest, ...] = () + withdrawals: Tuple[WithdrawalRequest, ...] = () + consolidations: Tuple[ConsolidationRequest, ...] = () + + last_type = -1 + for blob in wire: + if len(blob) < 1: + raise InvalidBlock("Empty execution request blob") + type_byte = bytes(blob[0:1]) + body = bytes(blob[1:]) + type_int = type_byte[0] + if type_int <= last_type: + raise InvalidBlock( + "Execution requests must be in strict ascending type order" + ) + last_type = type_int + + if type_byte == DEPOSIT_REQUEST_TYPE: + if len(body) % DEPOSIT_REQUEST_SIZE != 0: + raise InvalidBlock("Invalid deposit request payload length") + deposits = tuple( + _decode_deposit(Bytes(body[i : i + DEPOSIT_REQUEST_SIZE])) + for i in range(0, len(body), DEPOSIT_REQUEST_SIZE) + ) + elif type_byte == WITHDRAWAL_REQUEST_TYPE: + if len(body) % WITHDRAWAL_REQUEST_SIZE != 0: + raise InvalidBlock("Invalid withdrawal request payload length") + withdrawals = tuple( + _decode_withdrawal( + Bytes(body[i : i + WITHDRAWAL_REQUEST_SIZE]) + ) + for i in range(0, len(body), WITHDRAWAL_REQUEST_SIZE) + ) + elif type_byte == CONSOLIDATION_REQUEST_TYPE: + if len(body) % CONSOLIDATION_REQUEST_SIZE != 0: + raise InvalidBlock( + "Invalid consolidation request payload length" + ) + consolidations = tuple( + _decode_consolidation( + Bytes(body[i : i + CONSOLIDATION_REQUEST_SIZE]) + ) + for i in range(0, len(body), CONSOLIDATION_REQUEST_SIZE) + ) + else: + raise InvalidBlock( + f"Unknown execution request type byte {type_byte!r}" + ) + + return ExecutionRequests( + deposits=deposits, + withdrawals=withdrawals, + consolidations=consolidations, + ) diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py index e136a28d427..144d763ce35 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/types.py +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -15,13 +15,13 @@ from ..blocks import Withdrawal from ..fork import BlockChain from ..fork_types import Bloom, VersionedHash +from .requests import ExecutionRequests # In this module, the execution engine is the chain/state container used by # the fork's transition functions. ExecutionEngine = BlockChain PayloadId = Bytes8 _ZERO_HASH32 = Hash32(b"\x00" * 32) -ExecutionRequests = Tuple[Bytes, ...] @slotted_freezable diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py index a4568ed9323..8300daa4d7f 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -2,7 +2,7 @@ Shared execution-engine conversion helpers. """ -from typing import Optional, Tuple +from typing import Optional from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes8 @@ -15,13 +15,14 @@ from ..fork import EMPTY_OMMER_HASH from ..requests import compute_requests_hash from ..trie import Trie, root, trie_set +from .requests import ExecutionRequests, encode_execution_requests from .types import ExecutionPayload def _payload_header( execution_payload: ExecutionPayload, parent_beacon_block_root: Root, - execution_requests: Tuple[Bytes, ...], + execution_requests: ExecutionRequests, ) -> Header: """ Build the execution header implied by a payload request. @@ -48,7 +49,11 @@ def _payload_header( ) withdrawals_root = root(withdrawals_trie) - requests_hash = Hash32(compute_requests_hash(list(execution_requests))) + requests_hash = Hash32( + compute_requests_hash( + list(encode_execution_requests(execution_requests)) + ) + ) return Header( parent_hash=execution_payload.parent_hash, @@ -81,7 +86,7 @@ def _payload_header( def _payload_block( execution_payload: ExecutionPayload, parent_beacon_block_root: Root, - execution_requests: Tuple[Bytes, ...], + execution_requests: ExecutionRequests, ) -> Block: """ Convert an execution payload request into an execution-layer block. diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index b261d089b88..d7a38376e67 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -16,6 +16,7 @@ from .blocks import Block, Header from .execution_engine.new_payload import execute_new_payload_request +from .execution_engine.requests import ExecutionRequests from .execution_engine.types import NewPayloadRequest from .fork import ChainContext from .fork_types import VersionedHash @@ -47,10 +48,6 @@ class ExecutionWitness: """ -# Amsterdam currently carries execution requests as raw bytes in order. -ExecutionRequests = Tuple[Bytes, ...] - - @slotted_freezable @dataclass class ExecutionPayloadHeader: diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 36f490749c8..379805a5a2b 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -2,7 +2,7 @@ Host-side assembly of stateless input from block execution data. """ -from typing import List, Tuple +from typing import List from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -12,6 +12,7 @@ from .block_access_lists import BlockAccessList from .blocks import Block +from .execution_engine.requests import ExecutionRequests from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash from .stateless import ( @@ -50,7 +51,7 @@ def build_stateless_input( block: Block, *, execution_witness: ExecutionWitness, - execution_requests: Tuple[Bytes, ...], + execution_requests: ExecutionRequests, block_access_list: BlockAccessList, chain_id: U64, ) -> StatelessInput: diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index ccc608f2abc..a80f26e9aaa 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -6,7 +6,7 @@ functions between the two representations. """ -from ethereum_types.bytes import Bytes +from ethereum_types.bytes import Bytes, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint from remerkleable.basic import boolean, uint64, uint256 from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector @@ -17,6 +17,12 @@ from ethereum.state import Address, Root from .blocks import Withdrawal +from .execution_engine.requests import ( + ConsolidationRequest, + DepositRequest, + ExecutionRequests, + WithdrawalRequest, +) from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import Bloom, VersionedHash from .stateless import ( @@ -36,8 +42,9 @@ # capped or skipped for Amsterdam. MAX_WITHDRAWALS_PER_PAYLOAD = 2**16 MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 -MAX_EXECUTION_REQUESTS = 16 -MAX_BYTES_PER_REQUEST = 2**20 +MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**13 +MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = 2**4 +MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD = 2**1 MAX_BLOCK_ACCESS_LIST_BYTES = 2**24 MAX_WITNESS_NODES = 2**20 MAX_WITNESS_CODES = 2**16 @@ -86,15 +93,51 @@ class SszExecutionPayload(Container): block_access_list: ByteList[MAX_BLOCK_ACCESS_LIST_BYTES] +class SszDepositRequest(Container): + """SSZ container mirroring ``DepositRequest``.""" + + pubkey: ByteVector[48] + withdrawal_credentials: Bytes32 + amount: uint64 + signature: ByteVector[96] + index: uint64 + + +class SszWithdrawalRequest(Container): + """SSZ container mirroring ``WithdrawalRequest``.""" + + source_address: ByteVector[20] + validator_pubkey: ByteVector[48] + amount: uint64 + + +class SszConsolidationRequest(Container): + """SSZ container mirroring ``ConsolidationRequest``.""" + + source_address: ByteVector[20] + source_pubkey: ByteVector[48] + target_pubkey: ByteVector[48] + + +class SszExecutionRequests(Container): + """SSZ container mirroring ``ExecutionRequests``.""" + + deposits: SszList[SszDepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD] + withdrawals: SszList[ + SszWithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD + ] + consolidations: SszList[ + SszConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD + ] + + class SszNewPayloadRequest(Container): """SSZ container mirroring ``NewPayloadRequest``.""" execution_payload: SszExecutionPayload versioned_hashes: SszList[Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK] parent_beacon_block_root: Bytes32 - execution_requests: SszList[ - ByteList[MAX_BYTES_PER_REQUEST], MAX_EXECUTION_REQUESTS - ] # noqa: E501 + execution_requests: SszExecutionRequests class SszExecutionWitness(Container): @@ -213,6 +256,104 @@ def _ssz_to_payload( ) +def _deposit_request_to_ssz(d: DepositRequest) -> SszDepositRequest: + """Convert a DepositRequest to its SSZ form.""" + return SszDepositRequest( + pubkey=ByteVector[48](bytes(d.pubkey)), + withdrawal_credentials=Bytes32(bytes(d.withdrawal_credentials)), + amount=uint64(int(d.amount)), + signature=ByteVector[96](bytes(d.signature)), + index=uint64(int(d.index)), + ) + + +def _ssz_to_deposit_request(sd: SszDepositRequest) -> DepositRequest: + """Convert an SSZ deposit request back.""" + return DepositRequest( + pubkey=Bytes48(bytes(sd.pubkey)), + withdrawal_credentials=Bytes32(bytes(sd.withdrawal_credentials)), + amount=U64(sd.amount), + signature=Bytes96(bytes(sd.signature)), + index=U64(sd.index), + ) + + +def _withdrawal_request_to_ssz( + w: WithdrawalRequest, +) -> SszWithdrawalRequest: + """Convert a WithdrawalRequest to its SSZ form.""" + return SszWithdrawalRequest( + source_address=ByteVector[20](bytes(w.source_address)), + validator_pubkey=ByteVector[48](bytes(w.validator_pubkey)), + amount=uint64(int(w.amount)), + ) + + +def _ssz_to_withdrawal_request( + sw: SszWithdrawalRequest, +) -> WithdrawalRequest: + """Convert an SSZ withdrawal request back.""" + return WithdrawalRequest( + source_address=Address(bytes(sw.source_address)), + validator_pubkey=Bytes48(bytes(sw.validator_pubkey)), + amount=U64(sw.amount), + ) + + +def _consolidation_request_to_ssz( + c: ConsolidationRequest, +) -> SszConsolidationRequest: + """Convert a ConsolidationRequest to its SSZ form.""" + return SszConsolidationRequest( + source_address=ByteVector[20](bytes(c.source_address)), + source_pubkey=ByteVector[48](bytes(c.source_pubkey)), + target_pubkey=ByteVector[48](bytes(c.target_pubkey)), + ) + + +def _ssz_to_consolidation_request( + sc: SszConsolidationRequest, +) -> ConsolidationRequest: + """Convert an SSZ consolidation request back.""" + return ConsolidationRequest( + source_address=Address(bytes(sc.source_address)), + source_pubkey=Bytes48(bytes(sc.source_pubkey)), + target_pubkey=Bytes48(bytes(sc.target_pubkey)), + ) + + +def _execution_requests_to_ssz( + er: ExecutionRequests, +) -> SszExecutionRequests: + """Convert an ExecutionRequests to its SSZ form.""" + return SszExecutionRequests( + deposits=SszList[SszDepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD]( + _deposit_request_to_ssz(d) for d in er.deposits + ), + withdrawals=SszList[ + SszWithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD + ](_withdrawal_request_to_ssz(w) for w in er.withdrawals), + consolidations=SszList[ + SszConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD + ](_consolidation_request_to_ssz(c) for c in er.consolidations), + ) + + +def _ssz_to_execution_requests( + ser: SszExecutionRequests, +) -> ExecutionRequests: + """Convert an SSZ execution requests back.""" + return ExecutionRequests( + deposits=tuple(_ssz_to_deposit_request(sd) for sd in ser.deposits), + withdrawals=tuple( + _ssz_to_withdrawal_request(sw) for sw in ser.withdrawals + ), + consolidations=tuple( + _ssz_to_consolidation_request(sc) for sc in ser.consolidations + ), + ) + + def _new_payload_request_to_ssz( npr: NewPayloadRequest, ) -> SszNewPayloadRequest: @@ -223,13 +364,7 @@ def _new_payload_request_to_ssz( Bytes32(bytes(vh)) for vh in npr.versioned_hashes ), parent_beacon_block_root=Bytes32(bytes(npr.parent_beacon_block_root)), - execution_requests=SszList[ - ByteList[MAX_BYTES_PER_REQUEST], - MAX_EXECUTION_REQUESTS, - ]( - ByteList[MAX_BYTES_PER_REQUEST](bytes(er)) - for er in npr.execution_requests - ), + execution_requests=_execution_requests_to_ssz(npr.execution_requests), ) @@ -243,9 +378,7 @@ def _ssz_to_new_payload_request( VersionedHash(bytes(vh)) for vh in snpr.versioned_hashes ), parent_beacon_block_root=Root(bytes(snpr.parent_beacon_block_root)), - execution_requests=tuple( - Bytes(bytes(er)) for er in snpr.execution_requests - ), + execution_requests=_ssz_to_execution_requests(snpr.execution_requests), ) diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 855e6d1ecee..089880c5cf7 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -180,6 +180,13 @@ def build_stateless_input(self) -> Any: """build_stateless_input function of the fork.""" return self._module("stateless_host").build_stateless_input + @property + def decode_execution_requests(self) -> Any: + """decode_execution_requests function of the fork.""" + return self._module( + "execution_engine.requests" + ).decode_execution_requests + @property def serialize_stateless_input(self) -> Any: """serialize_stateless_input function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index fa3a88174f3..e91a7625c89 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -11,6 +11,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidBlock from ethereum.state import EMPTY_CODE_HASH from ethereum.utils.hexadecimal import hex_to_bytes, hex_to_u256, hex_to_uint @@ -419,10 +420,23 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: ) assert self.requests is not None + try: + typed_requests = t8n.fork.decode_execution_requests( + tuple(self.requests) + ) + except InvalidBlock: + # The wire form does not parse as a typed + # ``ExecutionRequests`` Container. This only happens for + # spec tests that mock predeploys to return non-canonical + # data; canonical predeploy bytecode always emits + # spec-compliant wire. Skip stateless validation in that + # case — re-running the guest over un-decodable input + # would just fail. + return stateless_input = t8n.fork.build_stateless_input( block, execution_witness=self.execution_witness, - execution_requests=tuple(self.requests), + execution_requests=typed_requests, block_access_list=self.block_access_list, chain_id=block_env.chain_id, ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index d488079eb41..d847b48985d 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -3,10 +3,14 @@ import random from typing import Tuple -from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32 +from ethereum.forks.amsterdam.execution_engine.requests import ( + DepositRequest, + ExecutionRequests, +) from ethereum.forks.amsterdam.execution_engine.types import ( ExecutionPayload, NewPayloadRequest, @@ -60,6 +64,16 @@ def _make_payload() -> ExecutionPayload: ) +def _make_deposit_request() -> DepositRequest: + return DepositRequest( + pubkey=Bytes48(_rb(48)), + withdrawal_credentials=Bytes32(_rb(32)), + amount=U64(_RNG.randint(0, 2**64 - 1)), + signature=Bytes96(_rb(96)), + index=U64(_RNG.randint(0, 2**64 - 1)), + ) + + def _make_stateless_input() -> StatelessInput: versioned_hashes: Tuple[Hash32, ...] = (Hash32(_rb(32)), Hash32(_rb(32))) return StatelessInput( @@ -67,7 +81,14 @@ def _make_stateless_input() -> StatelessInput: execution_payload=_make_payload(), versioned_hashes=versioned_hashes, parent_beacon_block_root=Root(_rb(32)), - execution_requests=(Bytes(_rb(48)), Bytes(_rb(48))), + execution_requests=ExecutionRequests( + deposits=( + _make_deposit_request(), + _make_deposit_request(), + ), + withdrawals=(), + consolidations=(), + ), ), witness=ExecutionWitness( state=(Bytes(_rb(64)), Bytes(_rb(64)), Bytes(_rb(32))), @@ -104,7 +125,11 @@ def test_empty_witness(self) -> None: execution_payload=_make_payload(), versioned_hashes=(), parent_beacon_block_root=Root(_rb(32)), - execution_requests=(), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), chain_config=ChainConfig(chain_id=U64(1)), @@ -132,7 +157,11 @@ def test_empty_witness(self) -> None: execution_payload=_make_payload(), versioned_hashes=(), parent_beacon_block_root=Root(_rb(32)), - execution_requests=(), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), chain_config=ChainConfig(chain_id=U64(1)), From 364463725a29ca2f199477c8f605508bfbae765e Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 27 Apr 2026 09:59:52 -0300 Subject: [PATCH 166/265] fix: update MAX_BYTES_PER_PUBLIC_KEY to comply with specification --- src/ethereum/forks/amsterdam/stateless_ssz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index a80f26e9aaa..d93a50db064 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -53,7 +53,7 @@ MAX_BYTES_PER_CODE = 2**24 MAX_BYTES_PER_HEADER = 2**10 MAX_PUBLIC_KEYS = 2**20 -MAX_BYTES_PER_PUBLIC_KEY = 48 +MAX_BYTES_PER_PUBLIC_KEY = 65 # --- SSZ Container types --- From dff46d936512a217103f794152d62d05051fd763 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 27 Apr 2026 13:16:53 -0300 Subject: [PATCH 167/265] fix withdrawal.amount type Signed-off-by: jsign --- src/ethereum/forks/amsterdam/stateless_ssz.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index d93a50db064..3b54972888d 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -65,7 +65,7 @@ class SszWithdrawal(Container): index: uint64 validator_index: uint64 address: ByteVector[20] - amount: uint256 + amount: uint64 class SszExecutionPayload(Container): @@ -180,7 +180,7 @@ def _withdrawal_to_ssz(w: Withdrawal) -> SszWithdrawal: index=uint64(int(w.index)), validator_index=uint64(int(w.validator_index)), address=ByteVector[20](bytes(w.address)), - amount=uint256(int(w.amount)), + amount=uint64(int(w.amount)), ) From 8496634e020f9589adc115508aa6fb52d9f388e8 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 27 Apr 2026 18:53:35 -0300 Subject: [PATCH 168/265] properly handle invalid txs in the guest ssz input bytes --- src/ethereum/forks/amsterdam/stateless_host.py | 8 +++++++- src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py | 10 +++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 379805a5a2b..1c5429c1a58 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -73,7 +73,13 @@ def build_stateless_input( tx_bytes_list.append(Bytes(rlp.encode(tx))) else: tx_bytes_list.append(Bytes(tx)) - tx_obj = decode_transaction(tx) + # A typed tx may be malformed (pre-execution-rejected by + # t8n but still committed to by the block's transactions + # trie). + try: + tx_obj = decode_transaction(tx) + except Exception: + continue if isinstance(tx_obj, BlobTransaction): versioned_hashes.extend(tx_obj.blob_versioned_hashes) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index e91a7625c89..980d36079e9 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -405,16 +405,16 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: block_access_list_hash=self.block_access_list_hash, ) - # TODO: perhaps change this for t8n.all_txs minus rejected_txs? - included_txs = [] - for key in block_output.receipt_keys: + payload_txs = [] + for tx_index in range(len(t8n.txs.transactions)): + key = rlp.encode(Uint(tx_index)) tx = t8n.fork.trie_get(block_output.transactions_trie, key) assert tx is not None - included_txs.append(tx) + payload_txs.append(tx) block = t8n.fork.Block( header=header, - transactions=tuple(included_txs), + transactions=tuple(payload_txs), ommers=(), withdrawals=withdrawals, ) From 9a3706b7bf92bed10d92b0b8c86e243869ae8758 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 27 Apr 2026 19:11:39 -0300 Subject: [PATCH 169/265] lint --- src/ethereum/forks/amsterdam/stateless_host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 1c5429c1a58..016c60c4b40 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -75,7 +75,7 @@ def build_stateless_input( tx_bytes_list.append(Bytes(tx)) # A typed tx may be malformed (pre-execution-rejected by # t8n but still committed to by the block's transactions - # trie). + # trie). try: tx_obj = decode_transaction(tx) except Exception: From 5ec2f6b51f10ae69c56d342f4e3a72a3bdbc100a Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 4 May 2026 12:08:55 -0300 Subject: [PATCH 170/265] add fork schedule fields to stateless ChainConfig Signed-off-by: Ignacio Hagopian --- src/ethereum/forks/amsterdam/stateless.py | 69 +++++++ .../forks/amsterdam/stateless_host.py | 2 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 184 +++++++++++++++++- tests/json_loader/test_stateless_guest.py | 10 +- 4 files changed, 257 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index d7a38376e67..0aa35e504e2 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -3,6 +3,7 @@ """ from dataclasses import dataclass +from enum import StrEnum from typing import List, Sequence, Tuple from ethereum_rlp import rlp @@ -74,6 +75,73 @@ class NewPayloadRequestHeader: execution_requests: ExecutionRequests +class ProtocolFork(StrEnum): + """ + Semantic execution-layer fork names understood by stateless inputs. + """ + + Frontier = "Frontier" + Homestead = "Homestead" + DAOFork = "DAOFork" + TangerineWhistle = "TangerineWhistle" + SpuriousDragon = "SpuriousDragon" + Byzantium = "Byzantium" + Constantinople = "Constantinople" + ConstantinopleFix = "ConstantinopleFix" + Istanbul = "Istanbul" + MuirGlacier = "MuirGlacier" + Berlin = "Berlin" + London = "London" + ArrowGlacier = "ArrowGlacier" + GrayGlacier = "GrayGlacier" + Paris = "Paris" + Shanghai = "Shanghai" + Cancun = "Cancun" + Prague = "Prague" + Osaka = "Osaka" + BPO1 = "BPO1" + BPO2 = "BPO2" + BPO3 = "BPO3" + BPO4 = "BPO4" + BPO5 = "BPO5" + Amsterdam = "Amsterdam" + + +@slotted_freezable +@dataclass +class ForkActivation: + """ + Activation point for a protocol fork. + """ + + block_number: U64 | None + timestamp: U64 | None + + +@slotted_freezable +@dataclass +class BlobSchedule: + """ + Effective blob parameters for a protocol fork. + """ + + target: U64 + max: U64 + base_fee_update_fraction: U64 + + +@slotted_freezable +@dataclass +class ForkConfig: + """ + Per-fork configuration needed to interpret stateless inputs. + """ + + fork: ProtocolFork + activation: ForkActivation + blob_schedule: BlobSchedule | None + + @slotted_freezable @dataclass class ChainConfig: @@ -85,6 +153,7 @@ class ChainConfig: """ chain_id: U64 + forks: Tuple[ForkConfig, ...] @slotted_freezable diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 016c60c4b40..6189138bc75 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -117,6 +117,6 @@ def build_stateless_input( return StatelessInput( new_payload_request=new_payload, witness=execution_witness, - chain_config=ChainConfig(chain_id=chain_id), + chain_config=ChainConfig(chain_id=chain_id, forks=()), public_keys=(), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 3b54972888d..5b4465c7c11 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -26,8 +26,12 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import Bloom, VersionedHash from .stateless import ( + BlobSchedule, ChainConfig, ExecutionWitness, + ForkActivation, + ForkConfig, + ProtocolFork, StatelessInput, StatelessValidationResult, ) @@ -52,6 +56,9 @@ MAX_BYTES_PER_WITNESS_NODE = 2**20 MAX_BYTES_PER_CODE = 2**24 MAX_BYTES_PER_HEADER = 2**10 +MAX_FORK_CONFIGS = 32 +MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 +MAX_BLOB_SCHEDULES_PER_FORK = 1 MAX_PUBLIC_KEYS = 2**20 MAX_BYTES_PER_PUBLIC_KEY = 65 @@ -59,6 +66,11 @@ # --- SSZ Container types --- +SszOptionalForkActivationValue = SszList[ + uint64, MAX_OPTIONAL_FORK_ACTIVATION_VALUES +] + + class SszWithdrawal(Container): """SSZ container mirroring ``Withdrawal``.""" @@ -148,10 +160,42 @@ class SszExecutionWitness(Container): headers: SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] +class SszForkActivation(Container): + """SSZ container mirroring ``ForkActivation``.""" + + block_number: SszOptionalForkActivationValue + timestamp: SszOptionalForkActivationValue + + +class SszBlobSchedule(Container): + """SSZ container mirroring ``BlobSchedule``.""" + + target: uint64 + max: uint64 + base_fee_update_fraction: uint64 + + +SszOptionalBlobSchedule = SszList[ + SszBlobSchedule, MAX_BLOB_SCHEDULES_PER_FORK +] + + +class SszForkConfig(Container): + """SSZ container mirroring ``ForkConfig``.""" + + fork: uint64 + activation: SszForkActivation + blob_schedule: SszOptionalBlobSchedule + + +SszForkConfigList = SszList[SszForkConfig, MAX_FORK_CONFIGS] + + class SszChainConfig(Container): """SSZ container mirroring ``ChainConfig``.""" chain_id: uint64 + forks: SszForkConfigList class SszStatelessInput(Container): @@ -174,6 +218,23 @@ class SszStatelessValidationResult(Container): # --- Conversion helpers --- +PROTOCOL_FORKS = tuple(ProtocolFork) + + +def _protocol_fork_to_ssz(fork: ProtocolFork) -> uint64: + """Convert a ProtocolFork to its SSZ enum value.""" + protocol_fork = ProtocolFork(fork) + return uint64(PROTOCOL_FORKS.index(protocol_fork)) + + +def _ssz_to_protocol_fork(ssz_fork: uint64) -> ProtocolFork: + """Convert an SSZ enum value back to a ProtocolFork.""" + try: + return PROTOCOL_FORKS[int(ssz_fork)] + except IndexError as error: + raise ValueError(f"Unknown protocol fork value: {ssz_fork}") from error + + def _withdrawal_to_ssz(w: Withdrawal) -> SszWithdrawal: """Convert a Withdrawal to its SSZ form.""" return SszWithdrawal( @@ -410,18 +471,137 @@ def _ssz_to_witness( ) +def _optional_u64_to_ssz( + value: U64 | None, +) -> SszOptionalForkActivationValue: + """Convert an optional U64 to a zero-or-one SSZ list.""" + if value is None: + return SszOptionalForkActivationValue() + return SszOptionalForkActivationValue(uint64(int(value))) + + +def _ssz_to_optional_u64( + ssz_value: SszOptionalForkActivationValue, +) -> U64 | None: + """Convert a zero-or-one SSZ list back to an optional U64.""" + if ssz_value.length() == 0: + return None + return U64(ssz_value.get(0)) + + +def _fork_activation_to_ssz( + activation: ForkActivation, +) -> SszForkActivation: + """Convert a ForkActivation to its SSZ form.""" + return SszForkActivation( + block_number=_optional_u64_to_ssz(activation.block_number), + timestamp=_optional_u64_to_ssz(activation.timestamp), + ) + + +def _ssz_to_fork_activation( + ssz_activation: SszForkActivation, +) -> ForkActivation: + """Convert an SSZ fork activation back.""" + return ForkActivation( + block_number=_ssz_to_optional_u64(ssz_activation.block_number), + timestamp=_ssz_to_optional_u64(ssz_activation.timestamp), + ) + + +def _blob_schedule_to_ssz( + blob_schedule: BlobSchedule, +) -> SszBlobSchedule: + """Convert a BlobSchedule to its SSZ form.""" + return SszBlobSchedule( + target=uint64(int(blob_schedule.target)), + max=uint64(int(blob_schedule.max)), + base_fee_update_fraction=uint64( + int(blob_schedule.base_fee_update_fraction) + ), + ) + + +def _ssz_to_blob_schedule( + ssz_blob_schedule: SszBlobSchedule, +) -> BlobSchedule: + """Convert an SSZ blob schedule back.""" + return BlobSchedule( + target=U64(ssz_blob_schedule.target), + max=U64(ssz_blob_schedule.max), + base_fee_update_fraction=U64( + ssz_blob_schedule.base_fee_update_fraction + ), + ) + + +def _optional_blob_schedule_to_ssz( + blob_schedule: BlobSchedule | None, +) -> SszOptionalBlobSchedule: + """Convert an optional BlobSchedule to a zero-or-one SSZ list.""" + if blob_schedule is None: + return SszOptionalBlobSchedule() + return SszOptionalBlobSchedule(_blob_schedule_to_ssz(blob_schedule)) + + +def _ssz_to_optional_blob_schedule( + ssz_blob_schedule: SszOptionalBlobSchedule, +) -> BlobSchedule | None: + """Convert a zero-or-one SSZ blob schedule list back.""" + if ssz_blob_schedule.length() == 0: + return None + return _ssz_to_blob_schedule(ssz_blob_schedule.get(0)) + + +def _fork_config_to_ssz( + fork_config: ForkConfig, +) -> SszForkConfig: + """Convert a ForkConfig to its SSZ form.""" + return SszForkConfig( + fork=_protocol_fork_to_ssz(fork_config.fork), + activation=_fork_activation_to_ssz(fork_config.activation), + blob_schedule=_optional_blob_schedule_to_ssz( + fork_config.blob_schedule + ), + ) + + +def _ssz_to_fork_config( + ssz_fork_config: SszForkConfig, +) -> ForkConfig: + """Convert an SSZ fork config back.""" + return ForkConfig( + fork=_ssz_to_protocol_fork(ssz_fork_config.fork), + activation=_ssz_to_fork_activation(ssz_fork_config.activation), + blob_schedule=_ssz_to_optional_blob_schedule( + ssz_fork_config.blob_schedule + ), + ) + + def _chain_config_to_ssz( cc: ChainConfig, ) -> SszChainConfig: """Convert a ChainConfig to its SSZ form.""" - return SszChainConfig(chain_id=uint64(int(cc.chain_id))) + return SszChainConfig( + chain_id=uint64(int(cc.chain_id)), + forks=SszForkConfigList( + _fork_config_to_ssz(fork_config) for fork_config in cc.forks + ), + ) def _ssz_to_chain_config( scc: SszChainConfig, ) -> ChainConfig: """Convert an SSZ chain config back.""" - return ChainConfig(chain_id=U64(scc.chain_id)) + return ChainConfig( + chain_id=U64(scc.chain_id), + forks=tuple( + _ssz_to_fork_config(ssz_fork_config) + for ssz_fork_config in scc.forks + ), + ) def stateless_input_to_ssz( diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index d847b48985d..d49622ea51d 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -95,7 +95,7 @@ def _make_stateless_input() -> StatelessInput: codes=(Bytes(_rb(48)), Bytes(_rb(96))), headers=(Bytes(_rb(512)), Bytes(_rb(512))), ), - chain_config=ChainConfig(chain_id=U64(1)), + chain_config=ChainConfig(chain_id=U64(1), forks=()), public_keys=(Bytes(_rb(33)), Bytes(_rb(33))), ) @@ -104,7 +104,7 @@ def _make_stateless_output() -> StatelessValidationResult: return StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=True, - chain_config=ChainConfig(chain_id=U64(1)), + chain_config=ChainConfig(chain_id=U64(1), forks=()), ) @@ -132,7 +132,7 @@ def test_empty_witness(self) -> None: ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), - chain_config=ChainConfig(chain_id=U64(1)), + chain_config=ChainConfig(chain_id=U64(1), forks=()), public_keys=(), ) encoded = serialize_stateless_input(original) @@ -164,7 +164,7 @@ def test_empty_witness(self) -> None: ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), - chain_config=ChainConfig(chain_id=U64(1)), + chain_config=ChainConfig(chain_id=U64(1), forks=()), public_keys=(), ) encoded = serialize_stateless_input(original) @@ -187,7 +187,7 @@ def test_failed_validation(self) -> None: original = StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=False, - chain_config=ChainConfig(chain_id=U64(1)), + chain_config=ChainConfig(chain_id=U64(1), forks=()), ) encoded = serialize_stateless_output(original) recovered = deserialize_stateless_output(encoded) From 091e9dba1f9ad9a678ba6077e434c2830e61b7e8 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 4 May 2026 12:47:44 -0300 Subject: [PATCH 171/265] include Amsterdam fork config in stateless inputs Signed-off-by: Ignacio Hagopian --- .../forks/amsterdam/stateless_host.py | 38 +++++- tests/json_loader/test_stateless_guest.py | 111 +++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 6189138bc75..ae99d366a7b 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -16,8 +16,12 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash from .stateless import ( + BlobSchedule, ChainConfig, ExecutionWitness, + ForkActivation, + ForkConfig, + ProtocolFork, StatelessInput, StatelessValidationResult, ) @@ -31,6 +35,11 @@ LegacyTransaction, decode_transaction, ) +from .vm.gas import ( + BLOB_BASE_FEE_UPDATE_FRACTION, + BLOB_SCHEDULE_MAX, + BLOB_SCHEDULE_TARGET, +) def serialize_stateless_input( @@ -47,6 +56,33 @@ def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: return ssz_to_validation_result(ssz_obj) +def build_chain_config(chain_id: U64) -> ChainConfig: + """ + Build the chain configuration supported by this host. + + For now the Amsterdam stateless host only describes the Amsterdam fork. + """ + return ChainConfig( + chain_id=chain_id, + forks=( + ForkConfig( + fork=ProtocolFork.Amsterdam, + activation=ForkActivation( + block_number=None, + timestamp=U64(0), + ), + blob_schedule=BlobSchedule( + target=BLOB_SCHEDULE_TARGET, + max=BLOB_SCHEDULE_MAX, + base_fee_update_fraction=U64( + BLOB_BASE_FEE_UPDATE_FRACTION + ), + ), + ), + ), + ) + + def build_stateless_input( block: Block, *, @@ -117,6 +153,6 @@ def build_stateless_input( return StatelessInput( new_payload_request=new_payload, witness=execution_witness, - chain_config=ChainConfig(chain_id=chain_id, forks=()), + chain_config=build_chain_config(chain_id), public_keys=(), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index d49622ea51d..e91a1e2ea14 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -3,10 +3,12 @@ import random from typing import Tuple -from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 +from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32 +from ethereum.forks.amsterdam.block_access_lists import BlockAccessList +from ethereum.forks.amsterdam.blocks import Block, Header from ethereum.forks.amsterdam.execution_engine.requests import ( DepositRequest, ExecutionRequests, @@ -17,8 +19,12 @@ ) from ethereum.forks.amsterdam.fork_types import Bloom from ethereum.forks.amsterdam.stateless import ( + BlobSchedule, ChainConfig, ExecutionWitness, + ForkActivation, + ForkConfig, + ProtocolFork, StatelessInput, StatelessValidationResult, compute_new_payload_request_root, @@ -28,9 +34,16 @@ serialize_stateless_output, ) from ethereum.forks.amsterdam.stateless_host import ( + build_chain_config, + build_stateless_input, deserialize_stateless_output, serialize_stateless_input, ) +from ethereum.forks.amsterdam.vm.gas import ( + BLOB_BASE_FEE_UPDATE_FRACTION, + BLOB_SCHEDULE_MAX, + BLOB_SCHEDULE_TARGET, +) from ethereum.state import Address, Root _RNG = random.Random(0xDEADBEEF) @@ -64,6 +77,64 @@ def _make_payload() -> ExecutionPayload: ) +def _make_header() -> Header: + return Header( + parent_hash=Hash32(_rb(32)), + ommers_hash=Hash32(_rb(32)), + coinbase=Address(_rb(20)), + state_root=Root(_rb(32)), + transactions_root=Root(_rb(32)), + receipt_root=Root(_rb(32)), + bloom=Bloom(_rb(256)), + difficulty=Uint(0), + number=Uint(_RNG.randint(1, 2**32)), + gas_limit=Uint(30_000_000), + gas_used=Uint(_RNG.randint(0, 20_000_000)), + timestamp=U256(_RNG.randint(1, 2**32)), + extra_data=Bytes(_rb(32)), + prev_randao=Bytes32(_rb(32)), + nonce=Bytes8(_rb(8)), + base_fee_per_gas=Uint(_RNG.randint(1, 10**9)), + withdrawals_root=Root(_rb(32)), + blob_gas_used=U64(_RNG.randint(0, 2**17)), + excess_blob_gas=U64(_RNG.randint(0, 2**17)), + parent_beacon_block_root=Root(_rb(32)), + requests_hash=Hash32(_rb(32)), + block_access_list_hash=Hash32(_rb(32)), + ) + + +def _make_block() -> Block: + return Block( + header=_make_header(), + transactions=(), + ommers=(), + withdrawals=(), + ) + + +def _expected_amsterdam_chain_config(chain_id: U64) -> ChainConfig: + return ChainConfig( + chain_id=chain_id, + forks=( + ForkConfig( + fork=ProtocolFork.Amsterdam, + activation=ForkActivation( + block_number=None, + timestamp=U64(0), + ), + blob_schedule=BlobSchedule( + target=BLOB_SCHEDULE_TARGET, + max=BLOB_SCHEDULE_MAX, + base_fee_update_fraction=U64( + BLOB_BASE_FEE_UPDATE_FRACTION + ), + ), + ), + ), + ) + + def _make_deposit_request() -> DepositRequest: return DepositRequest( pubkey=Bytes48(_rb(48)), @@ -95,7 +166,7 @@ def _make_stateless_input() -> StatelessInput: codes=(Bytes(_rb(48)), Bytes(_rb(96))), headers=(Bytes(_rb(512)), Bytes(_rb(512))), ), - chain_config=ChainConfig(chain_id=U64(1), forks=()), + chain_config=build_chain_config(U64(1)), public_keys=(Bytes(_rb(33)), Bytes(_rb(33))), ) @@ -104,10 +175,44 @@ def _make_stateless_output() -> StatelessValidationResult: return StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=True, - chain_config=ChainConfig(chain_id=U64(1), forks=()), + chain_config=build_chain_config(U64(1)), ) +class TestBuildChainConfig: + """Test host-side ChainConfig construction.""" + + def test_amsterdam_only(self) -> None: + """Builds a single Amsterdam fork entry.""" + chain_config = build_chain_config(U64(123)) + assert chain_config == _expected_amsterdam_chain_config(U64(123)) + + +class TestBuildStatelessInput: + """Test host-side StatelessInput construction.""" + + def test_includes_amsterdam_chain_config(self) -> None: + """Includes the Amsterdam-only chain config.""" + chain_config = build_chain_config(U64(123)) + block_access_list: BlockAccessList = [] + stateless_input = build_stateless_input( + _make_block(), + execution_witness=ExecutionWitness( + state=(), + codes=(), + headers=(), + ), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + ), + block_access_list=block_access_list, + chain_id=U64(123), + ) + assert stateless_input.chain_config == chain_config + + class TestSerializeStatelessInput: """Test serialize_stateless_input.""" From 08bb71c6a3f581e47cfcc4dc8771069bf2301c5b Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 4 May 2026 13:47:51 -0300 Subject: [PATCH 172/265] validate stateless chain config activations Signed-off-by: Ignacio Hagopian --- src/ethereum/forks/amsterdam/stateless.py | 72 ++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 0aa35e504e2..18f97af60dc 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -18,7 +18,7 @@ from .blocks import Block, Header from .execution_engine.new_payload import execute_new_payload_request from .execution_engine.requests import ExecutionRequests -from .execution_engine.types import NewPayloadRequest +from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork import ChainContext from .fork_types import VersionedHash from .witness_state import WitnessState, build_code_db, build_node_db @@ -107,6 +107,24 @@ class ProtocolFork(StrEnum): Amsterdam = "Amsterdam" +class ChainConfigValidationError(Exception): + """ + Raised when a chain config cannot be used by this stateless guest. + """ + + +class MissingActiveForkError(ChainConfigValidationError): + """ + Raised when no fork entry is active for the target payload. + """ + + +class InvalidForkActivationError(ChainConfigValidationError): + """ + Raised when a fork entry has a malformed activation point. + """ + + @slotted_freezable @dataclass class ForkActivation: @@ -260,6 +278,53 @@ def validate_headers( return headers, block_hashes +def _is_activation_active( + activation: ForkActivation, + execution_payload: ExecutionPayload, +) -> bool: + """ + Return whether an activation point is active for the payload. + """ + if activation.block_number is None and activation.timestamp is None: + raise InvalidForkActivationError( + "Fork activation must set block_number or timestamp" + ) + + if activation.block_number is not None and int( + execution_payload.block_number + ) < int(activation.block_number): + return False + + if activation.timestamp is not None and int( + execution_payload.timestamp + ) < int(activation.timestamp): + return False + + return True + + +def validate_chain_config( + chain_config: ChainConfig, + new_payload_request: NewPayloadRequest, +) -> ForkConfig: + """ + Validate the chain config and return the target payload's active fork. + """ + active_fork: ForkConfig | None = None + execution_payload = new_payload_request.execution_payload + + for fork_config in chain_config.forks: + if _is_activation_active(fork_config.activation, execution_payload): + active_fork = fork_config + + if active_fork is None: + raise MissingActiveForkError( + "No ChainConfig fork is active for the target payload" + ) + + return active_fork + + def verify_stateless_new_payload( stateless_input: StatelessInput, ) -> StatelessValidationResult: @@ -272,6 +337,11 @@ def verify_stateless_new_payload( witness = stateless_input.witness try: + validate_chain_config( + stateless_input.chain_config, + stateless_input.new_payload_request, + ) + # Validate the headers are contiguous and compute their # blockhashes. decoded_headers, block_hashes = validate_headers(witness.headers) From 1231e0472d45988b0b77fbda525e280d25eb015c Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 4 May 2026 14:12:58 -0300 Subject: [PATCH 173/265] nit Signed-off-by: Ignacio Hagopian --- src/ethereum/forks/amsterdam/stateless.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 18f97af60dc..72fee8e55fe 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -165,9 +165,6 @@ class ForkConfig: class ChainConfig: """ Chain configuration needed for stateless validation. - - TODO: Since we do not want the client to hold all possible chains, - we may want to add more to the chain config, like a genesis file. """ chain_id: U64 From 7d8275c3df8dc095f01391fb09b0ef2513fe96f0 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 4 May 2026 14:27:24 -0300 Subject: [PATCH 174/265] lints --- src/ethereum/forks/amsterdam/stateless_ssz.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 5b4465c7c11..01e92a4e23e 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -6,6 +6,8 @@ functions between the two representations. """ +from typing import TypeAlias + from ethereum_types.bytes import Bytes, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint from remerkleable.basic import boolean, uint64, uint256 @@ -66,7 +68,7 @@ # --- SSZ Container types --- -SszOptionalForkActivationValue = SszList[ +SszOptionalForkActivationValue: TypeAlias = SszList[ uint64, MAX_OPTIONAL_FORK_ACTIVATION_VALUES ] @@ -175,7 +177,7 @@ class SszBlobSchedule(Container): base_fee_update_fraction: uint64 -SszOptionalBlobSchedule = SszList[ +SszOptionalBlobSchedule: TypeAlias = SszList[ SszBlobSchedule, MAX_BLOB_SCHEDULES_PER_FORK ] @@ -188,7 +190,7 @@ class SszForkConfig(Container): blob_schedule: SszOptionalBlobSchedule -SszForkConfigList = SszList[SszForkConfig, MAX_FORK_CONFIGS] +SszForkConfigList: TypeAlias = SszList[SszForkConfig, MAX_FORK_CONFIGS] class SszChainConfig(Container): From c90d9690d48d743aa4dc967fbd216890a8174295 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 4 May 2026 18:34:09 -0300 Subject: [PATCH 175/265] always check the ssz output bytes contain the new chainconfig struct --- .../plugins/filler/tests/test_filler.py | 3 + .../src/execution_testing/specs/blockchain.py | 70 ++++++++++++++----- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index bb41faf5b21..7f6d47a7e27 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1046,14 +1046,17 @@ def test_execution_witness_in_blockchain_fixture( assert isinstance(sob, str) and sob.startswith("0x") and len(sob) > 2 from ethereum.forks.amsterdam.stateless_host import ( + build_chain_config, deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes + from ethereum_types.numeric import U64 stateless_output = deserialize_stateless_output( EthereumBytes(bytes.fromhex(sob[2:])) ) assert stateless_output.successful_validation is True + assert stateless_output.chain_config == build_chain_config(U64(1)) engine_fixture_path = Path( "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 40b19bb096d..38c17c6e276 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -283,34 +283,55 @@ def rerun_amsterdam_stateless_guest_with_witness( ) -def deserialize_amsterdam_stateless_output_success( +def decode_amsterdam_stateless_output( *, fork: Fork, block_number: int, timestamp: int, - stateless_output_bytes: Bytes, -) -> bool: + stateless_output_bytes: Bytes | None, +) -> Any | None: """ - Decode the canonical t8n stateless output and return success status. + Decode Amsterdam stateless output, if available for the active fork. Amsterdam is currently the only fork with stateless guest support in this repository, so the decode path is kept Amsterdam-specific. """ active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam": - raise Exception( - "Stateless output decoding is only supported for Amsterdam" - ) + if active_fork.name() != "Amsterdam" or stateless_output_bytes is None: + return None from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as AmsterdamBytes - stateless_output = deserialize_stateless_output( + return deserialize_stateless_output( AmsterdamBytes(bytes(stateless_output_bytes)) ) - return stateless_output.successful_validation + + +def assert_amsterdam_stateless_output_chain_config( + *, + block_number: int, + chain_id: int, + stateless_output: Any | None, +) -> None: + """ + Assert the stateless output reports the fixed Amsterdam chain config. + """ + if stateless_output is None: + return + + from ethereum.forks.amsterdam.stateless_host import build_chain_config + from ethereum_types.numeric import U64 + + expected_chain_config = build_chain_config(U64(chain_id)) + if stateless_output.chain_config != expected_chain_config: + raise AssertionError( + "Stateless output chain_config mismatch for block " + f"{block_number}: got {stateless_output.chain_config}, " + f"want {expected_chain_config}" + ) class Header(CamelModel): @@ -1040,6 +1061,12 @@ def generate_block_data( stateless_output_bytes = ( transition_tool_output.result.stateless_output_bytes ) + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + stateless_output_bytes=stateless_output_bytes, + ) expected_success = block.expected_stateless_validation_success if has_witness_modifier and expected_success is None: raise AssertionError( @@ -1053,14 +1080,11 @@ def generate_block_data( "Stateless guest verification requires stateless output " "bytes" ) - canonical_successful_validation = ( - deserialize_amsterdam_stateless_output_success( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - stateless_output_bytes=stateless_output_bytes, + if stateless_output is None: + raise Exception( + "Stateless output decoding is only supported for Amsterdam" ) - ) + canonical_successful_validation = stateless_output.successful_validation should_rerun_stateless_guest = has_witness_modifier if should_rerun_stateless_guest: @@ -1080,6 +1104,12 @@ def generate_block_data( original_stateless_input_bytes=stateless_input_bytes, execution_witness=execution_witness, ) + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + stateless_output_bytes=stateless_output_bytes, + ) if ( expected_success is not None and successful_validation != expected_success @@ -1099,6 +1129,12 @@ def generate_block_data( f"want {expected_success}" ) + assert_amsterdam_stateless_output_chain_config( + block_number=int(env.number), + chain_id=self.chain_id, + stateless_output=stateless_output, + ) + built_block = BuiltBlock( header=header, alloc=transition_tool_output.alloc, From 7a7472cf14ecaa2498469f288c12d3626a3b8c8f Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 4 May 2026 18:44:57 -0300 Subject: [PATCH 176/265] lints --- packages/testing/src/execution_testing/specs/blockchain.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 38c17c6e276..e3162214796 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -1084,7 +1084,9 @@ def generate_block_data( raise Exception( "Stateless output decoding is only supported for Amsterdam" ) - canonical_successful_validation = stateless_output.successful_validation + canonical_successful_validation = ( + stateless_output.successful_validation + ) should_rerun_stateless_guest = has_witness_modifier if should_rerun_stateless_guest: From 56235ce097ca4ace610e695d2d8aa071f4b1c07b Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 4 May 2026 19:26:03 -0300 Subject: [PATCH 177/265] use 64 as max forks ssz limit --- src/ethereum/forks/amsterdam/stateless_ssz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 01e92a4e23e..c02d37b24d9 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -58,7 +58,7 @@ MAX_BYTES_PER_WITNESS_NODE = 2**20 MAX_BYTES_PER_CODE = 2**24 MAX_BYTES_PER_HEADER = 2**10 -MAX_FORK_CONFIGS = 32 +MAX_FORK_CONFIGS = 64 MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 MAX_BLOB_SCHEDULES_PER_FORK = 1 MAX_PUBLIC_KEYS = 2**20 From 73427c2c2a7344fa0b5d04ab9d2d118d0f67fef6 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 5 May 2026 16:34:45 -0300 Subject: [PATCH 178/265] allow blockchain tests to skip stateless validation Signed-off-by: Ignacio Hagopian --- .../pytest_commands/plugins/filler/filler.py | 8 ++ .../plugins/filler/tests/test_filler.py | 83 +++++++++++++++++++ .../client_clis/clis/execution_specs.py | 2 + .../client_clis/transition_tool.py | 1 + .../src/execution_testing/specs/blockchain.py | 25 +++++- src/ethereum/forks/amsterdam/stateless_ssz.py | 5 +- .../evm_tools/t8n/__init__.py | 9 ++ .../evm_tools/t8n/t8n_types.py | 3 +- .../eip4895_withdrawals/test_withdrawals.py | 13 ++- 9 files changed, 142 insertions(+), 7 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py index 5a733c09ad5..bf4a8a7f6be 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py @@ -1683,6 +1683,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs["operation_mode"] = op_mode kwargs["is_tx_gas_heavy_test"] = is_tx_gas_heavy_test kwargs["is_exception_test"] = is_exception_test + if ( + "skip_stateless_validation" in cls.model_fields + and "skip_stateless_validation" not in kwargs + and request.node.get_closest_marker( + "skip_stateless_validation" + ) + ): + kwargs["skip_stateless_validation"] = True if ( op_mode == OpMode.OPTIMIZE_GAS or op_mode == OpMode.OPTIMIZE_GAS_POST_PROCESSING diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index bb41faf5b21..b65aec13f00 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -841,6 +841,40 @@ def test_execution_witness(state_test, pre) -> None: """ ) +test_module_execution_witness_skip_stateless = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, + ) + + @pytest.mark.valid_at("Amsterdam") + @pytest.mark.skip_stateless_validation + def test_skip_stateless_validation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) + sender = pre.fund_eoa() + tx = Transaction(to=contract, gas_limit=100_000, sender=sender) + + blockchain_test( + pre=pre, + post={ + contract: Account(storage={0: 1}), + sender: Account(nonce=1), + }, + blocks=[Block(txs=[tx])], + ) + """ +) + test_module_execution_witness_soundness = textwrap.dedent( """\ import pytest @@ -1084,6 +1118,55 @@ def test_execution_witness_in_blockchain_fixture( assert engine_witness == witness +def test_execution_witness_skip_stateless_validation( + testdir: pytest.Testdir, +) -> None: + """The skip marker omits stateless witness and byte outputs.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_skip_stateless_validation.py" + ) + test_module.write(test_module_execution_witness_skip_stateless) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = [ + "-c", + "pytest-fill.ini", + "-v", + "--until=Amsterdam", + "-m", + "blockchain_test", + "--no-html", + ] + result = testdir.runpytest(*args) + result.assert_outcomes( + passed=1, + failed=0, + skipped=0, + errors=0, + ) + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_skip_stateless_validation/skip_stateless_validation.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + assert len(fixture_data) == 1, "Expected exactly one fixture" + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][0] + + assert "executionWitness" not in block + assert "statelessInputBytes" not in block + assert "statelessOutputBytes" not in block + + def test_execution_witness_expected_true_reuses_canonical_stateless_result( testdir: pytest.Testdir, ) -> None: diff --git a/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py b/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py index 55434840aeb..4b57d004bf5 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py +++ b/packages/testing/src/execution_testing/client_clis/clis/execution_specs.py @@ -97,6 +97,8 @@ def _evaluate( if transition_tool_data.state_test: t8n_args.append("--state-test") + if transition_tool_data.skip_stateless_validation: + t8n_args.append("--no-stateless") if transition_tool_data.blob_params: fork = transition_tool_data.fork diff --git a/packages/testing/src/execution_testing/client_clis/transition_tool.py b/packages/testing/src/execution_testing/client_clis/transition_tool.py index 19f82bfadfb..a70119761f2 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -310,6 +310,7 @@ class TransitionToolData: reward: int blob_schedule: BlobSchedule | None state_test: bool = False + skip_stateless_validation: bool = False @property def fork_name(self) -> str: diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 40b19bb096d..56a8b054b68 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -731,6 +731,11 @@ class BlockchainTest(BaseTest): """ Include transaction receipts in the fixture output. """ + skip_stateless_validation: bool = False + """ + Skip stateless witness generation, input serialization, and guest + validation for this test. + """ supported_fixture_formats: ClassVar[ Sequence[FixtureFormat | LabeledFixtureFormat] @@ -753,6 +758,10 @@ class BlockchainTest(BaseTest): "Only generate a blockchain test engine fixture" ), "blockchain_test_only": "Only generate a blockchain test fixture", + "skip_stateless_validation": ( + "Skip stateless witness generation, input serialization, and " + "guest validation." + ), } @classmethod @@ -863,6 +872,7 @@ def generate_block_data( chain_id=self.chain_id, reward=fork.get_reward(), blob_schedule=fork.blob_schedule(), + skip_stateless_validation=self.skip_stateless_validation, ), slow_request=self.is_tx_gas_heavy_test, ) @@ -976,6 +986,20 @@ def generate_block_data( # If expected witness state/codes defined, verify against actual t8n_witness = transition_tool_output.result.execution_witness execution_witness = t8n_witness + has_witness_expectation = ( + block.expected_execution_witness_state is not None + or block.expected_execution_witness_codes is not None + or block.expected_execution_witness_headers is not None + ) + expected_success = block.expected_stateless_validation_success + if self.skip_stateless_validation and ( + has_witness_expectation or expected_success is not None + ): + raise AssertionError( + "skip_stateless_validation cannot be combined with " + "execution witness expectations or " + "expected_stateless_validation_success" + ) state_expectation = block.expected_execution_witness_state if state_expectation is not None and execution_witness is not None: state_expectation.verify_against(execution_witness) @@ -1040,7 +1064,6 @@ def generate_block_data( stateless_output_bytes = ( transition_tool_output.result.stateless_output_bytes ) - expected_success = block.expected_stateless_validation_success if has_witness_modifier and expected_success is None: raise AssertionError( "Mutated execution witness tests must set " diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 3b54972888d..829a2d91db5 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -37,10 +37,7 @@ MAX_EXTRA_DATA_BYTES = 32 MAX_BYTES_PER_TRANSACTION = 2**30 MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 -# TODO: CL spec defines MAX_WITHDRAWALS_PER_PAYLOAD as 2**4 (16). -# Some fill tests exceed this; raised to 2**16 until those tests are -# capped or skipped for Amsterdam. -MAX_WITHDRAWALS_PER_PAYLOAD = 2**16 +MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**13 MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = 2**4 diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index c4a7462cd44..1dac1a075aa 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -95,6 +95,15 @@ def t8n_arguments(subparsers: argparse._SubParsersAction) -> None: t8n_parser.add_argument("--opcode.count", dest="opcode_count", type=str) t8n_parser.add_argument("--state-test", action="store_true") + t8n_parser.add_argument( + "--no-stateless", + dest="no_stateless", + action="store_true", + help=( + "Skip stateless witness generation, input serialization, " + "and guest validation." + ), + ) class ForkCache(AbstractContextManager): diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 980d36079e9..ca05bab802f 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -311,6 +311,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: """ Update the result after processing the inputs. """ + skip_stateless = getattr(t8n.options, "no_stateless", False) self.gas_used = block_output.block_gas_used self.tx_root = t8n.fork.root(block_output.transactions_trie) self.receipt_root = t8n.fork.root(block_output.receipts_trie) @@ -336,7 +337,7 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: # the witness reads pre-state tries that apply_changes mutates. # This is safe because compute_state_root_and_trie_changes # does not mutate state (it makes transient copies of MPTs). - if t8n.fork.has_execution_witness: + if t8n.fork.has_execution_witness and not skip_stateless: self.execution_witness = t8n.fork.build_execution_witness( block_env.state, expected_post_state_root=state_root_value, diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index c521628d665..9b679546acc 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -297,7 +297,17 @@ def test_withdrawals_root( blockchain_test(pre=pre, post={}, blocks=blocks) -@pytest.mark.parametrize("test_case", ["single_block", "multiple_blocks"]) +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + "single_block", + marks=pytest.mark.skip_stateless_validation, + id="single_block", + ), + pytest.param("multiple_blocks", id="multiple_blocks"), + ], +) class TestMultipleWithdrawalsSameAddress: """ Test that multiple withdrawals can be sent to the same address. @@ -372,6 +382,7 @@ def test_multiple_withdrawals_same_address( blockchain_test(pre=pre, post=post, blocks=blocks) +@pytest.mark.skip_stateless_validation def test_many_withdrawals( blockchain_test: BlockchainTestFiller, pre: Alloc, From 6a741aa2ced41b30ae14effd487773add48e04b4 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 6 May 2026 16:47:26 -0300 Subject: [PATCH 179/265] switch to single fork chain config given we validate a single block --- src/ethereum/forks/amsterdam/stateless.py | 50 +++++++++++++++---- .../forks/amsterdam/stateless_host.py | 24 ++++----- src/ethereum/forks/amsterdam/stateless_ssz.py | 15 ++---- tests/json_loader/test_stateless_guest.py | 33 ++++++------ 4 files changed, 68 insertions(+), 54 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 72fee8e55fe..0964ea36646 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -21,6 +21,11 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork import ChainContext from .fork_types import VersionedHash +from .vm.gas import ( + BLOB_BASE_FEE_UPDATE_FRACTION, + BLOB_SCHEDULE_MAX, + BLOB_SCHEDULE_TARGET, +) from .witness_state import WitnessState, build_code_db, build_node_db @@ -113,9 +118,9 @@ class ChainConfigValidationError(Exception): """ -class MissingActiveForkError(ChainConfigValidationError): +class InactiveForkConfigError(ChainConfigValidationError): """ - Raised when no fork entry is active for the target payload. + Raised when the configured active fork is not active for the payload. """ @@ -125,6 +130,12 @@ class InvalidForkActivationError(ChainConfigValidationError): """ +class UnsupportedForkConfigError(ChainConfigValidationError): + """ + Raised when this guest cannot execute the configured active fork. + """ + + @slotted_freezable @dataclass class ForkActivation: @@ -168,7 +179,7 @@ class ChainConfig: """ chain_id: U64 - forks: Tuple[ForkConfig, ...] + active_fork: ForkConfig @slotted_freezable @@ -300,23 +311,40 @@ def _is_activation_active( return True +def _expected_amsterdam_blob_schedule() -> BlobSchedule: + """ + Return the blob schedule currently compiled into the Amsterdam guest. + """ + return BlobSchedule( + target=BLOB_SCHEDULE_TARGET, + max=BLOB_SCHEDULE_MAX, + base_fee_update_fraction=U64(BLOB_BASE_FEE_UPDATE_FRACTION), + ) + + def validate_chain_config( chain_config: ChainConfig, new_payload_request: NewPayloadRequest, ) -> ForkConfig: """ - Validate the chain config and return the target payload's active fork. + Validate and return the target payload's active fork config. """ - active_fork: ForkConfig | None = None + active_fork = chain_config.active_fork execution_payload = new_payload_request.execution_payload - for fork_config in chain_config.forks: - if _is_activation_active(fork_config.activation, execution_payload): - active_fork = fork_config + if not _is_activation_active(active_fork.activation, execution_payload): + raise InactiveForkConfigError( + "ChainConfig active_fork is not active for the target payload" + ) + + if active_fork.fork != ProtocolFork.Amsterdam: + raise UnsupportedForkConfigError( + f"Amsterdam stateless guest cannot execute {active_fork.fork}" + ) - if active_fork is None: - raise MissingActiveForkError( - "No ChainConfig fork is active for the target payload" + if active_fork.blob_schedule != _expected_amsterdam_blob_schedule(): + raise UnsupportedForkConfigError( + "ChainConfig active_fork blob_schedule does not match Amsterdam" ) return active_fork diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index ae99d366a7b..6fd25fb5273 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -64,20 +64,16 @@ def build_chain_config(chain_id: U64) -> ChainConfig: """ return ChainConfig( chain_id=chain_id, - forks=( - ForkConfig( - fork=ProtocolFork.Amsterdam, - activation=ForkActivation( - block_number=None, - timestamp=U64(0), - ), - blob_schedule=BlobSchedule( - target=BLOB_SCHEDULE_TARGET, - max=BLOB_SCHEDULE_MAX, - base_fee_update_fraction=U64( - BLOB_BASE_FEE_UPDATE_FRACTION - ), - ), + active_fork=ForkConfig( + fork=ProtocolFork.Amsterdam, + activation=ForkActivation( + block_number=None, + timestamp=U64(0), + ), + blob_schedule=BlobSchedule( + target=BLOB_SCHEDULE_TARGET, + max=BLOB_SCHEDULE_MAX, + base_fee_update_fraction=U64(BLOB_BASE_FEE_UPDATE_FRACTION), ), ), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index c02d37b24d9..070ef5a018f 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -58,7 +58,6 @@ MAX_BYTES_PER_WITNESS_NODE = 2**20 MAX_BYTES_PER_CODE = 2**24 MAX_BYTES_PER_HEADER = 2**10 -MAX_FORK_CONFIGS = 64 MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 MAX_BLOB_SCHEDULES_PER_FORK = 1 MAX_PUBLIC_KEYS = 2**20 @@ -190,14 +189,11 @@ class SszForkConfig(Container): blob_schedule: SszOptionalBlobSchedule -SszForkConfigList: TypeAlias = SszList[SszForkConfig, MAX_FORK_CONFIGS] - - class SszChainConfig(Container): """SSZ container mirroring ``ChainConfig``.""" chain_id: uint64 - forks: SszForkConfigList + active_fork: SszForkConfig class SszStatelessInput(Container): @@ -587,9 +583,7 @@ def _chain_config_to_ssz( """Convert a ChainConfig to its SSZ form.""" return SszChainConfig( chain_id=uint64(int(cc.chain_id)), - forks=SszForkConfigList( - _fork_config_to_ssz(fork_config) for fork_config in cc.forks - ), + active_fork=_fork_config_to_ssz(cc.active_fork), ) @@ -599,10 +593,7 @@ def _ssz_to_chain_config( """Convert an SSZ chain config back.""" return ChainConfig( chain_id=U64(scc.chain_id), - forks=tuple( - _ssz_to_fork_config(ssz_fork_config) - for ssz_fork_config in scc.forks - ), + active_fork=_ssz_to_fork_config(scc.active_fork), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index e91a1e2ea14..c17d4abbbfe 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -3,6 +3,7 @@ import random from typing import Tuple +import pytest from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint @@ -21,6 +22,7 @@ from ethereum.forks.amsterdam.stateless import ( BlobSchedule, ChainConfig, + ChainConfigValidationError, ExecutionWitness, ForkActivation, ForkConfig, @@ -28,6 +30,7 @@ StatelessInput, StatelessValidationResult, compute_new_payload_request_root, + validate_chain_config, ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, @@ -116,20 +119,16 @@ def _make_block() -> Block: def _expected_amsterdam_chain_config(chain_id: U64) -> ChainConfig: return ChainConfig( chain_id=chain_id, - forks=( - ForkConfig( - fork=ProtocolFork.Amsterdam, - activation=ForkActivation( - block_number=None, - timestamp=U64(0), - ), - blob_schedule=BlobSchedule( - target=BLOB_SCHEDULE_TARGET, - max=BLOB_SCHEDULE_MAX, - base_fee_update_fraction=U64( - BLOB_BASE_FEE_UPDATE_FRACTION - ), - ), + active_fork=ForkConfig( + fork=ProtocolFork.Amsterdam, + activation=ForkActivation( + block_number=None, + timestamp=U64(0), + ), + blob_schedule=BlobSchedule( + target=BLOB_SCHEDULE_TARGET, + max=BLOB_SCHEDULE_MAX, + base_fee_update_fraction=U64(BLOB_BASE_FEE_UPDATE_FRACTION), ), ), ) @@ -237,7 +236,7 @@ def test_empty_witness(self) -> None: ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), - chain_config=ChainConfig(chain_id=U64(1), forks=()), + chain_config=build_chain_config(U64(1)), public_keys=(), ) encoded = serialize_stateless_input(original) @@ -269,7 +268,7 @@ def test_empty_witness(self) -> None: ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), - chain_config=ChainConfig(chain_id=U64(1), forks=()), + chain_config=build_chain_config(U64(1)), public_keys=(), ) encoded = serialize_stateless_input(original) @@ -292,7 +291,7 @@ def test_failed_validation(self) -> None: original = StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=False, - chain_config=ChainConfig(chain_id=U64(1), forks=()), + chain_config=build_chain_config(U64(1)), ) encoded = serialize_stateless_output(original) recovered = deserialize_stateless_output(encoded) From e477e83913fd5e4420ed7b3aa8af212f7fdff50f Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 6 May 2026 16:55:47 -0300 Subject: [PATCH 180/265] lints --- tests/json_loader/test_stateless_guest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index c17d4abbbfe..5985054370f 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -3,7 +3,6 @@ import random from typing import Tuple -import pytest from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint @@ -22,7 +21,6 @@ from ethereum.forks.amsterdam.stateless import ( BlobSchedule, ChainConfig, - ChainConfigValidationError, ExecutionWitness, ForkActivation, ForkConfig, @@ -30,7 +28,6 @@ StatelessInput, StatelessValidationResult, compute_new_payload_request_root, - validate_chain_config, ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, From 23d0665d010c80712e0e0a84cb92108a686a0783 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 6 May 2026 19:09:43 -0300 Subject: [PATCH 181/265] implement pubkey stateless input field generation and validation --- .../amsterdam/execution_engine/new_payload.py | 20 +++- src/ethereum/forks/amsterdam/fork.py | 26 ++++- src/ethereum/forks/amsterdam/stateless.py | 20 +++- .../forks/amsterdam/stateless_host.py | 13 ++- src/ethereum/forks/amsterdam/stateless_ssz.py | 16 +-- src/ethereum/forks/amsterdam/transactions.py | 98 ++++++++++++++++--- src/ethereum/forks/amsterdam/vm/__init__.py | 1 + tests/json_loader/test_stateless_guest.py | 71 +++++++++++++- 8 files changed, 236 insertions(+), 29 deletions(-) diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py index 1a7d5b2a288..fb169fb1146 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -2,9 +2,10 @@ Payload verification. """ -from typing import Tuple +from typing import Optional, Tuple from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock @@ -71,6 +72,7 @@ def execute_new_payload_request( new_payload_request: NewPayloadRequest, pre_state: PreState, chain_context: ChainContext, + transaction_public_keys: Optional[Tuple[Bytes, ...]] = None, ) -> Tuple[BlockDiff, Block]: """ Validate and execute a payload against ``pre_state``. @@ -88,6 +90,8 @@ def execute_new_payload_request( Pre-execution state provider. chain_context : Chain context needed for block execution. + transaction_public_keys : + Optional transaction public keys in payload order. Returns ------- @@ -101,6 +105,13 @@ def execute_new_payload_request( parent_beacon_block_root = new_payload_request.parent_beacon_block_root execution_requests = new_payload_request.execution_requests + if transaction_public_keys is not None and len( + transaction_public_keys + ) != len(payload.transactions): + raise InvalidBlock( + "Transaction public key count does not match payload transactions" + ) + if b"" in payload.transactions: raise InvalidBlock("Empty transaction in payload") @@ -119,7 +130,12 @@ def execute_new_payload_request( parent_beacon_block_root, execution_requests, ) - block_diff = execute_block(block, pre_state, chain_context) + block_diff = execute_block( + block, + pre_state, + chain_context, + transaction_public_keys=transaction_public_keys, + ) return block_diff, block diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 3c84573c3e2..343a9053f10 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -88,6 +88,7 @@ get_transaction_hash, has_access_list, recover_sender, + recover_sender_from_public_key, validate_transaction, ) from .trie import root, trie_set @@ -269,6 +270,7 @@ def execute_block( block: Block, pre_state: PreState, chain_context: ChainContext, + transaction_public_keys: Optional[Tuple[Bytes, ...]] = None, ) -> BlockDiff: """ Execute a block and validate the resulting roots against the header. @@ -283,6 +285,8 @@ def execute_block( Pre-execution state provider. chain_context : Chain context that the block may need during execution. + transaction_public_keys : + Optional transaction public keys in block order. Returns ------- @@ -293,6 +297,13 @@ def execute_block( if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE: raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE") + if transaction_public_keys is not None and len( + transaction_public_keys + ) != len(block.transactions): + raise InvalidBlock( + "Transaction public key count does not match block transactions" + ) + parent_header = chain_context.parent_header validate_header(parent_header, block.header) @@ -314,6 +325,7 @@ def execute_block( excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, block_access_list_builder=BlockAccessListBuilder(), + transaction_public_keys=transaction_public_keys, ) block_output = apply_body( @@ -483,6 +495,7 @@ def check_transaction( block_output: vm.BlockOutput, tx: Transaction, tx_state: TransactionState, + sender_public_key: Optional[Bytes] = None, ) -> Tuple[Address, Uint, Tuple[VersionedHash, ...], U64]: """ Check if the transaction is includable in the block. @@ -497,6 +510,8 @@ def check_transaction( The transaction. tx_state : The transaction state tracker. + sender_public_key : + Optional sender public key to verify instead of recovering. Returns ------- @@ -554,7 +569,12 @@ def check_transaction( if tx_blob_gas_used > blob_gas_available: raise BlobGasLimitExceededError("blob gas limit exceeded") - sender_address = recover_sender(block_env.chain_id, tx) + if sender_public_key is None: + sender_address = recover_sender(block_env.chain_id, tx) + else: + sender_address = recover_sender_from_public_key( + block_env.chain_id, tx, sender_public_key + ) sender_account = get_account(tx_state, sender_address) if isinstance( @@ -975,6 +995,9 @@ def process_transaction( ) intrinsic_gas, calldata_floor_gas_cost = validate_transaction(tx) + sender_public_key = None + if block_env.transaction_public_keys is not None: + sender_public_key = block_env.transaction_public_keys[int(index)] ( sender, @@ -986,6 +1009,7 @@ def process_transaction( block_output=block_output, tx=tx, tx_state=tx_state, + sender_public_key=sender_public_key, ) sender_account = get_account(tx_state, sender) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index d7a38376e67..4ba278e1bb5 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -114,7 +114,7 @@ class StatelessInput: public_keys: Tuple[Bytes, ...] """ - Recovered transaction public keys, in transaction order. + 65-byte uncompressed transaction public keys, in payload order. """ @@ -203,6 +203,8 @@ def verify_stateless_new_payload( witness = stateless_input.witness try: + validate_transaction_public_keys(stateless_input) + # Validate the headers are contiguous and compute their # blockhashes. decoded_headers, block_hashes = validate_headers(witness.headers) @@ -224,6 +226,7 @@ def verify_stateless_new_payload( stateless_input.new_payload_request, pre_state, chain_context, + transaction_public_keys=stateless_input.public_keys, ) successful_validation = True except Exception: @@ -234,3 +237,18 @@ def verify_stateless_new_payload( successful_validation=successful_validation, chain_config=stateless_input.chain_config, ) + + +def validate_transaction_public_keys( + stateless_input: StatelessInput, +) -> None: + """ + Validate that the transaction public key witness matches the payload. + """ + transaction_count = len( + stateless_input.new_payload_request.execution_payload.transactions + ) + if len(stateless_input.public_keys) != transaction_count: + raise ValueError( + "Transaction public key count does not match payload transactions" + ) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 016c60c4b40..ef3eba95392 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -30,6 +30,7 @@ BlobTransaction, LegacyTransaction, decode_transaction, + recover_transaction_public_key, ) @@ -65,12 +66,15 @@ def build_stateless_input( header = block.header block_hash = Hash32(keccak256(rlp.encode(header))) - # Encode transactions to bytes and collect versioned hashes. + # Encode transactions to bytes, recover public keys, and collect + # versioned hashes. tx_bytes_list: List[Bytes] = [] + public_keys: List[Bytes] = [] versioned_hashes: List[VersionedHash] = [] for tx in block.transactions: if isinstance(tx, LegacyTransaction): tx_bytes_list.append(Bytes(rlp.encode(tx))) + tx_obj = tx else: tx_bytes_list.append(Bytes(tx)) # A typed tx may be malformed (pre-execution-rejected by @@ -80,8 +84,9 @@ def build_stateless_input( tx_obj = decode_transaction(tx) except Exception: continue - if isinstance(tx_obj, BlobTransaction): - versioned_hashes.extend(tx_obj.blob_versioned_hashes) + public_keys.append(recover_transaction_public_key(chain_id, tx_obj)) + if isinstance(tx_obj, BlobTransaction): + versioned_hashes.extend(tx_obj.blob_versioned_hashes) # Block access list as RLP bytes. bal_bytes = Bytes(rlp.encode(block_access_list)) @@ -118,5 +123,5 @@ def build_stateless_input( new_payload_request=new_payload, witness=execution_witness, chain_config=ChainConfig(chain_id=chain_id), - public_keys=(), + public_keys=tuple(public_keys), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 829a2d91db5..685f2923636 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -50,7 +50,7 @@ MAX_BYTES_PER_CODE = 2**24 MAX_BYTES_PER_HEADER = 2**10 MAX_PUBLIC_KEYS = 2**20 -MAX_BYTES_PER_PUBLIC_KEY = 65 +PUBLIC_KEY_BYTES = 65 # --- SSZ Container types --- @@ -157,7 +157,7 @@ class SszStatelessInput(Container): new_payload_request: SszNewPayloadRequest witness: SszExecutionWitness chain_config: SszChainConfig - public_keys: SszList[ByteList[MAX_BYTES_PER_PUBLIC_KEY], MAX_PUBLIC_KEYS] + public_keys: SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS] class SszStatelessValidationResult(Container): @@ -425,16 +425,20 @@ def stateless_input_to_ssz( si: StatelessInput, ) -> SszStatelessInput: """Convert a StatelessInput to its SSZ form.""" + for public_key in si.public_keys: + if len(public_key) != PUBLIC_KEY_BYTES: + raise ValueError( + f"Transaction public key must be {PUBLIC_KEY_BYTES} bytes" + ) + return SszStatelessInput( new_payload_request=_new_payload_request_to_ssz( si.new_payload_request ), witness=_witness_to_ssz(si.witness), chain_config=_chain_config_to_ssz(si.chain_config), - public_keys=SszList[ - ByteList[MAX_BYTES_PER_PUBLIC_KEY], MAX_PUBLIC_KEYS - ]( - ByteList[MAX_BYTES_PER_PUBLIC_KEY](bytes(pk)) + public_keys=SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS]( + ByteVector[PUBLIC_KEY_BYTES](bytes(pk)) for pk in si.public_keys ), ) diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py index da7dfd13f5e..7fb1e0d9690 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -12,7 +12,10 @@ from ethereum_types.frozen import slotted_freezable from ethereum_types.numeric import U64, U256, Uint, ulen -from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover +from ethereum.crypto.elliptic_curve import ( + SECP256K1N, + secp256k1_recover, +) from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import ( InsufficientTransactionGasError, @@ -66,6 +69,7 @@ """ TX_MAX_GAS_LIMIT = Uint(16_777_216) +SECP256K1_UNCOMPRESSED_PUBLIC_KEY_PREFIX = b"\x04" @slotted_freezable @@ -676,6 +680,57 @@ def recover_sender(chain_id: U64, tx: Transaction) -> Address: the address of the sender of the transaction. It raises an `InvalidSignatureError` if the signature values (r, s, v) are invalid. """ + public_key = recover_transaction_public_key(chain_id, tx) + return _sender_address_from_public_key(public_key) + + +def recover_transaction_public_key(chain_id: U64, tx: Transaction) -> Bytes: + """ + Recover the canonical uncompressed SEC1 public key for a transaction. + """ + r, s, recovery_id, signing_hash = _signature_recovery_parameters( + chain_id, tx + ) + return Bytes( + SECP256K1_UNCOMPRESSED_PUBLIC_KEY_PREFIX + + secp256k1_recover(r, s, recovery_id, signing_hash) + ) + + +def recover_sender_from_public_key( + chain_id: U64, tx: Transaction, public_key: Bytes +) -> Address: + """ + Verify that ``public_key`` is the transaction sender's canonical + uncompressed SEC1 public key. + + This reference implementation verifies the supplied key by recovering the + canonical public key from the transaction signature and comparing the two. + Optimized implementations may avoid full public-key recovery, but must + still verify that the supplied key validates the signature and is + consistent with the transaction's recovery id / y-parity bit. Otherwise, + another valid recovery candidate could derive a different sender address. + + Returns the sender address derived from the verified public key. + """ + if public_key != recover_transaction_public_key(chain_id, tx): + raise InvalidSignatureError + return _sender_address_from_public_key(public_key) + + +def _sender_address_from_public_key(public_key: Bytes) -> Address: + """ + Derive the sender address from an uncompressed SEC1 public key. + """ + return Address(keccak256(bytes(public_key)[1:])[12:32]) + + +def _signature_recovery_parameters( + chain_id: U64, tx: Transaction +) -> Tuple[U256, U256, U256, Hash32]: + """ + Validate the transaction signature fields and return recovery inputs. + """ r, s = tx.r, tx.s if U256(0) >= r or r >= SECP256K1N: raise InvalidSignatureError("bad r") @@ -685,14 +740,17 @@ def recover_sender(chain_id: U64, tx: Transaction) -> Address: if isinstance(tx, LegacyTransaction): v = tx.v if v == 27 or v == 28: - public_key = secp256k1_recover( - r, s, v - U256(27), signing_hash_pre155(tx) + return ( + r, + s, + v - U256(27), + signing_hash_pre155(tx), ) else: chain_id_x2 = U256(chain_id) * U256(2) if v != U256(35) + chain_id_x2 and v != U256(36) + chain_id_x2: raise InvalidSignatureError("bad v") - public_key = secp256k1_recover( + return ( r, s, v - U256(35) - chain_id_x2, @@ -701,29 +759,41 @@ def recover_sender(chain_id: U64, tx: Transaction) -> Address: elif isinstance(tx, AccessListTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_2930(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_2930(tx), ) elif isinstance(tx, FeeMarketTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_1559(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_1559(tx), ) elif isinstance(tx, BlobTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_4844(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_4844(tx), ) elif isinstance(tx, SetCodeTransaction): if tx.y_parity not in (U256(0), U256(1)): raise InvalidSignatureError("bad y_parity") - public_key = secp256k1_recover( - r, s, tx.y_parity, signing_hash_7702(tx) + return ( + r, + s, + tx.y_parity, + signing_hash_7702(tx), ) - - return Address(keccak256(public_key)[12:32]) + else: + raise InvalidSignatureError("unsupported transaction type") def signing_hash_pre155(tx: LegacyTransaction) -> Hash32: diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 5d6b2fbfb72..19187d37b44 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -50,6 +50,7 @@ class BlockEnvironment: excess_blob_gas: U64 parent_beacon_block_root: Hash32 block_access_list_builder: BlockAccessListBuilder + transaction_public_keys: Optional[Tuple[Bytes, ...]] = None @dataclass diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index d847b48985d..f7f447e5e3a 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -3,6 +3,7 @@ import random from typing import Tuple +import pytest from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint @@ -22,6 +23,8 @@ StatelessInput, StatelessValidationResult, compute_new_payload_request_root, + validate_transaction_public_keys, + verify_stateless_new_payload, ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, @@ -96,7 +99,7 @@ def _make_stateless_input() -> StatelessInput: headers=(Bytes(_rb(512)), Bytes(_rb(512))), ), chain_config=ChainConfig(chain_id=U64(1)), - public_keys=(Bytes(_rb(33)), Bytes(_rb(33))), + public_keys=(Bytes(_rb(65)), Bytes(_rb(65))), ) @@ -139,6 +142,19 @@ def test_empty_witness(self) -> None: recovered = deserialize_stateless_input(encoded) assert recovered == original + def test_rejects_non_65_byte_public_key(self) -> None: + """Public keys must be 65-byte uncompressed SEC1 points.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_config=original.chain_config, + public_keys=(Bytes(_rb(64)), Bytes(_rb(65))), + ) + + with pytest.raises(ValueError): + serialize_stateless_input(invalid) + class TestDeserializeStatelessInput: """Test deserialize_stateless_input.""" @@ -209,3 +225,56 @@ def test_hash_tree_root_is_32_bytes(self) -> None: si = _make_stateless_input() root = compute_new_payload_request_root(si) assert len(root) == 32 + + +class TestTransactionPublicKeys: + """Test stateless transaction public-key validation.""" + + def test_count_must_match_payload_transactions(self) -> None: + """Count validation should require exactly one key per transaction.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_config=original.chain_config, + public_keys=(original.public_keys[0],), + ) + + with pytest.raises( + ValueError, + match=( + "Transaction public key count does not match payload " + "transactions" + ), + ): + validate_transaction_public_keys(invalid) + + def test_too_few_public_keys_fail_validation(self) -> None: + """Stateless validation should fail with too few public keys.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_config=original.chain_config, + public_keys=(original.public_keys[0],), + ) + + result = verify_stateless_new_payload(invalid) + assert not result.successful_validation + + def test_too_many_public_keys_fail_validation(self) -> None: + """Stateless validation should fail with too many public keys.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=original.witness, + chain_config=original.chain_config, + public_keys=( + original.public_keys[0], + original.public_keys[1], + Bytes(_rb(65)), + ), + ) + + result = verify_stateless_new_payload(invalid) + assert not result.successful_validation From c95252eb85b29247263366ea25ee1f5637ad837c Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 6 May 2026 19:18:56 -0300 Subject: [PATCH 182/265] lints --- src/ethereum/forks/amsterdam/stateless_host.py | 2 ++ src/ethereum/forks/amsterdam/stateless_ssz.py | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index ef3eba95392..9bda761552c 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -29,6 +29,7 @@ from .transactions import ( BlobTransaction, LegacyTransaction, + Transaction, decode_transaction, recover_transaction_public_key, ) @@ -72,6 +73,7 @@ def build_stateless_input( public_keys: List[Bytes] = [] versioned_hashes: List[VersionedHash] = [] for tx in block.transactions: + tx_obj: Transaction if isinstance(tx, LegacyTransaction): tx_bytes_list.append(Bytes(rlp.encode(tx))) tx_obj = tx diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 685f2923636..cdd77594f4f 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -438,8 +438,7 @@ def stateless_input_to_ssz( witness=_witness_to_ssz(si.witness), chain_config=_chain_config_to_ssz(si.chain_config), public_keys=SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS]( - ByteVector[PUBLIC_KEY_BYTES](bytes(pk)) - for pk in si.public_keys + ByteVector[PUBLIC_KEY_BYTES](bytes(pk)) for pk in si.public_keys ), ) From fc9acb2e0b67bd7036fdba108df7af23bc4600d8 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 7 May 2026 16:28:24 -0300 Subject: [PATCH 183/265] add basic tests for valid and invalid cases --- .../src/execution_testing/specs/blockchain.py | 201 +++++++++++++++--- .../test_types/execution_witness/modifiers.py | 27 ++- .../test_witness_public_keys.py | 89 ++++++++ 3 files changed, 290 insertions(+), 27 deletions(-) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 56a8b054b68..439d2dc8257 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -95,6 +95,9 @@ ExecutionWitnessHeadersExpectation, ExecutionWitnessStateExpectation, ) +from execution_testing.test_types.execution_witness.modifiers import ( + PublicKeyModifier, +) from .base import BaseTest, FillResult, OpMode, verify_result from .debugging import print_traces @@ -215,16 +218,17 @@ def with_execution_witness_implicit_codes( return expectation.model_copy(update={"codes_present": codes_present}) -def rerun_amsterdam_stateless_guest_with_witness( +def rerun_amsterdam_stateless_guest_with_overrides( *, fork: Fork, block_number: int, timestamp: int, original_stateless_input_bytes: Bytes, - execution_witness: ExecutionWitness, + execution_witness: ExecutionWitness | None = None, + public_keys: Tuple[Bytes, ...] | None = None, ) -> tuple[Bytes, Bytes, bool]: """ - Rebuild the stateless input with the emitted witness and rerun the guest. + Rebuild the stateless input with test overrides and rerun the guest. Amsterdam is currently the only fork with stateless guest support in this repository, so the rerun path is kept Amsterdam-specific. @@ -254,23 +258,31 @@ def rerun_amsterdam_stateless_guest_with_witness( original_input = deserialize_stateless_input( AmsterdamBytes(bytes(original_stateless_input_bytes)) ) - rebuilt_witness = AmsterdamExecutionWitness( - state=tuple( - AmsterdamBytes(bytes(node)) for node in execution_witness.state - ), - codes=tuple( - AmsterdamBytes(bytes(code)) for code in execution_witness.codes - ), - headers=tuple( - AmsterdamBytes(bytes(header)) - for header in execution_witness.headers - ), - ) + rebuilt_witness = original_input.witness + if execution_witness is not None: + rebuilt_witness = AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) + for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) + for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) rebuilt_input = AmsterdamStatelessInput( new_payload_request=original_input.new_payload_request, witness=rebuilt_witness, chain_config=original_input.chain_config, - public_keys=original_input.public_keys, + public_keys=( + tuple(AmsterdamBytes(bytes(key)) for key in public_keys) + if public_keys is not None + else original_input.public_keys + ), ) rebuilt_input_bytes = serialize_stateless_input(rebuilt_input) rebuilt_output_bytes = run_stateless_guest(rebuilt_input_bytes) @@ -283,6 +295,83 @@ def rerun_amsterdam_stateless_guest_with_witness( ) +def get_amsterdam_stateless_input_public_key_data( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> tuple[Tuple[Bytes, ...], Tuple[Bytes, ...]]: + """ + Decode Amsterdam stateless input public keys and payload transactions. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless input public-key decoding is only supported for " + "Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(stateless_input_bytes)) + ) + public_keys = tuple( + Bytes(bytes(public_key)) for public_key in stateless_input.public_keys + ) + payload_transactions = tuple( + Bytes(bytes(transaction)) + for transaction in ( + stateless_input.new_payload_request.execution_payload.transactions + ) + ) + return public_keys, payload_transactions + + +def verify_stateless_input_public_keys( + public_keys: Tuple[Bytes, ...], + payload_transactions: Tuple[Bytes, ...], + chain_id: int, +) -> None: + """ + Verify that every payload transaction has its recovered public key. + """ + payload_transaction_count = len(payload_transactions) + if len(public_keys) != payload_transaction_count: + raise AssertionError( + "Stateless input public key count does not match payload " + f"transactions: got {len(public_keys)} public keys for " + f"{payload_transaction_count} transactions" + ) + + from ethereum.forks.amsterdam.transactions import ( + decode_transaction, + recover_transaction_public_key, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U64 + + for index, (public_key, payload_transaction) in enumerate( + zip(public_keys, payload_transactions, strict=True) + ): + transaction = decode_transaction( + AmsterdamBytes(bytes(payload_transaction)) + ) + expected_public_key = recover_transaction_public_key( + U64(chain_id), + transaction, + ) + if bytes(public_key) != bytes(expected_public_key): + raise AssertionError( + "Stateless input public key " + f"{index} does not match recovered transaction public key" + ) + + def deserialize_amsterdam_stateless_output_success( *, fork: Fork, @@ -461,10 +550,18 @@ class Block(Header): If set, the execution witness headers will be verified and potentially modified for invalid tests. """ + stateless_input_public_keys_modifier: PublicKeyModifier | None = Field( + default=None, + exclude=True, + ) + """ + If set, mutate the stateless input transaction public keys before rerunning + the guest for invalid tests. + """ expected_stateless_validation_success: bool | None = None """ If set, assert the stateless guest result matches this expectation. This - must be set explicitly for tests that mutate the emitted execution witness. + must be set explicitly for tests that mutate stateless validation input. """ exception: BLOCK_EXCEPTION_TYPE = None # If set, the block is expected to be rejected by the client. @@ -991,13 +1088,18 @@ def generate_block_data( or block.expected_execution_witness_codes is not None or block.expected_execution_witness_headers is not None ) + public_keys_modifier = block.stateless_input_public_keys_modifier + has_public_keys_modifier = public_keys_modifier is not None expected_success = block.expected_stateless_validation_success if self.skip_stateless_validation and ( - has_witness_expectation or expected_success is not None + has_witness_expectation + or has_public_keys_modifier + or expected_success is not None ): raise AssertionError( "skip_stateless_validation cannot be combined with " - "execution witness expectations or " + "execution witness expectations, stateless input public-key " + "modifiers, or " "expected_stateless_validation_success" ) state_expectation = block.expected_execution_witness_state @@ -1069,8 +1171,39 @@ def generate_block_data( "Mutated execution witness tests must set " "expected_stateless_validation_success explicitly" ) + if has_public_keys_modifier and expected_success is None: + raise AssertionError( + "Mutated stateless input public-key tests must set " + "expected_stateless_validation_success explicitly" + ) + public_keys: Tuple[Bytes, ...] | None = None + if stateless_input_bytes is not None: + payload_transactions: Tuple[Bytes, ...] + ( + public_keys, + payload_transactions, + ) = get_amsterdam_stateless_input_public_key_data( + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + stateless_input_bytes=stateless_input_bytes, + ) + verify_stateless_input_public_keys( + public_keys, + payload_transactions, + self.chain_id, + ) + elif has_public_keys_modifier: + raise Exception( + "Stateless input public-key mutation requires stateless " + "input bytes" + ) canonical_successful_validation: bool | None = None - if has_witness_modifier or expected_success is not None: + if ( + has_witness_modifier + or has_public_keys_modifier + or expected_success is not None + ): if stateless_output_bytes is None: raise Exception( "Stateless guest verification requires stateless output " @@ -1085,23 +1218,39 @@ def generate_block_data( ) ) - should_rerun_stateless_guest = has_witness_modifier + should_rerun_stateless_guest = ( + has_witness_modifier or has_public_keys_modifier + ) if should_rerun_stateless_guest: - if execution_witness is None or stateless_input_bytes is None: + if stateless_input_bytes is None: raise Exception( - "Stateless guest rerun requires execution witness and " - "stateless input bytes" + "Stateless guest rerun requires stateless input bytes" ) + if has_witness_modifier and execution_witness is None: + raise Exception( + "Stateless guest witness mutation rerun requires " + "execution witness" + ) + modified_public_keys: Tuple[Bytes, ...] | None = None + if public_keys_modifier is not None: + if public_keys is None: + raise Exception( + "Stateless guest rerun requires public keys" + ) + modified_public_keys = public_keys_modifier(public_keys) ( stateless_input_bytes, stateless_output_bytes, successful_validation, - ) = rerun_amsterdam_stateless_guest_with_witness( + ) = rerun_amsterdam_stateless_guest_with_overrides( fork=fork, block_number=int(env.number), timestamp=int(env.timestamp), original_stateless_input_bytes=stateless_input_bytes, - execution_witness=execution_witness, + execution_witness=( + execution_witness if has_witness_modifier else None + ), + public_keys=modified_public_keys, ) if ( expected_success is not None diff --git a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py index 51307cebc7d..cf412cd0793 100644 --- a/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/execution_witness/modifiers.py @@ -5,12 +5,14 @@ execution witnesses for testing invalid block scenarios. """ -from typing import Callable +from typing import Callable, Tuple from execution_testing.base_types import Bytes from .types import ExecutionWitness +PublicKeyModifier = Callable[[Tuple[Bytes, ...]], Tuple[Bytes, ...]] + def add_code( code: Bytes, @@ -206,10 +208,32 @@ def transform( return transform +def replace_public_key_at( + index: int, + public_key: Bytes, +) -> PublicKeyModifier: + """Replace the transaction public key at `index`.""" + + def transform( + public_keys: Tuple[Bytes, ...], + ) -> Tuple[Bytes, ...]: + new_public_keys = list(public_keys) + try: + new_public_keys[index] = public_key + except IndexError as exc: + raise IndexError( + f"Public key index {index} out of range for stateless input" + ) from exc + return tuple(new_public_keys) + + return transform + + __all__ = [ "add_state_node", "add_code", "clear_headers", + "PublicKeyModifier", "remove_state_node", "remove_code", "remove_code_at", @@ -219,4 +243,5 @@ def transform( "remove_header_at", "reverse_headers", "replace_header_at", + "replace_public_key_at", ] diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py new file mode 100644 index 00000000000..8f2a9900118 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -0,0 +1,89 @@ +"""Stateless input transaction public-key tests.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Transaction, +) +from execution_testing.test_types.execution_witness.modifiers import ( + replace_public_key_at, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + + +def test_stateless_input_public_keys_are_constructed( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A public key is included for each payload transaction.""" + recipient = pre.fund_eoa() + sender_a = pre.fund_eoa() + sender_b = pre.fund_eoa() + tx_a = Transaction( + sender=sender_a, + to=recipient, + value=0, + gas_limit=500_000, + ) + tx_b = Transaction( + sender=sender_b, + to=recipient, + value=0, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx_a, tx_b], + # The filler automatically verifies stateless input public + # keys against the recovered payload transaction keys. + expected_stateless_validation_success=True, + ) + ], + post={ + sender_a: Account(nonce=1), + sender_b: Account(nonce=1), + }, + ) + + +def test_stateless_input_invalid_public_key_is_rejected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A wrong but SSZ-valid public key fails stateless validation.""" + recipient = pre.fund_eoa() + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=recipient, + value=0, + gas_limit=500_000, + ) + invalid_public_key = Bytes(b"\x04" + b"\x00" * 64) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_public_keys_modifier=( + replace_public_key_at(0, invalid_public_key) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + }, + ) From 2d774bf879d58d1e944a81a17008517af1b89334 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 7 May 2026 16:40:50 -0300 Subject: [PATCH 184/265] improvement --- .../src/execution_testing/specs/blockchain.py | 23 ++++++++++++++----- .../test_witness_public_keys.py | 4 ++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 439d2dc8257..bf7cdb4de8a 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -1177,7 +1177,17 @@ def generate_block_data( "expected_stateless_validation_success explicitly" ) public_keys: Tuple[Bytes, ...] | None = None - if stateless_input_bytes is not None: + should_verify_stateless_input_public_keys = ( + stateless_input_bytes is not None + # The block could be invalid because of invalid txs, thus + # the public keys might not be properly constructed given they + # can't be decoded and thus provided in the execution witness. + and block.exception is None + ) + if stateless_input_bytes is not None and ( + should_verify_stateless_input_public_keys + or has_public_keys_modifier + ): payload_transactions: Tuple[Bytes, ...] ( public_keys, @@ -1188,11 +1198,12 @@ def generate_block_data( timestamp=int(env.timestamp), stateless_input_bytes=stateless_input_bytes, ) - verify_stateless_input_public_keys( - public_keys, - payload_transactions, - self.chain_id, - ) + if should_verify_stateless_input_public_keys: + verify_stateless_input_public_keys( + public_keys, + payload_transactions, + self.chain_id, + ) elif has_public_keys_modifier: raise Exception( "Stateless input public-key mutation requires stateless " diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py index 8f2a9900118..00dff01dee4 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -45,8 +45,8 @@ def test_stateless_input_public_keys_are_constructed( blocks=[ Block( txs=[tx_a, tx_b], - # The filler automatically verifies stateless input public - # keys against the recovered payload transaction keys. + # For accepted blocks, the filler verifies stateless input + # public keys against the recovered payload transaction keys. expected_stateless_validation_success=True, ) ], From 29a9bb4c5d3b0d46e69a5b24b47b4f77da59d6a2 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 7 May 2026 16:59:47 -0300 Subject: [PATCH 185/265] lint and simplification of redundant checks --- .../amsterdam/execution_engine/new_payload.py | 7 ------- src/ethereum/forks/amsterdam/stateless.py | 17 ---------------- tests/json_loader/test_stateless_guest.py | 20 ------------------- 3 files changed, 44 deletions(-) diff --git a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py index fb169fb1146..fdde8e16fa5 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/new_payload.py +++ b/src/ethereum/forks/amsterdam/execution_engine/new_payload.py @@ -105,13 +105,6 @@ def execute_new_payload_request( parent_beacon_block_root = new_payload_request.parent_beacon_block_root execution_requests = new_payload_request.execution_requests - if transaction_public_keys is not None and len( - transaction_public_keys - ) != len(payload.transactions): - raise InvalidBlock( - "Transaction public key count does not match payload transactions" - ) - if b"" in payload.transactions: raise InvalidBlock("Empty transaction in payload") diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 4ba278e1bb5..bafe9a84cfb 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -203,8 +203,6 @@ def verify_stateless_new_payload( witness = stateless_input.witness try: - validate_transaction_public_keys(stateless_input) - # Validate the headers are contiguous and compute their # blockhashes. decoded_headers, block_hashes = validate_headers(witness.headers) @@ -237,18 +235,3 @@ def verify_stateless_new_payload( successful_validation=successful_validation, chain_config=stateless_input.chain_config, ) - - -def validate_transaction_public_keys( - stateless_input: StatelessInput, -) -> None: - """ - Validate that the transaction public key witness matches the payload. - """ - transaction_count = len( - stateless_input.new_payload_request.execution_payload.transactions - ) - if len(stateless_input.public_keys) != transaction_count: - raise ValueError( - "Transaction public key count does not match payload transactions" - ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index f7f447e5e3a..adfae07f32b 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -23,7 +23,6 @@ StatelessInput, StatelessValidationResult, compute_new_payload_request_root, - validate_transaction_public_keys, verify_stateless_new_payload, ) from ethereum.forks.amsterdam.stateless_guest import ( @@ -230,25 +229,6 @@ def test_hash_tree_root_is_32_bytes(self) -> None: class TestTransactionPublicKeys: """Test stateless transaction public-key validation.""" - def test_count_must_match_payload_transactions(self) -> None: - """Count validation should require exactly one key per transaction.""" - original = _make_stateless_input() - invalid = StatelessInput( - new_payload_request=original.new_payload_request, - witness=original.witness, - chain_config=original.chain_config, - public_keys=(original.public_keys[0],), - ) - - with pytest.raises( - ValueError, - match=( - "Transaction public key count does not match payload " - "transactions" - ), - ): - validate_transaction_public_keys(invalid) - def test_too_few_public_keys_fail_validation(self) -> None: """Stateless validation should fail with too few public keys.""" original = _make_stateless_input() From f9e5611f00a33748afdabcc11b68d01d99b751ed Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 7 May 2026 17:07:42 -0300 Subject: [PATCH 186/265] lints --- packages/testing/src/execution_testing/specs/blockchain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index bf7cdb4de8a..89fb3f13795 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -1180,7 +1180,7 @@ def generate_block_data( should_verify_stateless_input_public_keys = ( stateless_input_bytes is not None # The block could be invalid because of invalid txs, thus - # the public keys might not be properly constructed given they + # the public keys might not be properly constructed given they # can't be decoded and thus provided in the execution witness. and block.exception is None ) From f83e1e2152372138e3c9f9d26e8e56c253b24920 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 7 May 2026 17:08:09 -0300 Subject: [PATCH 187/265] lints --- packages/testing/src/execution_testing/specs/blockchain.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 89fb3f13795..f0bf2bbce9c 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -262,12 +262,10 @@ def rerun_amsterdam_stateless_guest_with_overrides( if execution_witness is not None: rebuilt_witness = AmsterdamExecutionWitness( state=tuple( - AmsterdamBytes(bytes(node)) - for node in execution_witness.state + AmsterdamBytes(bytes(node)) for node in execution_witness.state ), codes=tuple( - AmsterdamBytes(bytes(code)) - for code in execution_witness.codes + AmsterdamBytes(bytes(code)) for code in execution_witness.codes ), headers=tuple( AmsterdamBytes(bytes(header)) From ffcef222e82dfd50c7b288a5e94fd747580edf54 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 7 May 2026 18:35:56 -0300 Subject: [PATCH 188/265] add stateless input schema-id prefix to signal encoding and struct deserialization target --- .../plugins/filler/tests/test_filler.py | 3 ++- .../forks/amsterdam/stateless_guest.py | 12 +++++++-- .../forks/amsterdam/stateless_host.py | 7 +++-- src/ethereum/forks/amsterdam/stateless_ssz.py | 3 +++ tests/json_loader/test_stateless_guest.py | 26 +++++++++++++++++++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index b65aec13f00..e2862885f9a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1070,10 +1070,11 @@ def test_execution_witness_in_blockchain_fixture( assert len(witness["codes"]) > 0, "executionWitness.codes is empty" assert len(witness["headers"]) > 0, "executionWitness.headers is empty" - # statelessInputBytes and statelessOutputBytes are non-empty hex strings + # statelessInputBytes is a schema-prefixed guest input hex string. assert "statelessInputBytes" in block sib = block["statelessInputBytes"] assert isinstance(sib, str) and sib.startswith("0x") and len(sib) > 2 + assert sib.startswith("0x01") assert "statelessOutputBytes" in block sob = block["statelessOutputBytes"] diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index ebf26f77bad..badb843bb08 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -10,6 +10,7 @@ verify_stateless_new_payload, ) from .stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, SszStatelessInput, ssz_to_stateless_input, validation_result_to_ssz, @@ -25,8 +26,15 @@ def serialize_stateless_output( def deserialize_stateless_input(data: Bytes) -> StatelessInput: - """Deserialize a StatelessInput from SSZ bytes.""" - ssz_obj = SszStatelessInput.decode_bytes(data) + """Deserialize a StatelessInput from schema-prefixed SSZ bytes.""" + if len(data) == 0: + raise ValueError("Stateless input is missing schema id") + schema_id = data[0] + if schema_id != STATELESS_INPUT_SCHEMA_ID: + raise ValueError( + f"Unsupported stateless input schema id: 0x{schema_id:02x}" + ) + ssz_obj = SszStatelessInput.decode_bytes(data[1:]) return ssz_to_stateless_input(ssz_obj) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 016c60c4b40..624f7ea9ee5 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -22,6 +22,7 @@ StatelessValidationResult, ) from .stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, SszStatelessValidationResult, ssz_to_validation_result, stateless_input_to_ssz, @@ -36,9 +37,11 @@ def serialize_stateless_input( stateless_input: StatelessInput, ) -> Bytes: - """Serialize a StatelessInput to SSZ-encoded bytes.""" + """Serialize a StatelessInput to schema-prefixed SSZ bytes.""" ssz_obj = stateless_input_to_ssz(stateless_input) - return Bytes(ssz_obj.encode_bytes()) + return Bytes( + bytes([STATELESS_INPUT_SCHEMA_ID]) + bytes(ssz_obj.encode_bytes()) + ) def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 829a2d91db5..b7654fdeb1f 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -52,6 +52,9 @@ MAX_PUBLIC_KEYS = 2**20 MAX_BYTES_PER_PUBLIC_KEY = 65 +# Amsterdam SSZ stateless input schema identifier. +STATELESS_INPUT_SCHEMA_ID = 0x01 + # --- SSZ Container types --- diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index d847b48985d..55251bf5261 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -3,6 +3,7 @@ import random from typing import Tuple +import pytest from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint @@ -31,6 +32,10 @@ deserialize_stateless_output, serialize_stateless_input, ) +from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, + stateless_input_to_ssz, +) from ethereum.state import Address, Root _RNG = random.Random(0xDEADBEEF) @@ -115,6 +120,7 @@ def test_roundtrip(self) -> None: """Encoding then decoding recovers the original StatelessInput.""" original = _make_stateless_input() encoded = serialize_stateless_input(original) + assert encoded[0] == STATELESS_INPUT_SCHEMA_ID recovered = deserialize_stateless_input(encoded) assert recovered == original @@ -136,6 +142,7 @@ def test_empty_witness(self) -> None: public_keys=(), ) encoded = serialize_stateless_input(original) + assert encoded[0] == STATELESS_INPUT_SCHEMA_ID recovered = deserialize_stateless_input(encoded) assert recovered == original @@ -171,6 +178,25 @@ def test_empty_witness(self) -> None: recovered = deserialize_stateless_input(encoded) assert recovered == original + def test_empty_input_rejected(self) -> None: + """Reject input that does not contain a schema id.""" + with pytest.raises(ValueError, match="missing schema id"): + deserialize_stateless_input(Bytes(b"")) + + def test_unknown_schema_id_rejected(self) -> None: + """Reject input with a schema id other than Amsterdam's.""" + encoded = serialize_stateless_input(_make_stateless_input()) + with pytest.raises(ValueError, match="Unsupported stateless input"): + deserialize_stateless_input(Bytes(b"\x02" + encoded[1:])) + + def test_legacy_raw_ssz_input_rejected(self) -> None: + """Reject unprefixed SSZ input bytes.""" + original = _make_stateless_input() + raw_ssz = Bytes(stateless_input_to_ssz(original).encode_bytes()) + assert raw_ssz[0] != STATELESS_INPUT_SCHEMA_ID + with pytest.raises(ValueError, match="Unsupported stateless input"): + deserialize_stateless_input(raw_ssz) + class TestSerializeStatelessOutput: """Test serialize_stateless_output.""" From 224df696aa3e4b1db829a1f4ab9dfbcb0fd20430 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 8 May 2026 19:35:54 -0300 Subject: [PATCH 189/265] test(filler): parse witness fixtures with typed model --- .../plugins/filler/tests/test_filler.py | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index e2862885f9a..e09a30daba1 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -15,11 +15,14 @@ import pytest -from execution_testing.test_types import Environment from execution_testing.client_clis import ( ExecutionSpecsTransitionTool, TransitionTool, ) +from execution_testing.fixtures import BlockchainEngineFixture, BlockchainFixture +from execution_testing.fixtures.file import Fixtures +from execution_testing.test_types import Environment + from ..filler import default_output_directory # Path to the real benchmark conftest.py that we copy into testdirs. @@ -1056,29 +1059,27 @@ def test_execution_witness_in_blockchain_fixture( ) assert fixture_path.exists(), f"{fixture_path} does not exist" - with open(fixture_path, "r") as f: - fixture_data = json.load(f) + fixture_data = Fixtures.model_validate_json(fixture_path.read_text()) assert len(fixture_data) == 1, "Expected exactly one fixture" fixture = next(iter(fixture_data.values())) - block = fixture["blocks"][0] + assert isinstance(fixture, BlockchainFixture) + block = fixture.blocks[0] # executionWitness exists with non-empty state, codes, and headers - assert "executionWitness" in block - witness = block["executionWitness"] - assert len(witness["state"]) > 0, "executionWitness.state is empty" - assert len(witness["codes"]) > 0, "executionWitness.codes is empty" - assert len(witness["headers"]) > 0, "executionWitness.headers is empty" - - # statelessInputBytes is a schema-prefixed guest input hex string. - assert "statelessInputBytes" in block - sib = block["statelessInputBytes"] - assert isinstance(sib, str) and sib.startswith("0x") and len(sib) > 2 - assert sib.startswith("0x01") - - assert "statelessOutputBytes" in block - sob = block["statelessOutputBytes"] - assert isinstance(sob, str) and sob.startswith("0x") and len(sob) > 2 + witness = block.execution_witness + assert witness is not None + assert len(witness.state) > 0, "executionWitness.state is empty" + assert len(witness.codes) > 0, "executionWitness.codes is empty" + assert len(witness.headers) > 0, "executionWitness.headers is empty" + + # statelessInputBytes is schema-prefixed guest input bytes. + sib = block.stateless_input_bytes + assert sib is not None and len(sib) > 0 + assert bytes(sib).startswith(b"\x01") + + sob = block.stateless_output_bytes + assert sob is not None and len(sob) > 0 from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, @@ -1086,7 +1087,7 @@ def test_execution_witness_in_blockchain_fixture( from ethereum_types.bytes import Bytes as EthereumBytes stateless_output = deserialize_stateless_output( - EthereumBytes(bytes.fromhex(sob[2:])) + EthereumBytes(bytes(sob)) ) assert stateless_output.successful_validation is True @@ -1098,22 +1099,22 @@ def test_execution_witness_in_blockchain_fixture( f"{engine_fixture_path} does not exist" ) - with open(engine_fixture_path, "r") as f: - engine_fixture_data = json.load(f) + engine_fixture_data = Fixtures.model_validate_json(engine_fixture_path.read_text()) assert len(engine_fixture_data) == 1, "Expected exactly one engine fixture" engine_fixture = next(iter(engine_fixture_data.values())) - engine_payload = engine_fixture["engineNewPayloads"][0] + assert isinstance(engine_fixture, BlockchainEngineFixture) + engine_payload = engine_fixture.payloads[0] - assert "executionWitness" in engine_payload - engine_witness = engine_payload["executionWitness"] - assert len(engine_witness["state"]) > 0, ( + engine_witness = engine_payload.execution_witness + assert engine_witness is not None + assert len(engine_witness.state) > 0, ( "engine executionWitness.state is empty" ) - assert len(engine_witness["codes"]) > 0, ( + assert len(engine_witness.codes) > 0, ( "engine executionWitness.codes is empty" ) - assert len(engine_witness["headers"]) > 0, ( + assert len(engine_witness.headers) > 0, ( "engine executionWitness.headers is empty" ) assert engine_witness == witness From 0951b85179f76a6c7dc39bb6eb75064e57c559e9 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 8 May 2026 19:45:16 -0300 Subject: [PATCH 190/265] lints Signed-off-by: jsign --- .../plugins/filler/tests/test_filler.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index e09a30daba1..e302d82db0a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -19,7 +19,10 @@ ExecutionSpecsTransitionTool, TransitionTool, ) -from execution_testing.fixtures import BlockchainEngineFixture, BlockchainFixture +from execution_testing.fixtures import ( + BlockchainEngineFixture, + BlockchainFixture, +) from execution_testing.fixtures.file import Fixtures from execution_testing.test_types import Environment @@ -1086,9 +1089,7 @@ def test_execution_witness_in_blockchain_fixture( ) from ethereum_types.bytes import Bytes as EthereumBytes - stateless_output = deserialize_stateless_output( - EthereumBytes(bytes(sob)) - ) + stateless_output = deserialize_stateless_output(EthereumBytes(bytes(sob))) assert stateless_output.successful_validation is True engine_fixture_path = Path( @@ -1099,7 +1100,9 @@ def test_execution_witness_in_blockchain_fixture( f"{engine_fixture_path} does not exist" ) - engine_fixture_data = Fixtures.model_validate_json(engine_fixture_path.read_text()) + engine_fixture_data = Fixtures.model_validate_json( + engine_fixture_path.read_text() + ) assert len(engine_fixture_data) == 1, "Expected exactly one engine fixture" engine_fixture = next(iter(engine_fixture_data.values())) From e1eb780a37536ef839160eaa0f1e37f1bb81561f Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 8 May 2026 20:35:53 -0300 Subject: [PATCH 191/265] refactor: update stateless input schema handling to use byte representation --- src/ethereum/forks/amsterdam/stateless_guest.py | 14 ++++++++++---- src/ethereum/forks/amsterdam/stateless_host.py | 4 ++-- src/ethereum/forks/amsterdam/stateless_ssz.py | 7 ++++++- tests/json_loader/test_stateless_guest.py | 15 ++++++++++----- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index badb843bb08..529ab8edcea 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -11,6 +11,7 @@ ) from .stateless_ssz import ( STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_SIZE, SszStatelessInput, ssz_to_stateless_input, validation_result_to_ssz, @@ -27,14 +28,19 @@ def serialize_stateless_output( def deserialize_stateless_input(data: Bytes) -> StatelessInput: """Deserialize a StatelessInput from schema-prefixed SSZ bytes.""" - if len(data) == 0: + if len(data) < STATELESS_INPUT_SCHEMA_ID_SIZE: raise ValueError("Stateless input is missing schema id") - schema_id = data[0] + schema_id = int.from_bytes( + data[:STATELESS_INPUT_SCHEMA_ID_SIZE], + "big", + ) if schema_id != STATELESS_INPUT_SCHEMA_ID: raise ValueError( - f"Unsupported stateless input schema id: 0x{schema_id:02x}" + f"Unsupported stateless input schema id: 0x{schema_id:04x}" ) - ssz_obj = SszStatelessInput.decode_bytes(data[1:]) + ssz_obj = SszStatelessInput.decode_bytes( + data[STATELESS_INPUT_SCHEMA_ID_SIZE:] + ) return ssz_to_stateless_input(ssz_obj) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 624f7ea9ee5..0659b656b6f 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -22,7 +22,7 @@ StatelessValidationResult, ) from .stateless_ssz import ( - STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_BYTES, SszStatelessValidationResult, ssz_to_validation_result, stateless_input_to_ssz, @@ -40,7 +40,7 @@ def serialize_stateless_input( """Serialize a StatelessInput to schema-prefixed SSZ bytes.""" ssz_obj = stateless_input_to_ssz(stateless_input) return Bytes( - bytes([STATELESS_INPUT_SCHEMA_ID]) + bytes(ssz_obj.encode_bytes()) + STATELESS_INPUT_SCHEMA_ID_BYTES + bytes(ssz_obj.encode_bytes()) ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index b7654fdeb1f..fca0aca1fa1 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -53,7 +53,12 @@ MAX_BYTES_PER_PUBLIC_KEY = 65 # Amsterdam SSZ stateless input schema identifier. -STATELESS_INPUT_SCHEMA_ID = 0x01 +STATELESS_INPUT_SCHEMA_ID = 0x0001 +STATELESS_INPUT_SCHEMA_ID_SIZE = 2 +STATELESS_INPUT_SCHEMA_ID_BYTES = STATELESS_INPUT_SCHEMA_ID.to_bytes( + STATELESS_INPUT_SCHEMA_ID_SIZE, + "big", +) # --- SSZ Container types --- diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 55251bf5261..6379f731fc1 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -33,7 +33,7 @@ serialize_stateless_input, ) from ethereum.forks.amsterdam.stateless_ssz import ( - STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_BYTES, stateless_input_to_ssz, ) from ethereum.state import Address, Root @@ -120,7 +120,7 @@ def test_roundtrip(self) -> None: """Encoding then decoding recovers the original StatelessInput.""" original = _make_stateless_input() encoded = serialize_stateless_input(original) - assert encoded[0] == STATELESS_INPUT_SCHEMA_ID + assert encoded[:2] == STATELESS_INPUT_SCHEMA_ID_BYTES recovered = deserialize_stateless_input(encoded) assert recovered == original @@ -142,7 +142,7 @@ def test_empty_witness(self) -> None: public_keys=(), ) encoded = serialize_stateless_input(original) - assert encoded[0] == STATELESS_INPUT_SCHEMA_ID + assert encoded[:2] == STATELESS_INPUT_SCHEMA_ID_BYTES recovered = deserialize_stateless_input(encoded) assert recovered == original @@ -183,17 +183,22 @@ def test_empty_input_rejected(self) -> None: with pytest.raises(ValueError, match="missing schema id"): deserialize_stateless_input(Bytes(b"")) + def test_one_byte_input_rejected(self) -> None: + """Reject input that does not contain a full schema id.""" + with pytest.raises(ValueError, match="missing schema id"): + deserialize_stateless_input(Bytes(b"\x01")) + def test_unknown_schema_id_rejected(self) -> None: """Reject input with a schema id other than Amsterdam's.""" encoded = serialize_stateless_input(_make_stateless_input()) with pytest.raises(ValueError, match="Unsupported stateless input"): - deserialize_stateless_input(Bytes(b"\x02" + encoded[1:])) + deserialize_stateless_input(Bytes(b"\x00\x02" + encoded[2:])) def test_legacy_raw_ssz_input_rejected(self) -> None: """Reject unprefixed SSZ input bytes.""" original = _make_stateless_input() raw_ssz = Bytes(stateless_input_to_ssz(original).encode_bytes()) - assert raw_ssz[0] != STATELESS_INPUT_SCHEMA_ID + assert raw_ssz[:2] != STATELESS_INPUT_SCHEMA_ID_BYTES with pytest.raises(ValueError, match="Unsupported stateless input"): deserialize_stateless_input(raw_ssz) From 4d63eb6a324a6b6477035498d913e9aa8a0ee87c Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 8 May 2026 20:44:01 -0300 Subject: [PATCH 192/265] fix test --- .../cli/pytest_commands/plugins/filler/tests/test_filler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index e302d82db0a..b158159d2dd 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1079,7 +1079,11 @@ def test_execution_witness_in_blockchain_fixture( # statelessInputBytes is schema-prefixed guest input bytes. sib = block.stateless_input_bytes assert sib is not None and len(sib) > 0 - assert bytes(sib).startswith(b"\x01") + from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID_BYTES, + ) + + assert bytes(sib).startswith(STATELESS_INPUT_SCHEMA_ID_BYTES) sob = block.stateless_output_bytes assert sob is not None and len(sob) > 0 From 11d16c857eff669bc8891d6c60bc54a8904fb9e0 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 11 May 2026 16:25:11 -0300 Subject: [PATCH 193/265] Skip stateless artifacts for rlp_modifier blocks --- .../plugins/filler/tests/test_filler.py | 147 ++++++++++++++++++ .../src/execution_testing/specs/blockchain.py | 55 ++++--- 2 files changed, 182 insertions(+), 20 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 1476c1244ec..153109766bd 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1028,6 +1028,76 @@ def test_execution_witness_missing_expected( ) +test_module_execution_witness_rlp_modifier = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Header, + Op, + Transaction, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_rlp_modifier( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) + sender = pre.fund_eoa() + tx = Transaction(to=contract, gas_limit=100_000, sender=sender) + + blockchain_test( + pre=pre, + post={ + contract: Account(storage={0: 1}), + sender: Account(nonce=1), + }, + blocks=[ + Block( + txs=[tx], + rlp_modifier=Header(extra_data=b"mutated"), + ) + ], + ) + """ +) + + +test_module_execution_witness_rlp_modifier_stateless_intent = textwrap.dedent( + """\ + import pytest + + from execution_testing import ( + Alloc, + Block, + BlockchainTestFiller, + Header, + ) + + @pytest.mark.valid_at("Amsterdam") + def test_execution_witness_rlp_modifier_stateless_intent( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + ) -> None: + blockchain_test( + pre=pre, + post={}, + blocks=[ + Block( + rlp_modifier=Header(extra_data=b"mutated"), + expected_stateless_validation_success=True, + ) + ], + ) + """ +) + + def test_execution_witness_in_blockchain_fixture( testdir: pytest.Testdir, ) -> None: @@ -1179,6 +1249,83 @@ def test_execution_witness_skip_stateless_validation( assert "statelessOutputBytes" not in block +def test_execution_witness_rlp_modifier_omits_stateless_artifacts( + testdir: pytest.Testdir, +) -> None: + """RLP-mutated blocks should not expose canonical stateless artifacts.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_rlp_modifier.py" + ) + test_module.write(test_module_execution_witness_rlp_modifier) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = [ + "-c", + "pytest-fill.ini", + "-v", + "--until=Amsterdam", + "-m", + "blockchain_test", + "--no-html", + ] + result = testdir.runpytest(*args) + result.assert_outcomes( + passed=1, + failed=0, + skipped=0, + errors=0, + ) + + fixture_path = Path( + "fixtures/blockchain_tests/for_amsterdam/amsterdam/" + "module_execution_witness_rlp_modifier/" + "execution_witness_rlp_modifier.json" + ) + assert fixture_path.exists(), f"{fixture_path} does not exist" + + with open(fixture_path, "r") as f: + fixture_data = json.load(f) + + assert len(fixture_data) == 1, "Expected exactly one fixture" + fixture = next(iter(fixture_data.values())) + block = fixture["blocks"][0] + + assert "executionWitness" not in block + assert "statelessInputBytes" not in block + assert "statelessOutputBytes" not in block + + +def test_execution_witness_rlp_modifier_rejects_stateless_intent( + testdir: pytest.Testdir, +) -> None: + """RLP modifiers cannot be combined with explicit stateless assertions.""" + tests_dir = testdir.mkdir("tests") + amsterdam_tests_dir = tests_dir.mkdir("amsterdam") + test_module = amsterdam_tests_dir.join( + "test_module_execution_witness_rlp_modifier_stateless_intent.py" + ) + test_module.write( + test_module_execution_witness_rlp_modifier_stateless_intent + ) + + testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + args = ["-c", "pytest-fill.ini", "-v", "--until=Amsterdam", "--no-html"] + result = testdir.runpytest(*args) + assert result.ret != 0 + result.stdout.fnmatch_lines( + [ + "*Blocks with rlp_modifier omit stateless artifacts because " + "they are generated before the RLP mutation*" + ] + ) + + def test_execution_witness_expected_true_reuses_canonical_stateless_result( testdir: pytest.Testdir, ) -> None: diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index b6d0c522d7a..af81b682f1e 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -987,6 +987,40 @@ def generate_block_data( "exception must be the last transaction in the block" ) + has_witness_expectation = ( + block.expected_execution_witness_state is not None + or block.expected_execution_witness_codes is not None + or block.expected_execution_witness_headers is not None + ) + public_keys_modifier = block.stateless_input_public_keys_modifier + has_public_keys_modifier = public_keys_modifier is not None + expected_success = block.expected_stateless_validation_success + omit_stateless_artifacts = block.rlp_modifier is not None + if omit_stateless_artifacts and ( + has_witness_expectation + or has_public_keys_modifier + or expected_success is not None + ): + raise AssertionError( + "Blocks with rlp_modifier omit stateless artifacts because " + "they are generated before the RLP mutation. SSZ/stateless " + "mutation tests require a separate explicit mechanism." + ) + if self.skip_stateless_validation and ( + has_witness_expectation + or has_public_keys_modifier + or expected_success is not None + ): + raise AssertionError( + "skip_stateless_validation cannot be combined with " + "execution witness expectations, stateless input public-key " + "modifiers, or " + "expected_stateless_validation_success" + ) + skip_stateless_for_block = ( + self.skip_stateless_validation or omit_stateless_artifacts + ) + transition_tool_output = t8n.evaluate( transition_tool_data=TransitionTool.TransitionToolData( alloc=previous_alloc, @@ -996,7 +1030,7 @@ def generate_block_data( chain_id=self.chain_id, reward=fork.get_reward(), blob_schedule=fork.blob_schedule(), - skip_stateless_validation=self.skip_stateless_validation, + skip_stateless_validation=skip_stateless_for_block, ), slow_request=self.is_tx_gas_heavy_test, ) @@ -1114,25 +1148,6 @@ def generate_block_data( # If expected witness state/codes defined, verify against actual t8n_witness = transition_tool_output.result.execution_witness execution_witness = t8n_witness - has_witness_expectation = ( - block.expected_execution_witness_state is not None - or block.expected_execution_witness_codes is not None - or block.expected_execution_witness_headers is not None - ) - public_keys_modifier = block.stateless_input_public_keys_modifier - has_public_keys_modifier = public_keys_modifier is not None - expected_success = block.expected_stateless_validation_success - if self.skip_stateless_validation and ( - has_witness_expectation - or has_public_keys_modifier - or expected_success is not None - ): - raise AssertionError( - "skip_stateless_validation cannot be combined with " - "execution witness expectations, stateless input public-key " - "modifiers, or " - "expected_stateless_validation_success" - ) state_expectation = block.expected_execution_witness_state if state_expectation is not None and execution_witness is not None: state_expectation.verify_against(execution_witness) From 8203653c02fcc007ce380cb7944f45c4631d4e0b Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 11 May 2026 16:49:32 -0300 Subject: [PATCH 194/265] lint from rebase --- tests/json_loader/test_incremental_mpt.py | 14 +++++++------- tests/json_loader/test_witness_state.py | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/json_loader/test_incremental_mpt.py b/tests/json_loader/test_incremental_mpt.py index 699e2be5573..e0b64d36bd9 100644 --- a/tests/json_loader/test_incremental_mpt.py +++ b/tests/json_loader/test_incremental_mpt.py @@ -3,6 +3,13 @@ from typing import Any import pytest +from ethereum.forks.amsterdam.trie import ( + EMPTY_TRIE_ROOT, + Trie, + nibble_list_to_compact, + root, + trie_set, +) from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -19,13 +26,6 @@ mpt_root, mpt_set, ) -from ethereum.forks.amsterdam.trie import ( - EMPTY_TRIE_ROOT, - Trie, - nibble_list_to_compact, - root, - trie_set, -) from ethereum.state import Root diff --git a/tests/json_loader/test_witness_state.py b/tests/json_loader/test_witness_state.py index f2770b85666..6158edf6454 100644 --- a/tests/json_loader/test_witness_state.py +++ b/tests/json_loader/test_witness_state.py @@ -3,6 +3,11 @@ from typing import Any, Optional import pytest +from ethereum.forks.amsterdam.trie import ( + EMPTY_TRIE_ROOT, + bytes_to_nibble_list, + nibble_list_to_compact, +) from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U256, Uint @@ -15,11 +20,6 @@ mpt_get, mpt_root, ) -from ethereum.forks.amsterdam.trie import ( - EMPTY_TRIE_ROOT, - bytes_to_nibble_list, - nibble_list_to_compact, -) from ethereum.forks.amsterdam.witness_state import ( WitnessState, build_code_db, From 1e823f464352c724edec2655e6f1e58b94d805d6 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 11 May 2026 17:00:38 -0300 Subject: [PATCH 195/265] more lints from rebase --- .../amsterdam/execution_engine/get_payload.py | 2 +- .../forks/amsterdam/incremental_mpt.py | 6 +-- src/ethereum/forks/amsterdam/stateless.py | 11 +---- tests/json_loader/test_incremental_mpt.py | 14 +++--- tests/json_loader/test_witness_state.py | 10 ++--- vulture_whitelist.py | 45 +++++++++++++++++++ 6 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/ethereum/forks/amsterdam/execution_engine/get_payload.py b/src/ethereum/forks/amsterdam/execution_engine/get_payload.py index 8dc2541a3d7..832d5643d82 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/get_payload.py +++ b/src/ethereum/forks/amsterdam/execution_engine/get_payload.py @@ -8,7 +8,7 @@ ) -def get_payload(payload_id: PayloadId) -> GetPayloadResponse: +def get_payload(_payload_id: PayloadId) -> GetPayloadResponse: """ Return a prepared payload response for a previously returned ``PayloadId``. diff --git a/src/ethereum/forks/amsterdam/incremental_mpt.py b/src/ethereum/forks/amsterdam/incremental_mpt.py index abadf402817..5430e0f2a9d 100644 --- a/src/ethereum/forks/amsterdam/incremental_mpt.py +++ b/src/ethereum/forks/amsterdam/incremental_mpt.py @@ -28,7 +28,7 @@ from ethereum_rlp import Extended, rlp from ethereum_types.bytes import Bytes -from ethereum_types.numeric import Uint +from ethereum_types.numeric import Uint, ulen from ethereum.crypto.hash import keccak256 from ethereum.merkle_patricia_trie import ( @@ -713,9 +713,7 @@ def _delete_from_extension( return node old_child = node.child - new_child = _mpt_delete_node( - mpt, old_child, key, level + Uint(len(segment)) - ) + new_child = _mpt_delete_node(mpt, old_child, key, level + ulen(segment)) if new_child is None: return None diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 41b62fceb25..8d103f86118 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -15,7 +15,7 @@ from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.state import Root -from .blocks import Block, Header +from .blocks import Header from .execution_engine.new_payload import execute_new_payload_request from .execution_engine.requests import ExecutionRequests from .execution_engine.types import ExecutionPayload, NewPayloadRequest @@ -243,15 +243,6 @@ def compute_new_payload_request_root( return Hash32(ssz_npr.hash_tree_root()) -def new_payload_request_to_block( - new_payload_request: NewPayloadRequest, -) -> Block: - """ - Convert a NewPayloadRequest into a block. - """ - raise NotImplementedError - - def _decode_header(header_bytes: Bytes) -> Header | PreviousForkHeader: """ Decode an RLP-encoded header, trying the current fork first and diff --git a/tests/json_loader/test_incremental_mpt.py b/tests/json_loader/test_incremental_mpt.py index e0b64d36bd9..c16aa070813 100644 --- a/tests/json_loader/test_incremental_mpt.py +++ b/tests/json_loader/test_incremental_mpt.py @@ -3,13 +3,6 @@ from typing import Any import pytest -from ethereum.forks.amsterdam.trie import ( - EMPTY_TRIE_ROOT, - Trie, - nibble_list_to_compact, - root, - trie_set, -) from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -26,6 +19,13 @@ mpt_root, mpt_set, ) +from ethereum.merkle_patricia_trie import ( + EMPTY_TRIE_ROOT, + Trie, + nibble_list_to_compact, + root, + trie_set, +) from ethereum.state import Root diff --git a/tests/json_loader/test_witness_state.py b/tests/json_loader/test_witness_state.py index 6158edf6454..23459531271 100644 --- a/tests/json_loader/test_witness_state.py +++ b/tests/json_loader/test_witness_state.py @@ -3,11 +3,6 @@ from typing import Any, Optional import pytest -from ethereum.forks.amsterdam.trie import ( - EMPTY_TRIE_ROOT, - bytes_to_nibble_list, - nibble_list_to_compact, -) from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U256, Uint @@ -25,6 +20,11 @@ build_code_db, build_node_db, ) +from ethereum.merkle_patricia_trie import ( + EMPTY_TRIE_ROOT, + bytes_to_nibble_list, + nibble_list_to_compact, +) from ethereum.state import EMPTY_CODE_HASH, Account, Address, Root _ADDR1 = Address(b"\x01" * 20) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 94bccfdbafb..1ce8ef99477 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -11,6 +11,15 @@ from ethereum.ethash import * from ethereum.fork_criteria import Unscheduled +from ethereum.forks.amsterdam.execution_engine.types import ( + BlobsBundle, + GetPayloadResponse, + PayloadAttributes, +) +from ethereum.forks.amsterdam.stateless import ( + NewPayloadRequestHeader, + ProtocolFork, +) from ethereum.trace import EvmTracer from ethereum.utils.hexadecimal import hex_to_bytes256 from ethereum_optimized.state_db import State @@ -173,3 +182,39 @@ _configure_client_manager # autouse fixture test_suite_name # hive test suite name fixture genesis_header # genesis header fixture + +# src/ethereum/forks/amsterdam/execution_engine/types.py - Engine API fields +PayloadAttributes.suggested_fee_recipient +BlobsBundle.commitments +BlobsBundle.proofs +BlobsBundle.blobs +GetPayloadResponse.block_value +GetPayloadResponse.blobs_bundle + +# src/ethereum/forks/amsterdam/stateless.py - stateless public API scaffolding +NewPayloadRequestHeader +NewPayloadRequestHeader.execution_payload_header +ProtocolFork.Frontier +ProtocolFork.Homestead +ProtocolFork.DAOFork +ProtocolFork.TangerineWhistle +ProtocolFork.SpuriousDragon +ProtocolFork.Byzantium +ProtocolFork.Constantinople +ProtocolFork.ConstantinopleFix +ProtocolFork.Istanbul +ProtocolFork.MuirGlacier +ProtocolFork.Berlin +ProtocolFork.London +ProtocolFork.ArrowGlacier +ProtocolFork.GrayGlacier +ProtocolFork.Paris +ProtocolFork.Shanghai +ProtocolFork.Cancun +ProtocolFork.Prague +ProtocolFork.Osaka +ProtocolFork.BPO1 +ProtocolFork.BPO2 +ProtocolFork.BPO3 +ProtocolFork.BPO4 +ProtocolFork.BPO5 From 477824540224a402f36f12f2f0fa3dce63732658 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 11 May 2026 17:37:00 -0300 Subject: [PATCH 196/265] doc fixes --- src/ethereum/forks/amsterdam/witness_state.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index db824f5d205..102ca9ab393 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -12,12 +12,11 @@ from ethereum_types.numeric import U256, Uint from ethereum.crypto.hash import Hash32, keccak256 -from ethereum.merkle_patricia_trie import EMPTY_TRIE_ROOT +from ethereum.merkle_patricia_trie import EMPTY_TRIE_ROOT, InternalNode from ethereum.state import ( EMPTY_CODE_HASH, Account, Address, - InternalNode, Root, ) From e4733323fa4cd07155865c5106015f7bc798ae8c Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Mon, 20 Apr 2026 08:53:43 +0200 Subject: [PATCH 197/265] 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). --- .../cli/pytest_commands/consume.py | 22 +- .../simulators/engine_witness/__init__.py | 0 .../simulators/engine_witness/conftest.py | 102 ++++++++ .../simulators/helpers/tests/__init__.py | 0 .../helpers/tests/test_witness_diff.py | 70 ++++++ .../simulators/helpers/witness_diff.py | 55 +++++ .../test_via_engine_witness.py | 219 ++++++++++++++++++ .../cli/pytest_commands/processors.py | 7 + .../src/execution_testing/rpc/__init__.py | 6 + .../testing/src/execution_testing/rpc/rpc.py | 89 +++++++ .../src/execution_testing/rpc/rpc_types.py | 152 ++++++++++++ .../tests/test_new_payload_with_witness.py | 213 +++++++++++++++++ 12 files changed, 934 insertions(+), 1 deletion(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/__init__.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/engine_witness/conftest.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/__init__.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_witness_diff.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/witness_diff.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_engine_witness.py create mode 100644 packages/testing/src/execution_testing/rpc/tests/test_new_payload_with_witness.py 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 From c3143883ee2378c20530c4f5a0b53f88e248e6fa Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 15 May 2026 12:51:32 -0300 Subject: [PATCH 198/265] add support for 'eels/consume-engine-witness' in check_live_port fixture --- .../cli/pytest_commands/plugins/consume/simulators/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py index 5780353cf68..f1c6118d0a5 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py @@ -34,6 +34,7 @@ def check_live_port(test_suite_name: str) -> Literal[8545, 8551]: "eels/consume-engine", "eels/consume-enginex", "eels/consume-sync", + "eels/consume-engine-witness", }: return 8551 raise ValueError( From f65cd1f68b24cfbdc458ad433ae38b5f734fd912 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 15 May 2026 15:49:08 -0300 Subject: [PATCH 199/265] refactor(witness): enhance strictness in witness comparison tests and documentation --- .../helpers/tests/test_witness_diff.py | 20 +++-- .../simulators/helpers/witness_diff.py | 84 +++++++++++++------ .../test_via_engine_witness.py | 6 +- 3 files changed, 74 insertions(+), 36 deletions(-) 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 index 75c79ff0851..645a1df9ba4 100644 --- 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 @@ -1,4 +1,4 @@ -"""Tests for set-based witness comparison helper.""" +"""Tests for strict witness comparison helper.""" import pytest @@ -28,22 +28,24 @@ def test_matching_witnesses_pass() -> None: assert_witness_matches(expected=w, actual=w) -def test_reordered_witness_matches() -> None: - """Set-equality ignores ordering — PR #773 does not mandate it.""" +def test_reordered_witness_fails() -> None: + """Witness comparison is order-sensitive.""" 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) + with pytest.raises(WitnessMismatchError, match="state: ordered mismatch"): + 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.""" +def test_duplicates_are_significant() -> None: + """Duplicate items make the witness differ.""" expected = _w(state=[b"\xaa"]) actual = _w(state=[b"\xaa", b"\xaa"]) - assert_witness_matches(expected=expected, actual=actual) + with pytest.raises(WitnessMismatchError, match="state: 1 extra"): + assert_witness_matches(expected=expected, actual=actual) def test_missing_state_node_fails() -> None: - """Client missing a state node gives a 'missing' diff line.""" + """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"): @@ -51,7 +53,7 @@ def test_missing_state_node_fails() -> None: def test_extra_code_fails() -> None: - """Client over-collecting a code gives an 'extra' diff line.""" + """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"): 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 index 3c4da69268c..ad7a450cff1 100644 --- 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 @@ -1,4 +1,4 @@ -"""Set-based witness comparison helper for the engine-witness simulator.""" +"""Strict witness comparison helper for the engine-witness simulator.""" from typing import Iterable, List @@ -6,26 +6,64 @@ from execution_testing.test_types.execution_witness import ExecutionWitness -def _diff_sets( +def _preview(value: bytes | None) -> str: + """Return a compact hex preview for a witness item.""" + if value is None: + return "" + return "0x" + value.hex()[:16] + + +def _diff_ordered( 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}" + exp = [bytes(x) for x in expected] + act = [bytes(x) for x in actual] + + if exp == act: + return [] + + if len(exp) > len(act) and exp[: len(act)] == act: + missing_count = len(exp) - len(act) + preview = ", ".join(_preview(item) for item in exp[len(act) :][:5]) + return [ + ( + f"{field}: {missing_count} missing " + f"(not emitted by client): {preview}" + ) + ] + + if len(act) > len(exp) and act[: len(exp)] == exp: + extra_count = len(act) - len(exp) + preview = ", ".join(_preview(item) for item in act[len(exp) :][:5]) + return [ + ( + f"{field}: {extra_count} extra " + f"(over-collected by client): {preview}" + ) + ] + + first_mismatch = next( + ( + i + for i, (expected_item, actual_item) in enumerate(zip(exp, act)) + if expected_item != actual_item + ), + min(len(exp), len(act)), + ) + expected_item = ( + exp[first_mismatch] if first_mismatch < len(exp) else None + ) + actual_item = act[first_mismatch] if first_mismatch < len(act) else None + + return [ + ( + f"{field}: ordered mismatch " + f"(expected {len(exp)} items, got {len(act)}); " + f"first mismatch at index {first_mismatch}: " + f"expected {_preview(expected_item)}, got {_preview(actual_item)}" ) - return messages + ] class WitnessMismatchError(AssertionError): @@ -37,16 +75,14 @@ def assert_witness_matches( ) -> None: """ Assert the client-emitted `actual` witness matches the fixture `expected` - witness under set-equality on each of `state`, `codes`, `headers`. + witness exactly on each of `state`, `codes`, and `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. + Ordering and duplicate entries are significant. """ 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) + messages += _diff_ordered("state", expected.state, actual.state) + messages += _diff_ordered("codes", expected.codes, actual.codes) + messages += _diff_ordered("headers", expected.headers, actual.headers) if messages: raise WitnessMismatchError( 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 index 694b1796385..f863be0b63d 100644 --- 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 @@ -5,7 +5,7 @@ - `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 +expectation, and when VALID the client-emitted witness exactly matches `fixture.execution_witness` on state/codes/headers. """ @@ -73,8 +73,8 @@ def test_blockchain_via_engine_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). + 3. On VALID, compare the client-emitted witness exactly to + fixture.execution_witness 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 From be0757092b195820329eb5cf129f0c48136f1db2 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 15 May 2026 17:13:08 -0300 Subject: [PATCH 200/265] feat(execution): add executionWitnessMutated metadata for stateless validation --- .../test_formats/blockchain_test_engine.md | 6 +++ .../test_formats/blockchain_test_sync.md | 6 +++ .../helpers/tests/test_engine_witness_skip.py | 40 +++++++++++++++++++ .../test_via_engine_witness.py | 38 +++++++++++------- .../plugins/filler/tests/test_filler.py | 17 ++++++++ .../execution_testing/fixtures/blockchain.py | 3 ++ .../fixtures/tests/test_blockchain.py | 2 + .../src/execution_testing/specs/blockchain.py | 5 +++ 8 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py diff --git a/docs/running_tests/test_formats/blockchain_test_engine.md b/docs/running_tests/test_formats/blockchain_test_engine.md index 75aad79c19c..ae4cf361485 100644 --- a/docs/running_tests/test_formats/blockchain_test_engine.md +++ b/docs/running_tests/test_formats/blockchain_test_engine.md @@ -109,6 +109,12 @@ Hash of the parent beacon block root. Optional fixture metadata for stateless validation. When present, contains `state`, `codes`, and `headers` byte lists associated with this payload. +#### - `executionWitnessMutated`: [`Optional`](./common_types.md#optional)`[bool]` + +Optional fixture metadata. When `true`, the payload's `executionWitness` was +deliberately modified by the filler for stateless validation negative testing +and is not expected to be generated by execution clients. + #### - `validationError`: [`TransactionException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.TransactionException)` | `[`BlockException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.BlockException) Validation error expected when executing the payload. diff --git a/docs/running_tests/test_formats/blockchain_test_sync.md b/docs/running_tests/test_formats/blockchain_test_sync.md index fc1f08f9aa9..c9b332a618e 100644 --- a/docs/running_tests/test_formats/blockchain_test_sync.md +++ b/docs/running_tests/test_formats/blockchain_test_sync.md @@ -132,6 +132,12 @@ Hash of the parent beacon block root. Optional fixture metadata for stateless validation. When present, contains `state`, `codes`, and `headers` byte lists associated with this payload. +#### - `executionWitnessMutated`: [`Optional`](./common_types.md#optional)`[bool]` + +Optional fixture metadata. When `true`, the payload's `executionWitness` was +deliberately modified by the filler for stateless validation negative testing +and is not expected to be generated by execution clients. + #### - `validationError`: [`Optional`](./common_types.md#optional)`[`[`TransactionException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.TransactionException)` | `[`BlockException`](../../library/execution_testing_exceptions.md#execution_testing.exceptions.BlockException)`]` For sync tests, this field should not be present as sync tests only work with valid chains. Invalid blocks cannot be synced. diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py new file mode 100644 index 00000000000..aa609c3a23e --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py @@ -0,0 +1,40 @@ +"""Tests for engine-witness simulator skip handling.""" + +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from execution_testing.cli.pytest_commands.plugins.consume.simulators.simulator_logic.test_via_engine_witness import ( # noqa: E501 + test_blockchain_via_engine_witness as run_engine_witness, +) + + +def test_mutated_execution_witness_fixture_is_skipped() -> None: + """Fixtures with deliberately mutated witnesses are not consumable.""" + fixture = cast( + Any, + SimpleNamespace( + payloads=[ + SimpleNamespace( + execution_witness=object(), + execution_witness_mutated=True, + ) + ] + ), + ) + unused_dependency = cast(Any, None) + + with pytest.raises( + pytest.skip.Exception, + match="fixture contains a deliberately mutated executionWitness", + ): + run_engine_witness( + timing_data=unused_dependency, + eth_rpc=unused_dependency, + engine_rpc=unused_dependency, + engine_witness_rpc=unused_dependency, + fixture=fixture, + genesis_header=unused_dependency, + use_ssz_transport=False, + ) 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 index f863be0b63d..a67fd7d9e05 100644 --- 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 @@ -1,5 +1,7 @@ """ -A hive based simulator that executes blocks against clients using either: +Hive-based simulator for witness-emitting payload execution. + +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` @@ -80,6 +82,9 @@ def test_blockchain_via_engine_witness( 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 any(p.execution_witness_mutated for p in fixture.payloads): + pytest.skip("fixture contains a deliberately mutated executionWitness") + if not any(p.execution_witness is not None for p in fixture.payloads): pytest.skip("fixture has no executionWitness on any payload") @@ -89,7 +94,7 @@ def test_blockchain_via_engine_witness( 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_response = engine_rpc.forkchoice_updated_with_retry( forkchoice_state=ForkchoiceState( head_block_hash=fixture.genesis.block_hash, ), @@ -99,10 +104,13 @@ def test_blockchain_via_engine_witness( max_attempts=30, wait_fixed=1.0, ) - if response.payload_status.status != PayloadStatusEnum.VALID: + if ( + forkchoice_response.payload_status.status + != PayloadStatusEnum.VALID + ): raise LoggedError( f"Unexpected status on forkchoice updated to genesis: " - f"{response.payload_status.status}" + f"{forkchoice_response.payload_status.status}" ) except ForkchoiceUpdateTimeoutError as e: raise LoggedError( @@ -137,7 +145,7 @@ def test_blockchain_via_engine_witness( ) with payload_timing.time(timing_label): try: - response = _call_endpoint( + witness_response = _call_endpoint( use_ssz=use_ssz_transport, engine_rpc=engine_rpc, engine_witness_rpc=engine_witness_rpc, @@ -162,19 +170,19 @@ def test_blockchain_via_engine_witness( if payload.valid() else PayloadStatusEnum.INVALID ) - if response.status != expected_validity: + if witness_response.status != expected_validity: raise LoggedError( f"unexpected status: want {expected_validity}, " - f"got {response.status}" + f"got {witness_response.status}" ) - if response.status == PayloadStatusEnum.VALID: + if witness_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: + elif witness_response.witness is None: raise LoggedError( f"Payload {i + 1}: VALID status but client " "returned no witness" @@ -184,18 +192,20 @@ def test_blockchain_via_engine_witness( try: assert_witness_matches( expected=payload.execution_witness, - actual=response.witness, + actual=witness_response.witness, ) except WitnessMismatchError as e: raise LoggedError(str(e)) from e - elif use_ssz_transport and response.witness is not None: + elif ( + use_ssz_transport and witness_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)" + f"Payload {i + 1}: {witness_response.status} " + "status but client returned a non-empty witness " + "(PR #773 requires empty witness when not VALID)" ) if payload.valid(): diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 153109766bd..dea13d1c63f 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1424,6 +1424,23 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( for header in stateless_input.witness.headers ] == block["executionWitness"]["headers"] + engine_fixture_path = Path( + "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" + "module_execution_witness_soundness/" + "execution_witness_soundness.json" + ) + assert engine_fixture_path.exists(), ( + f"{engine_fixture_path} does not exist" + ) + + with open(engine_fixture_path, "r") as f: + engine_fixture_data = json.load(f) + + engine_fixture = next(iter(engine_fixture_data.values())) + engine_payload = engine_fixture["engineNewPayloads"][-1] + assert engine_payload["executionWitnessMutated"] is True + assert engine_payload["executionWitness"] == block["executionWitness"] + def test_execution_witness_modifier_requires_explicit_guest_expectation( testdir: pytest.Testdir, diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 1b7437c70a9..a2abd9972ee 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -458,6 +458,7 @@ class FixtureEngineNewPayload(CamelModel): new_payload_version: Number forkchoice_updated_version: Number execution_witness: ExecutionWitness | None = None + execution_witness_mutated: bool | None = None validation_error: ExceptionInstanceOrList | None = None error_code: ( Annotated[ @@ -484,6 +485,7 @@ def from_fixture_header( requests: List[Bytes] | None, block_access_list: Bytes | None = None, execution_witness: ExecutionWitness | None = None, + execution_witness_mutated: bool | None = None, **kwargs: Any, ) -> Self: """Create `FixtureEngineNewPayload` from a `FixtureHeader`.""" @@ -537,6 +539,7 @@ def from_fixture_header( new_payload_version=new_payload_version, forkchoice_updated_version=forkchoice_updated_version, execution_witness=execution_witness, + execution_witness_mutated=execution_witness_mutated, **kwargs, ) diff --git a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py index 3fa98f41d4f..2f50c8e15c8 100644 --- a/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/tests/test_blockchain.py @@ -743,6 +743,7 @@ codes=[Bytes(b"code")], headers=[Bytes(b"header")], ), + execution_witness_mutated=True, validation_error=[ BlockException.INCORRECT_BLOCK_FORMAT, TransactionException.INTRINSIC_GAS_TOO_LOW, @@ -832,6 +833,7 @@ "codes": [Bytes(b"code").hex()], "headers": [Bytes(b"header").hex()], }, + "executionWitnessMutated": True, "forkchoiceUpdatedVersion": "3", "newPayloadVersion": "4", "validationError": "BlockException.INCORRECT_BLOCK_FORMAT" diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index af81b682f1e..8dbc24f82b4 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -707,6 +707,7 @@ class BuiltBlock(CamelModel): fork: Fork block_access_list: BlockAccessList | None execution_witness: ExecutionWitness | None = None + execution_witness_mutated: bool = False stateless_input_bytes: Bytes | None = None stateless_output_bytes: Bytes | None = None @@ -783,6 +784,9 @@ def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: if self.block_access_list else None, execution_witness=self.execution_witness, + execution_witness_mutated=( + True if self.execution_witness_mutated else None + ), validation_error=self.expected_exception, error_code=self.engine_api_error_code, ) @@ -1360,6 +1364,7 @@ def generate_block_data( fork=fork, block_access_list=bal, execution_witness=execution_witness, + execution_witness_mutated=has_witness_modifier, stateless_input_bytes=stateless_input_bytes, stateless_output_bytes=stateless_output_bytes, ) From 21075df6f5fbaba41b331bee80faaa904aa2af31 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 15 May 2026 18:08:45 -0300 Subject: [PATCH 201/265] feat(hive): handle skipped tests in hive_test results --- .../plugins/pytest_hive/pytest_hive.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py index 89e24f1f5e9..abb38999ec6 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/pytest_hive.py @@ -365,6 +365,17 @@ def hive_test( ): test_passed = True test_result_details = "Test passed.\n\n" + captured_output + elif ( + hasattr(request.node, "result_call") + and request.node.result_call.skipped + ): + test_passed = True + test_result_details = ( + "Test skipped.\n\n" + + request.node.result_call.longreprtext + + "\n" + + captured_output + ) elif ( hasattr(request.node, "result_call") and not request.node.result_call.passed From dcb52d6ea6bcf9c5ba5a3fae4ab6257b6f476a98 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 12:06:44 -0300 Subject: [PATCH 202/265] lints --- .../simulators/engine_witness/__init__.py | 1 + .../simulators/helpers/tests/__init__.py | 1 + .../simulators/helpers/witness_diff.py | 8 +++--- .../testing/src/execution_testing/rpc/rpc.py | 2 +- .../src/execution_testing/rpc/rpc_types.py | 26 ++++++------------- .../tests/test_new_payload_with_witness.py | 19 ++++++++------ 6 files changed, 26 insertions(+), 31 deletions(-) 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 index e69de29bb2d..095c8b113fa 100644 --- 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 @@ -0,0 +1 @@ +"""Engine witness simulator package.""" 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 index e69de29bb2d..f2c1ab2abce 100644 --- 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 @@ -0,0 +1 @@ +"""Tests for consume simulator helpers.""" 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 index ad7a450cff1..1214125eb93 100644 --- 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 @@ -46,14 +46,14 @@ def _diff_ordered( first_mismatch = next( ( i - for i, (expected_item, actual_item) in enumerate(zip(exp, act)) + for i, (expected_item, actual_item) in enumerate( + zip(exp, act, strict=False) + ) if expected_item != actual_item ), min(len(exp), len(act)), ) - expected_item = ( - exp[first_mismatch] if first_mismatch < len(exp) else None - ) + expected_item = exp[first_mismatch] if first_mismatch < len(exp) else None actual_item = act[first_mismatch] if first_mismatch < len(act) else None return [ diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index fcad0a72446..25bd74f0da3 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -149,7 +149,7 @@ class EngineWitnessEndpointNotImplementedError(Exception): """ def __init__(self, url: str, http_status: int): - """Initialize with the URL and the HTTP status that signalled absence.""" + """Initialize with endpoint URL and absence HTTP status.""" self.url = url self.http_status = http_status super().__init__( diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index 3df4049758f..95062299492 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -342,21 +342,15 @@ class EthConfigResponse(CamelModel): 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 - ] + 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] + latest_valid_hash: SszUnion[None, ByteVector[32]] + validation_error: SszUnion[None, ByteList[VALIDATION_ERROR_MAX]] witness: ByteList[MAX_WITNESS_BYTES] @@ -392,9 +386,7 @@ def from_ssz_bytes(cls, data: bytes) -> Self: try: status = _SSZ_STATUS_TO_ENUM[status_int] except KeyError as e: - raise ValueError( - f"Unknown SSZ status byte: {status_int}" - ) from 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: @@ -425,7 +417,7 @@ def from_ssz_bytes(cls, data: bytes) -> Self: @classmethod def from_geth_json(cls, data: Dict[str, Any]) -> Self: """ - Decode the response of geth's JSON-RPC `engine_newPayloadWithWitnessVX`. + Decode geth's JSON-RPC `engine_newPayloadWithWitnessVX` response. The `witness` field is a hex-encoded RLP list `[Headers, Codes, State, Keys]` where Headers are RLP-encoded header @@ -441,9 +433,7 @@ def from_geth_json(cls, data: Dict[str, Any]) -> Self: ) raw_err = data.get("validationError") - validation_error: str | None = ( - raw_err if raw_err is not None else None - ) + validation_error: str | None = raw_err if raw_err is not None else None witness: ExecutionWitness | None = None raw_witness = data.get("witness") 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 index 0ff27389499..41c34d49636 100644 --- 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 @@ -8,9 +8,9 @@ from execution_testing.rpc.rpc_types import ( MAX_WITNESS_BYTES, MAX_WITNESS_ITEM_BYTES, + VALIDATION_ERROR_MAX, NewPayloadWithWitnessResponse, PayloadStatusEnum, - VALIDATION_ERROR_MAX, _SszExecutionWitness, _SszNewPayloadWithWitnessResponse, ) @@ -143,7 +143,7 @@ def test_decode_unknown_status_byte_raises() -> None: 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].""" + """Build a geth ExtWitness RLP payload.""" return eth_rlp.encode([headers, codes, state, []]) @@ -151,11 +151,14 @@ 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() + witness_hex = ( + "0x" + + _geth_witness_rlp( + headers=[header_list], + codes=[b"\x60\x01"], + state=[b"\xaa\xaa", b"\xbb"], + ).hex() + ) response_json = { "status": "VALID", @@ -199,7 +202,7 @@ def test_decode_geth_json_invalid_no_witness() -> None: def test_decode_geth_json_empty_witness_hex() -> None: - """Geth may emit witness='0x' on non-VALID statuses; parse as no witness.""" + """Parse geth's empty non-VALID witness as no witness.""" response_json = { "status": "SYNCING", "latestValidHash": None, From e9ed2dec73bd4535a4e01494767fafb612cfe997 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 12:11:49 -0300 Subject: [PATCH 203/265] improve comments to be more precise --- .../simulators/engine_witness/conftest.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 index 8cb4f79d784..560066d66d2 100644 --- 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 @@ -1,9 +1,10 @@ """ 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. +Drives the Hive back-end and EL clients through a witness-emitting +payload execution path, using JSON-RPC+RLP by default or REST+SSZ when +`--ssz` is enabled, then asserts the client-generated execution witness +matches the fixture. """ import io @@ -64,10 +65,12 @@ def test_suite_name() -> str: 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." + "Execute blockchain-engine fixtures via the witness-emitting Engine " + "API path, using JSON-RPC engine_newPayloadWithWitnessVX with " + "RLP-encoded witness by default or REST POST " + "/new-payload-with-witness with SSZ response when --ssz is enabled, " + "verifying the client-generated execution witness against the fixture " + "witness." ) @@ -91,7 +94,7 @@ def genesis_header(fixture: BlockchainEngineFixture) -> "FixtureHeader": def engine_witness_rpc( client: Client, client_exception_mapper: ExceptionMapper | None ) -> EngineWitnessRPC: - """Provide a REST client for POST /new-payload-with-witness.""" + """Provide the REST client used by the `--ssz` witness transport.""" if client_exception_mapper: return EngineWitnessRPC( f"http://{client.ip}:8551", From d9b5819e916125d9ffb10ec34913b486a67a1356 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 12:17:42 -0300 Subject: [PATCH 204/265] tighten a bit more witness diff tests --- .../simulators/helpers/tests/test_witness_diff.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 index 645a1df9ba4..c067cb9af89 100644 --- 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 @@ -23,15 +23,17 @@ def _w( 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) + """Separate byte-equal witnesses match.""" + # Create separate objects so equality is not just object identity. + expected = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60"], headers=[b"\xf9"]) + actual = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60"], headers=[b"\xf9"]) + assert_witness_matches(expected=expected, actual=actual) -def test_reordered_witness_fails() -> None: - """Witness comparison is order-sensitive.""" +def test_reordered_state_fails() -> None: + """State item comparison is order-sensitive.""" expected = _w(state=[b"\xaa", b"\xbb"], codes=[b"\x60", b"\x70"]) - actual = _w(state=[b"\xbb", b"\xaa"], codes=[b"\x70", b"\x60"]) + actual = _w(state=[b"\xbb", b"\xaa"], codes=[b"\x60", b"\x70"]) with pytest.raises(WitnessMismatchError, match="state: ordered mismatch"): assert_witness_matches(expected=expected, actual=actual) From 88312deb719c5074463558b800bf80f92ff425cd Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 12:25:20 -0300 Subject: [PATCH 205/265] improve witness diff helpers --- .../simulators/helpers/witness_diff.py | 106 +++++++++--------- 1 file changed, 52 insertions(+), 54 deletions(-) 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 index 1214125eb93..637c3f8476f 100644 --- 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 @@ -1,69 +1,60 @@ """Strict witness comparison helper for the engine-witness simulator.""" -from typing import Iterable, List +from typing import Iterable from execution_testing.base_types import Bytes from execution_testing.test_types.execution_witness import ExecutionWitness -def _preview(value: bytes | None) -> str: - """Return a compact hex preview for a witness item.""" - if value is None: - return "" +def _item_preview(value: bytes) -> str: + """Return a short hex preview for one witness item.""" return "0x" + value.hex()[:16] -def _diff_ordered( +def _items_preview(items: list[bytes]) -> str: + """Return previews for the first few witness items.""" + return ", ".join(_item_preview(item) for item in items[:5]) + + +def _ordered_field_mismatch( field: str, expected: Iterable[Bytes], actual: Iterable[Bytes] -) -> List[str]: - """Return human-readable diff messages for a single witness field.""" +) -> str | None: + """Return a human-readable mismatch line for one ordered field.""" exp = [bytes(x) for x in expected] act = [bytes(x) for x in actual] if exp == act: - return [] - - if len(exp) > len(act) and exp[: len(act)] == act: - missing_count = len(exp) - len(act) - preview = ", ".join(_preview(item) for item in exp[len(act) :][:5]) - return [ - ( - f"{field}: {missing_count} missing " - f"(not emitted by client): {preview}" - ) - ] - - if len(act) > len(exp) and act[: len(exp)] == exp: - extra_count = len(act) - len(exp) - preview = ", ".join(_preview(item) for item in act[len(exp) :][:5]) - return [ - ( - f"{field}: {extra_count} extra " - f"(over-collected by client): {preview}" + return None + + common_len = min(len(exp), len(act)) + if exp[:common_len] == act[:common_len]: + if len(exp) > len(act): + missing = exp[common_len:] + return ( + f"{field}: {len(missing)} missing " + f"(not emitted by client): {_items_preview(missing)}" ) - ] + extra = act[common_len:] + return ( + f"{field}: {len(extra)} extra " + f"(over-collected by client): {_items_preview(extra)}" + ) first_mismatch = next( - ( - i - for i, (expected_item, actual_item) in enumerate( - zip(exp, act, strict=False) - ) - if expected_item != actual_item - ), - min(len(exp), len(act)), - ) - expected_item = exp[first_mismatch] if first_mismatch < len(exp) else None - actual_item = act[first_mismatch] if first_mismatch < len(act) else None - - return [ - ( - f"{field}: ordered mismatch " - f"(expected {len(exp)} items, got {len(act)}); " - f"first mismatch at index {first_mismatch}: " - f"expected {_preview(expected_item)}, got {_preview(actual_item)}" + i + for i, (expected_item, actual_item) in enumerate( + zip(exp, act, strict=False) ) - ] + if expected_item != actual_item + ) + + return ( + f"{field}: ordered mismatch " + f"(expected {len(exp)} items, got {len(act)}); " + f"first mismatch at index {first_mismatch}: " + f"expected {_item_preview(exp[first_mismatch])}, " + f"got {_item_preview(act[first_mismatch])}" + ) class WitnessMismatchError(AssertionError): @@ -74,15 +65,22 @@ def assert_witness_matches( expected: ExecutionWitness, actual: ExecutionWitness ) -> None: """ - Assert the client-emitted `actual` witness matches the fixture `expected` - witness exactly on each of `state`, `codes`, and `headers`. + Assert the client-emitted `actual` witness matches the fixture `expected`. - Ordering and duplicate entries are significant. + Each of `state`, `codes`, and `headers` must match exactly. Ordering and + duplicate entries are significant. """ - messages: List[str] = [] - messages += _diff_ordered("state", expected.state, actual.state) - messages += _diff_ordered("codes", expected.codes, actual.codes) - messages += _diff_ordered("headers", expected.headers, actual.headers) + messages: list[str] = [] + for field, expected_items, actual_items in ( + ("state", expected.state, actual.state), + ("codes", expected.codes, actual.codes), + ("headers", expected.headers, actual.headers), + ): + mismatch = _ordered_field_mismatch( + field, expected_items, actual_items + ) + if mismatch is not None: + messages.append(mismatch) if messages: raise WitnessMismatchError( From 91e402ffeee7925b8caf9f119f0cc915993b9a76 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 13:12:48 -0300 Subject: [PATCH 206/265] refactor engine-witness logic and cleanups --- .../cli/pytest_commands/consume.py | 3 +- .../simulators/engine_witness/conftest.py | 15 +- .../helpers/tests/test_engine_witness_skip.py | 2 +- .../simulators/helpers/witness_diff.py | 4 +- .../test_via_engine_witness.py | 285 +++++++++--------- .../src/execution_testing/rpc/__init__.py | 4 +- .../testing/src/execution_testing/rpc/rpc.py | 4 +- .../src/execution_testing/rpc/rpc_types.py | 5 +- .../tests/test_new_payload_with_witness.py | 2 +- 9 files changed, 167 insertions(+), 157 deletions(-) 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 a0c123a449f..2fb9b63e03d 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/consume.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/consume.py @@ -134,8 +134,7 @@ 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 --ssz to use the REST POST /new-payload-with-witness endpoint. """ pass 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 index 560066d66d2..81dfbc9e41f 100644 --- 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 @@ -16,7 +16,7 @@ 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 +from execution_testing.rpc import EngineSszRPC pytest_plugins = ( "execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive", @@ -37,9 +37,8 @@ def pytest_addoption(parser: pytest.Parser) -> None: 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)." + "SSZ-encoded response instead of the default JSON-RPC " + "engine_newPayloadWithWitnessVX with RLP-encoded witness " ), ) @@ -91,15 +90,15 @@ def genesis_header(fixture: BlockchainEngineFixture) -> "FixtureHeader": @pytest.fixture(scope="function") -def engine_witness_rpc( +def engine_ssz_rpc( client: Client, client_exception_mapper: ExceptionMapper | None -) -> EngineWitnessRPC: +) -> EngineSszRPC: """Provide the REST client used by the `--ssz` witness transport.""" if client_exception_mapper: - return EngineWitnessRPC( + return EngineSszRPC( f"http://{client.ip}:8551", response_validation_context={ "exception_mapper": client_exception_mapper, }, ) - return EngineWitnessRPC(f"http://{client.ip}:8551") + return EngineSszRPC(f"http://{client.ip}:8551") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py index aa609c3a23e..1f759c27714 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/tests/test_engine_witness_skip.py @@ -33,7 +33,7 @@ def test_mutated_execution_witness_fixture_is_skipped() -> None: timing_data=unused_dependency, eth_rpc=unused_dependency, engine_rpc=unused_dependency, - engine_witness_rpc=unused_dependency, + engine_ssz_rpc=unused_dependency, fixture=fixture, genesis_header=unused_dependency, use_ssz_transport=False, 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 index 637c3f8476f..d5be3558168 100644 --- 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 @@ -76,9 +76,7 @@ def assert_witness_matches( ("codes", expected.codes, actual.codes), ("headers", expected.headers, actual.headers), ): - mismatch = _ordered_field_mismatch( - field, expected_items, actual_items - ) + mismatch = _ordered_field_mismatch(field, expected_items, actual_items) if mismatch is not None: messages.append(mismatch) 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 index a67fd7d9e05..8690bf4930d 100644 --- 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 @@ -1,25 +1,17 @@ -""" -Hive-based simulator for witness-emitting payload execution. - -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 exactly matches -`fixture.execution_witness` on state/codes/headers. -""" +"""Hive simulator for witness-emitting payload execution.""" import pytest from execution_testing.fixtures import BlockchainEngineFixture -from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.fixtures.blockchain import ( + FixtureEngineNewPayload, + FixtureHeader, +) from execution_testing.logging import get_logger from execution_testing.rpc import ( EngineRPC, + EngineSszRPC, EngineWitnessEndpointNotImplementedError, - EngineWitnessRPC, EthRPC, ForkchoiceUpdateTimeoutError, ) @@ -42,46 +34,131 @@ logger = get_logger(__name__) +_JSONRPC_METHOD_NOT_FOUND = -32601 -def _call_endpoint( + +def _witness_endpoint_label( + payload: FixtureEngineNewPayload, *, - use_ssz: bool, + use_ssz_transport: bool, +) -> str: + """Return the timing label for the selected witness endpoint.""" + if use_ssz_transport: + return "POST /new-payload-with-witness" + return f"engine_newPayloadWithWitnessV{payload.new_payload_version}" + + +def _send_payload_with_witness( + *, + use_ssz_transport: bool, engine_rpc: EngineRPC, - engine_witness_rpc: EngineWitnessRPC, - payload_params: tuple, - version: int, + engine_ssz_rpc: EngineSszRPC, + payload: FixtureEngineNewPayload, ) -> 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 + """Execute one payload through the configured witness endpoint.""" + try: + if use_ssz_transport: + return engine_ssz_rpc.new_payload_with_witness(*payload.params) + return engine_rpc.new_payload_with_witness( + *payload.params, + version=payload.new_payload_version, + ) + except EngineWitnessEndpointNotImplementedError as e: + pytest.skip(str(e)) + except JSONRPCError as e: + if e.code == _JSONRPC_METHOD_NOT_FOUND: + pytest.skip( + "client does not support " + f"engine_newPayloadWithWitnessV" + f"{payload.new_payload_version}: {e.message}" + ) + raise + + +def _assert_witness_response( + *, + payload: FixtureEngineNewPayload, + payload_number: int, + response: NewPayloadWithWitnessResponse, + payload_timing: TimingData, + use_ssz_transport: bool, +) -> None: + """Assert one witness response matches the fixture payload.""" + expected_status = ( + PayloadStatusEnum.VALID + if payload.valid() + else PayloadStatusEnum.INVALID ) + if response.status != expected_status: + raise LoggedError( + f"unexpected status: want {expected_status}, got {response.status}" + ) + + if response.status != PayloadStatusEnum.VALID: + if use_ssz_transport and response.witness is not None: + raise LoggedError( + f"Payload {payload_number}: {response.status} status but " + "client returned a non-empty witness; the REST+SSZ endpoint " + "requires an empty witness when not VALID" + ) + return + + expected_witness = payload.execution_witness + if expected_witness is None: + logger.warning( + f"Payload {payload_number}: fixture has no executionWitness; " + "skipping witness diff" + ) + return + + actual_witness = response.witness + if actual_witness is None: + raise LoggedError( + f"Payload {payload_number}: VALID status but client returned " + "no witness" + ) + + with payload_timing.time("Witness diff"): + try: + assert_witness_matches( + expected=expected_witness, + actual=actual_witness, + ) + except WitnessMismatchError as e: + raise LoggedError(str(e)) from e + + +def _advance_forkchoice_to_payload( + *, + engine_rpc: EngineRPC, + payload: FixtureEngineNewPayload, +) -> None: + """Advance the client forkchoice to one valid payload.""" + 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 = response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise LoggedError( + f"unexpected forkchoice status: want {PayloadStatusEnum.VALID}, " + f"got {status}" + ) def test_blockchain_via_engine_witness( timing_data: TimingData, eth_rpc: EthRPC, engine_rpc: EngineRPC, - engine_witness_rpc: EngineWitnessRPC, + engine_ssz_rpc: EngineSszRPC, 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 exactly to - fixture.execution_witness 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. - """ + """Execute blockchain-engine fixtures through a witness endpoint.""" if any(p.execution_witness_mutated for p in fixture.payloads): pytest.skip("fixture contains a deliberately mutated executionWitness") @@ -98,25 +175,23 @@ def test_blockchain_via_engine_witness( forkchoice_state=ForkchoiceState( head_block_hash=fixture.genesis.block_hash, ), - forkchoice_version=fixture.payloads[ - 0 - ].forkchoice_updated_version, + forkchoice_version=( + fixture.payloads[0].forkchoice_updated_version + ), max_attempts=30, wait_fixed=1.0, ) - if ( - forkchoice_response.payload_status.status - != PayloadStatusEnum.VALID - ): - raise LoggedError( - f"Unexpected status on forkchoice updated to genesis: " - f"{forkchoice_response.payload_status.status}" - ) except ForkchoiceUpdateTimeoutError as e: raise LoggedError( f"Timed out waiting for forkchoice update to genesis: {e}" ) from None + status = forkchoice_response.payload_status.status + if status != PayloadStatusEnum.VALID: + raise LoggedError( + f"Unexpected status on forkchoice updated to genesis: {status}" + ) + 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" @@ -127,103 +202,43 @@ def test_blockchain_via_engine_witness( ) 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): + payload_count = len(fixture.payloads) + logger.info(f"Starting execution of {payload_count} payloads...") + for payload_number, payload in enumerate(fixture.payloads, start=1): logger.info( - f"Processing payload {i + 1}/{len(fixture.payloads)}..." + f"Processing payload {payload_number}/{payload_count}..." ) with total_payload_timing.time( - f"Payload {i + 1}" + f"Payload {payload_number}" ) 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: - witness_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 witness_response.status != expected_validity: - raise LoggedError( - f"unexpected status: want {expected_validity}, " - f"got {witness_response.status}" + with payload_timing.time( + _witness_endpoint_label( + payload, + use_ssz_transport=use_ssz_transport, ) - - if witness_response.status == PayloadStatusEnum.VALID: - if payload.execution_witness is None: - logger.warning( - f"Payload {i + 1}: fixture has no " - "executionWitness; skipping witness diff" - ) - elif witness_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=witness_response.witness, - ) - except WitnessMismatchError as e: - raise LoggedError(str(e)) from e - elif ( - use_ssz_transport and witness_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}: {witness_response.status} " - "status but client returned a non-empty witness " - "(PR #773 requires empty witness when not VALID)" + witness_response = _send_payload_with_witness( + use_ssz_transport=use_ssz_transport, + engine_rpc=engine_rpc, + engine_ssz_rpc=engine_ssz_rpc, + payload=payload, ) + _assert_witness_response( + payload=payload, + payload_number=payload_number, + response=witness_response, + payload_timing=payload_timing, + use_ssz_transport=use_ssz_transport, + ) + 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, + _advance_forkchoice_to_payload( + engine_rpc=engine_rpc, + payload=payload, ) - 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/rpc/__init__.py b/packages/testing/src/execution_testing/rpc/__init__.py index 42ac537c5ce..fd98957920f 100644 --- a/packages/testing/src/execution_testing/rpc/__init__.py +++ b/packages/testing/src/execution_testing/rpc/__init__.py @@ -8,8 +8,8 @@ BlockNumberType, DebugRPC, EngineRPC, + EngineSszRPC, EngineWitnessEndpointNotImplementedError, - EngineWitnessRPC, EthRPC, ForkchoiceUpdateTimeoutError, NetRPC, @@ -38,8 +38,8 @@ "BlockNumberType", "DebugRPC", "EngineRPC", + "EngineSszRPC", "EngineWitnessEndpointNotImplementedError", - "EngineWitnessRPC", "EthConfigResponse", "EthRPC", "ForkConfig", diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 25bd74f0da3..634bdb6d165 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -1370,9 +1370,9 @@ def _do_forkchoice_update() -> ForkchoiceUpdateResponse: return _do_forkchoice_update() -class EngineWitnessRPC(BaseJwtRPC): +class EngineSszRPC(BaseJwtRPC): """ - REST client for `POST /new-payload-with-witness` (execution-apis PR #773). + REST client for `POST /new-payload-with-witness`. The endpoint uses a JSON request body and an SSZ response body; JWT auth is inherited from `BaseJwtRPC`. diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index 95062299492..05d63399267 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -332,8 +332,7 @@ 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). +# SSZ schema for the REST POST /new-payload-with-witness response. VALIDATION_ERROR_MAX = 8192 MAX_WITNESS_BYTES = 2**30 # 1 GiB @@ -366,7 +365,7 @@ class _SszNewPayloadWithWitnessResponse(Container): @dataclass class NewPayloadWithWitnessResponse: """ - Decoded response of POST /new-payload-with-witness (PR #773). + Decoded response of POST /new-payload-with-witness. The witness field is ``None`` whenever status is not ``VALID`` (the spec mandates an empty SSZ witness in that case). 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 index 41c34d49636..b0c91dbdbae 100644 --- 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 @@ -108,7 +108,7 @@ def test_decode_invalid_with_validation_error() -> None: def test_decode_syncing_empty_witness() -> None: - """A SYNCING response has no witness per PR #773.""" + """A SYNCING response has no witness.""" raw = _build_response( status=2, latest_valid_hash=None, From b9df13e9f118907ffc77eec86aff97b477b330c6 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 13:19:15 -0300 Subject: [PATCH 207/265] comments cleanup --- .../cli/pytest_commands/consume.py | 3 +- .../testing/src/execution_testing/rpc/rpc.py | 5 ++-- .../src/execution_testing/rpc/rpc_types.py | 6 ++-- .../tests/test_new_payload_with_witness.py | 28 +++++++++---------- 4 files changed, 21 insertions(+), 21 deletions(-) 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 2fb9b63e03d..4b14110c6ab 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/consume.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/consume.py @@ -133,7 +133,8 @@ def engine_witness() -> None: """ Verify client-emitted execution witnesses against the fixture. - Default transport: engine_newPayloadWithWitnessVX JSON-RPC (geth). + Default transport: JSON-RPC engine_newPayloadWithWitnessVX with RLP + witness. Pass --ssz to use the REST POST /new-payload-with-witness endpoint. """ pass diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 634bdb6d165..4872428f2e9 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -1212,8 +1212,7 @@ def new_payload_with_witness( ) -> NewPayloadWithWitnessResponse: """ `engine_newPayloadWithWitnessVX`: execute the payload and return the - payload status plus an RLP-encoded execution witness (geth extension, - see go-ethereum PR #30069). + payload status plus an RLP-encoded execution witness. """ method = f"newPayloadWithWitnessV{version}" params_list = [to_json(param) for param in params] @@ -1222,7 +1221,7 @@ def new_payload_with_witness( request=RPCCall(method=method, params=params_list) ).result_or_raise() - return NewPayloadWithWitnessResponse.from_geth_json(result) + return NewPayloadWithWitnessResponse.from_json_rpc_result(result) def forkchoice_updated( self, diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index 05d63399267..eeee61e87b1 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -414,9 +414,9 @@ def from_ssz_bytes(cls, data: bytes) -> Self: ) @classmethod - def from_geth_json(cls, data: Dict[str, Any]) -> Self: + def from_json_rpc_result(cls, data: Dict[str, Any]) -> Self: """ - Decode geth's JSON-RPC `engine_newPayloadWithWitnessVX` response. + Decode a JSON-RPC `engine_newPayloadWithWitnessVX` response. The `witness` field is a hex-encoded RLP list `[Headers, Codes, State, Keys]` where Headers are RLP-encoded header @@ -446,7 +446,7 @@ def from_geth_json(cls, data: Dict[str, Any]) -> Self: parsed = eth_rlp.decode(witness_bytes) if not isinstance(parsed, list) or len(parsed) < 3: raise ValueError( - "Unexpected geth ExtWitness RLP structure: " + "Unexpected execution witness RLP structure: " f"{type(parsed).__name__} of length " f"{len(parsed) if isinstance(parsed, list) else 0}" ) 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 index b0c91dbdbae..f2f3dd35565 100644 --- 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 @@ -137,23 +137,23 @@ def test_decode_unknown_status_byte_raises() -> None: NewPayloadWithWitnessResponse.from_ssz_bytes(raw) -# --- Geth JSON-RPC (RLP witness) decode --- +# --- JSON-RPC (RLP witness) decode --- -def _geth_witness_rlp( +def _json_rpc_witness_rlp( headers: list[list], codes: list[bytes], state: list[bytes] ) -> bytes: - """Build a geth ExtWitness RLP payload.""" + """Build an RLP witness payload returned by the JSON-RPC endpoint.""" return eth_rlp.encode([headers, codes, state, []]) -def test_decode_geth_json_valid() -> None: - """Round-trip a geth VALID response with RLP witness.""" +def test_decode_json_rpc_valid() -> None: + """Round-trip a VALID JSON-RPC 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( + + _json_rpc_witness_rlp( headers=[header_list], codes=[b"\x60\x01"], state=[b"\xaa\xaa", b"\xbb"], @@ -167,7 +167,7 @@ def test_decode_geth_json_valid() -> None: "witness": witness_hex, } - decoded = NewPayloadWithWitnessResponse.from_geth_json(response_json) + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) assert decoded.status == PayloadStatusEnum.VALID assert decoded.latest_valid_hash is not None @@ -184,16 +184,16 @@ def test_decode_geth_json_valid() -> None: 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.""" +def test_decode_json_rpc_invalid_no_witness() -> None: + """An INVALID JSON-RPC response has no witness payload.""" response_json = { "status": "INVALID", "latestValidHash": None, "validationError": "block root mismatch", - # geth omits the witness field on INVALID + # The witness field may be omitted on INVALID. } - decoded = NewPayloadWithWitnessResponse.from_geth_json(response_json) + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) assert decoded.status == PayloadStatusEnum.INVALID assert decoded.latest_valid_hash is None @@ -201,8 +201,8 @@ def test_decode_geth_json_invalid_no_witness() -> None: assert decoded.witness is None -def test_decode_geth_json_empty_witness_hex() -> None: - """Parse geth's empty non-VALID witness as no witness.""" +def test_decode_json_rpc_empty_witness_hex() -> None: + """Parse an empty non-VALID witness as no witness.""" response_json = { "status": "SYNCING", "latestValidHash": None, @@ -210,7 +210,7 @@ def test_decode_geth_json_empty_witness_hex() -> None: "witness": "0x", } - decoded = NewPayloadWithWitnessResponse.from_geth_json(response_json) + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) assert decoded.status == PayloadStatusEnum.SYNCING assert decoded.witness is None From f1dc325c314f6e3c717b3da1b562a81244455a59 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 14:15:39 -0300 Subject: [PATCH 208/265] improve comments --- packages/testing/src/execution_testing/rpc/rpc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 4872428f2e9..e441cfa1cd9 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -149,7 +149,7 @@ class EngineWitnessEndpointNotImplementedError(Exception): """ def __init__(self, url: str, http_status: int): - """Initialize with endpoint URL and absence HTTP status.""" + """Initialize with endpoint URL and HTTP status.""" self.url = url self.http_status = http_status super().__init__( @@ -1211,8 +1211,8 @@ def new_payload_with_witness( version: int, ) -> NewPayloadWithWitnessResponse: """ - `engine_newPayloadWithWitnessVX`: execute the payload and return the - payload status plus an RLP-encoded execution witness. + `engine_newPayloadWithWitnessVX`: execute the payload and decode the + payload status plus hex-encoded RLP execution witness. """ method = f"newPayloadWithWitnessV{version}" params_list = [to_json(param) for param in params] From b93bcd1ad4ba9d4161ce5add8200e715f20638df Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Mon, 18 May 2026 15:13:13 -0300 Subject: [PATCH 209/265] tighten up strictness in rpc types --- .../src/execution_testing/rpc/rpc_types.py | 157 +++++++++++++----- .../tests/test_new_payload_with_witness.py | 100 ++++++++++- 2 files changed, 216 insertions(+), 41 deletions(-) diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index eeee61e87b1..38214e3ef6f 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -2,7 +2,7 @@ import json from binascii import crc32 -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from hashlib import sha256 from typing import Annotated, Any, Dict, List, Protocol, Self @@ -353,6 +353,17 @@ class _SszNewPayloadWithWitnessResponse(Container): witness: ByteList[MAX_WITNESS_BYTES] +class _NewPayloadWithWitnessJSONRPCResult(CamelModel): + """JSON-RPC result for `engine_newPayloadWithWitnessVX`.""" + + model_config = CamelModel.model_config | {"extra": "ignore"} + + status: PayloadStatusEnum + latest_valid_hash: Hash | None = None + validation_error: str | None = None + witness: str | None = None + + _SSZ_STATUS_TO_ENUM: Dict[int, PayloadStatusEnum] = { 0: PayloadStatusEnum.VALID, 1: PayloadStatusEnum.INVALID, @@ -362,7 +373,95 @@ class _SszNewPayloadWithWitnessResponse(Container): } -@dataclass +def _decode_0x_hex(value: str, field_name: str) -> bytes: + """Decode a strict JSON-RPC hex string.""" + if not value.startswith("0x"): + raise ValueError(f"{field_name} must be a 0x-prefixed hex string") + hex_value = value[2:] + if len(hex_value) % 2 != 0: + raise ValueError( + f"{field_name} must have an even number of hex digits" + ) + try: + return bytes.fromhex(hex_value) + except ValueError as e: + raise ValueError(f"{field_name} must be valid hex") from e + + +def _is_rlp_value(value: Any) -> bool: + """Return True when value can be re-encoded as an RLP value.""" + if isinstance(value, bytes): + return True + if isinstance(value, list): + return all(_is_rlp_value(item) for item in value) + return False + + +def _ensure_rlp_list(value: Any, field_name: str) -> List[Any]: + """Return an RLP list or raise a contextual error.""" + if not isinstance(value, list): + raise ValueError( + f"execution witness {field_name} must be an RLP list, " + f"got {type(value).__name__}" + ) + return value + + +def _bytes_list_from_rlp(value: Any, field_name: str) -> List[Bytes]: + """Convert an RLP list of byte strings to `Bytes` values.""" + values = _ensure_rlp_list(value, field_name) + result: List[Bytes] = [] + for index, item in enumerate(values): + if not isinstance(item, bytes): + raise ValueError( + f"execution witness {field_name}[{index}] must be bytes, " + f"got {type(item).__name__}" + ) + result.append(Bytes(item)) + return result + + +def _headers_from_rlp(value: Any) -> List[Bytes]: + """Convert RLP header objects to encoded header bytes.""" + headers = _ensure_rlp_list(value, "headers") + result: List[Bytes] = [] + for index, header in enumerate(headers): + if not _is_rlp_value(header): + raise ValueError( + f"execution witness headers[{index}] must be an RLP value, " + f"got {type(header).__name__}" + ) + result.append(Bytes(eth_rlp.encode(header))) + return result + + +def _execution_witness_from_json_rpc_rlp( + witness_bytes: bytes, +) -> ExecutionWitness: + """Decode a JSON-RPC RLP execution witness.""" + parsed = eth_rlp.decode(witness_bytes) + if not isinstance(parsed, list): + raise ValueError( + "Unexpected execution witness RLP structure: " + f"{type(parsed).__name__}" + ) + # Some clients append a legacy, non-spec `keys` field. Accept it + # temporarily, but ignore it below and only build from the spec fields. + if len(parsed) not in (3, 4): + raise ValueError( + "Unexpected execution witness RLP structure: " + f"list of length {len(parsed)}" + ) + + headers_raw, codes_raw, state_raw = parsed[0:3] + return ExecutionWitness( + state=_bytes_list_from_rlp(state_raw, "state"), + codes=_bytes_list_from_rlp(codes_raw, "codes"), + headers=_headers_from_rlp(headers_raw), + ) + + +@dataclass(frozen=True, slots=True) class NewPayloadWithWitnessResponse: """ Decoded response of POST /new-payload-with-witness. @@ -374,7 +473,7 @@ class NewPayloadWithWitnessResponse: status: PayloadStatusEnum latest_valid_hash: Hash | None validation_error: str | None - witness: ExecutionWitness | None = field(default=None) + witness: ExecutionWitness | None = None @classmethod def from_ssz_bytes(cls, data: bytes) -> Self: @@ -399,6 +498,10 @@ def from_ssz_bytes(cls, data: bytes) -> Self: witness: ExecutionWitness | None = None witness_bytes = bytes(resp.witness) if witness_bytes: + if status != PayloadStatusEnum.VALID: + raise ValueError( + f"{status.value} SSZ response must not contain a witness" + ) inner = _SszExecutionWitness.decode_bytes(witness_bytes) witness = ExecutionWitness( state=[Bytes(bytes(x)) for x in inner.state], @@ -419,48 +522,24 @@ def from_json_rpc_result(cls, data: Dict[str, Any]) -> Self: Decode a JSON-RPC `engine_newPayloadWithWitnessVX` response. 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. + `[Headers, Codes, State]` where Headers are RLP-encoded header + structures. Some clients append a legacy `Keys` element; it is ignored + because it is not part of the current spec. 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 + result = _NewPayloadWithWitnessJSONRPCResult.model_validate(data) 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 result.witness is not None: + witness_bytes = _decode_0x_hex(result.witness, "witness") if witness_bytes: - parsed = eth_rlp.decode(witness_bytes) - if not isinstance(parsed, list) or len(parsed) < 3: - raise ValueError( - "Unexpected execution witness 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], - ) + witness = _execution_witness_from_json_rpc_rlp(witness_bytes) return cls( - status=status, - latest_valid_hash=latest_valid_hash, - validation_error=validation_error, + status=result.status, + latest_valid_hash=result.latest_valid_hash, + validation_error=result.validation_error, witness=witness, ) 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 index f2f3dd35565..72154b0c061 100644 --- 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 @@ -2,6 +2,7 @@ import ethereum_rlp as eth_rlp import pytest +from ethereum_rlp.rlp import Extended from remerkleable.basic import uint8 from remerkleable.byte_arrays import ByteList, ByteVector @@ -137,14 +138,40 @@ def test_decode_unknown_status_byte_raises() -> None: NewPayloadWithWitnessResponse.from_ssz_bytes(raw) +def test_decode_invalid_with_witness_raises() -> None: + """A non-VALID SSZ response must not carry witness bytes.""" + raw = _build_response( + status=1, + latest_valid_hash=None, + validation_error="invalid state root", + witness_bytes=_build_inner_witness( + state=[b"\xaa"], + codes=[], + headers=[], + ), + ) + + with pytest.raises( + ValueError, match="INVALID SSZ response must not contain a witness" + ): + NewPayloadWithWitnessResponse.from_ssz_bytes(raw) + + # --- JSON-RPC (RLP witness) decode --- def _json_rpc_witness_rlp( - headers: list[list], codes: list[bytes], state: list[bytes] + headers: list[Extended], + codes: list[bytes], + state: list[bytes], + *, + legacy_keys: list[bytes] | None = None, ) -> bytes: """Build an RLP witness payload returned by the JSON-RPC endpoint.""" - return eth_rlp.encode([headers, codes, state, []]) + fields: list[Extended] = [headers, codes, state] + if legacy_keys is not None: + fields.append(legacy_keys) + return eth_rlp.encode(fields) def test_decode_json_rpc_valid() -> None: @@ -184,6 +211,34 @@ def test_decode_json_rpc_valid() -> None: assert eth_rlp.decode(bytes(decoded.witness.headers[0])) == header_list +def test_decode_json_rpc_ignores_legacy_keys() -> None: + """A legacy fourth `keys` RLP field is ignored.""" + header_list = [b"\x01" * 4, b"\x02" * 4] + witness_hex = ( + "0x" + + _json_rpc_witness_rlp( + headers=[header_list], + codes=[b"\x60\x01"], + state=[b"\xaa"], + legacy_keys=[b"legacy-key"], + ).hex() + ) + + decoded = NewPayloadWithWitnessResponse.from_json_rpc_result( + { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": witness_hex, + } + ) + + assert decoded.witness is not None + assert [bytes(c) for c in decoded.witness.codes] == [b"\x60\x01"] + assert [bytes(s) for s in decoded.witness.state] == [b"\xaa"] + assert eth_rlp.decode(bytes(decoded.witness.headers[0])) == header_list + + def test_decode_json_rpc_invalid_no_witness() -> None: """An INVALID JSON-RPC response has no witness payload.""" response_json = { @@ -214,3 +269,44 @@ def test_decode_json_rpc_empty_witness_hex() -> None: assert decoded.status == PayloadStatusEnum.SYNCING assert decoded.witness is None + + +def test_decode_json_rpc_witness_must_be_0x_prefixed() -> None: + """Witness hex must use the JSON-RPC 0x prefix.""" + response_json = { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": _json_rpc_witness_rlp( + headers=[], + codes=[], + state=[], + ).hex(), + } + + with pytest.raises(ValueError, match="0x-prefixed"): + NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) + + +def test_decode_json_rpc_rejects_non_list_witness_field() -> None: + """Codes and state must be RLP lists, not bare byte strings.""" + header_list = [b"\x01" * 4, b"\x02" * 4] + witness_hex = ( + "0x" + + eth_rlp.encode( + [ + [header_list], + b"\x60\x01", + [b"\xaa"], + ] + ).hex() + ) + response_json = { + "status": "VALID", + "latestValidHash": "0x" + ("11" * 32), + "validationError": None, + "witness": witness_hex, + } + + with pytest.raises(ValueError, match="codes must be an RLP list"): + NewPayloadWithWitnessResponse.from_json_rpc_result(response_json) From 8e8e3e66bc40c628447aa6c946c6a981b7aa850a Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 18 May 2026 16:20:05 -0300 Subject: [PATCH 210/265] rebase fixes --- src/ethereum/forks/amsterdam/execution_engine/types.py | 1 + .../forks/amsterdam/execution_engine/validation_helpers.py | 1 + src/ethereum/forks/amsterdam/stateless_host.py | 1 + src/ethereum/forks/amsterdam/stateless_ssz.py | 3 +++ tests/json_loader/test_stateless_guest.py | 2 ++ 5 files changed, 8 insertions(+) diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py index 144d763ce35..6495aafea14 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/types.py +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -56,6 +56,7 @@ class ExecutionPayload: blob_gas_used: U64 excess_blob_gas: U64 block_access_list: Bytes + slot_number: U64 @slotted_freezable diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py index 086702403a1..06c3735fa3b 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -80,6 +80,7 @@ def _payload_header( block_access_list_hash=Hash32( keccak256(execution_payload.block_access_list) ), + slot_number=execution_payload.slot_number, ) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index c442bd6e1e8..00e1821b341 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -147,6 +147,7 @@ def build_stateless_input( blob_gas_used=header.blob_gas_used, excess_blob_gas=header.excess_blob_gas, block_access_list=bal_bytes, + slot_number=header.slot_number, ) new_payload = NewPayloadRequest( diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 903b0e9a915..928644c6e03 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -109,6 +109,7 @@ class SszExecutionPayload(Container): blob_gas_used: uint64 excess_blob_gas: uint64 block_access_list: ByteList[MAX_BLOCK_ACCESS_LIST_BYTES] + slot_number: uint64 class SszDepositRequest(Container): @@ -291,6 +292,7 @@ def _payload_to_ssz( block_access_list=ByteList[MAX_BLOCK_ACCESS_LIST_BYTES]( bytes(p.block_access_list) ), + slot_number=uint64(int(p.slot_number)), ) @@ -317,6 +319,7 @@ def _ssz_to_payload( blob_gas_used=U64(sp.blob_gas_used), excess_blob_gas=U64(sp.excess_blob_gas), block_access_list=Bytes(bytes(sp.block_access_list)), + slot_number=U64(sp.slot_number), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index e1d3c7366d6..818cc2d3fc7 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -80,6 +80,7 @@ def _make_payload() -> ExecutionPayload: blob_gas_used=U64(_RNG.randint(0, 2**17)), excess_blob_gas=U64(_RNG.randint(0, 2**17)), block_access_list=Bytes(_rb(16)), + slot_number=U64(_RNG.randint(0, 2**32)), ) @@ -107,6 +108,7 @@ def _make_header() -> Header: parent_beacon_block_root=Root(_rb(32)), requests_hash=Hash32(_rb(32)), block_access_list_hash=Hash32(_rb(32)), + slot_number=U64(_RNG.randint(0, 2**32)), ) From dcf6a8120f9dbb8c69ed3268afcff111251e6e0e Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 18 May 2026 16:32:27 -0300 Subject: [PATCH 211/265] include in releases blockchain_test_engine format Signed-off-by: jsign --- .github/configs/feature.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index a64eb015679..788313c2e3a 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -20,5 +20,5 @@ bal: zkevm: evm-type: develop - fill-params: -m "blockchain_test" --fork=Amsterdam ./tests/ + fill-params: -m "blockchain_test or blockchain_test_engine" --fork=Amsterdam ./tests/ feature_only: true From 251d8e3fc6caae992e5ecc680f3165f14ae5526f Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 18 May 2026 16:51:43 -0300 Subject: [PATCH 212/265] lint --- src/ethereum/forks/amsterdam/witness_state.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index 102ca9ab393..3754b8ab08a 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -5,7 +5,7 @@ """ from dataclasses import dataclass, field -from typing import Dict, List, Optional, Tuple +from typing import AbstractSet, Dict, List, Optional, Tuple from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes32 @@ -225,6 +225,7 @@ def compute_state_root_and_trie_changes( self, account_changes: Dict[Address, Optional[Account]], storage_changes: Dict[Address, Dict[Bytes32, U256]], + storage_clears: AbstractSet[Address] = frozenset(), ) -> Tuple[Root, List[InternalNode]]: """ Compute the state root after applying changes. @@ -235,9 +236,16 @@ def compute_state_root_and_trie_changes( new_storage_roots: Dict[Address, Root] = {} for address, slots in storage_changes.items(): - if address not in self._storage_root_cache: + if ( + address not in storage_clears + and address not in self._storage_root_cache + ): self.get_account_optional(address) - old_root = self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + old_root = ( + EMPTY_TRIE_ROOT + if address in storage_clears + else self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) + ) storage_mpt: IncrementalMPT[Bytes32, U256] = decode_witness_to_mpt( self._node_db, old_root, @@ -263,7 +271,8 @@ def compute_state_root_and_trie_changes( ) ) - for address in storage_changes: + storage_touched = set(storage_changes) | set(storage_clears) + for address in storage_touched: if address not in account_changes: account = self.get_account_optional(address) if account is not None: @@ -282,6 +291,8 @@ def _sr_fn(_a: Address, _sr: Root = sr) -> Root: def get_storage_root(addr: Address) -> Root: if addr in new_storage_roots: return new_storage_roots[addr] + if addr in storage_clears: + return EMPTY_TRIE_ROOT return self._storage_root_cache.get(addr, EMPTY_TRIE_ROOT) for address, account in account_changes.items(): From 1ebc77ff69e4f926a05c9162c78f3b9360344e07 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 20 May 2026 13:08:22 -0300 Subject: [PATCH 213/265] support external t8n stateless input/output generation --- .github/workflows/benchmark-fill-parity.yaml | 134 +++++++++ .../src/execution_testing/specs/blockchain.py | 269 ++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 .github/workflows/benchmark-fill-parity.yaml diff --git a/.github/workflows/benchmark-fill-parity.yaml b/.github/workflows/benchmark-fill-parity.yaml new file mode 100644 index 00000000000..8b79b9c9fb6 --- /dev/null +++ b/.github/workflows/benchmark-fill-parity.yaml @@ -0,0 +1,134 @@ +name: Benchmark Fill Parity + +on: + pull_request: + workflow_dispatch: + inputs: + geth_repo: + description: "Geth repository to build" + required: false + type: string + default: "jsign/go-ethereum" + geth_branch: + description: "Geth branch to build" + required: false + type: string + default: "jsign-master-t8n-fill-executionwitness" + benchmark_file: + description: "Benchmark test file to fill" + required: false + type: string + default: "tests/benchmark/compute/instruction/test_memory.py" + fill_fork: + description: "Fork to fill" + required: false + type: string + default: "Amsterdam" + xdist_workers: + description: "Number of fill workers" + required: false + type: string + default: "auto" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.ref_name != github.event.repository.default_branch }} + +env: + GETH_REPO: ${{ inputs.geth_repo || 'jsign/go-ethereum' }} + GETH_BRANCH: ${{ inputs.geth_branch || 'jsign-master-t8n-fill-executionwitness' }} + BENCHMARK_FILE: ${{ inputs.benchmark_file || 'tests/benchmark/compute/instruction/test_memory.py' }} + FILL_FORK: ${{ inputs.fill_fork || 'Amsterdam' }} + XDIST_WORKERS: ${{ inputs.xdist_workers || 'auto' }} + +jobs: + compare-fills: + name: Compare Python and Geth fills + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + + - name: Setup uv + uses: ./.github/actions/setup-uv + with: + enable-cache: "false" + + - name: Install dependencies + run: uv sync --no-progress + + - name: Build Geth evm + uses: ./.github/actions/build-evm-client/geth + with: + repo: ${{ env.GETH_REPO }} + ref: ${{ env.GETH_BRANCH }} + + - name: Validate benchmark file input + shell: bash + run: | + case "$BENCHMARK_FILE" in + ./*) + echo "benchmark_file must be relative to the repository root without a leading ./" + exit 1 + ;; + tests/benchmark/*.py) + ;; + *) + echo "benchmark_file must be a Python file under tests/benchmark/" + exit 1 + ;; + esac + + if [ ! -f "$BENCHMARK_FILE" ]; then + echo "benchmark_file does not exist: $BENCHMARK_FILE" + exit 1 + fi + + - name: Prepare fill directories + run: mkdir -p fill_tmp/python fill_tmp/geth fill_logs/python fill_logs/geth + + - name: Fill benchmark with Python spec + run: | + uv run fill \ + --clean \ + --gas-benchmark-values 1 \ + --fork "$FILL_FORK" \ + -m blockchain_test \ + -n "$XDIST_WORKERS" \ + --output=fixtures_python \ + --no-html \ + --basetemp=fill_tmp/python \ + --log-to fill_logs/python \ + "$BENCHMARK_FILE" + + - name: Fill benchmark with Geth + run: | + uv run fill \ + --clean \ + --gas-benchmark-values 1 \ + --fork "$FILL_FORK" \ + --evm-bin=evm \ + -m blockchain_test \ + -n "$XDIST_WORKERS" \ + --output=fixtures_geth \ + --no-html \ + --basetemp=fill_tmp/geth \ + --log-to fill_logs/geth \ + "$BENCHMARK_FILE" + + - name: Compare fixture hashes + run: uv run hasher compare fixtures_python fixtures_geth + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: benchmark-fill-parity-debug + path: | + fixtures_python/ + fixtures_geth/ + fill_logs/ + if-no-files-found: ignore diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index f6f6bed2456..857ed054d88 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -372,6 +372,249 @@ def verify_stateless_input_public_keys( ) +def _decode_amsterdam_header_bytes(header_rlp: Bytes) -> Any | None: + """ + Decode an Amsterdam or immediate pre-Amsterdam RLP header. + """ + from ethereum.forks.amsterdam.stateless import _decode_header + from ethereum_types.bytes import Bytes as AmsterdamBytes + + try: + return _decode_header(AmsterdamBytes(bytes(header_rlp))) + except Exception: + return None + + +def _convert_amsterdam_execution_witness( + execution_witness: ExecutionWitness, +) -> Any: + """ + Convert fixture execution witness data to Amsterdam fork types. + """ + from ethereum.forks.amsterdam.stateless import ( + ExecutionWitness as AmsterdamExecutionWitness, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + return AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) + + +def _convert_amsterdam_withdrawals( + withdrawals: List[Withdrawal] | None, +) -> Any: + """ + Convert fixture withdrawals to Amsterdam fork withdrawals. + """ + from ethereum.forks.amsterdam.blocks import ( + Withdrawal as AmsterdamWithdrawal, + ) + from ethereum.state import Address as AmsterdamAddress + from ethereum_types.numeric import U64, U256 + + if withdrawals is None: + return () + return tuple( + AmsterdamWithdrawal( + index=U64(int(withdrawal.index)), + validator_index=U64(int(withdrawal.validator_index)), + address=AmsterdamAddress(bytes(withdrawal.address)), + amount=U256(int(withdrawal.amount)), + ) + for withdrawal in withdrawals + ) + + +def _convert_amsterdam_block_access_list( + block_access_list: BlockAccessList, +) -> Any: + """ + Convert fixture BAL data to Amsterdam fork BAL data. + """ + from ethereum.forks.amsterdam.block_access_lists import ( + AccountChanges, + BalanceChange, + CodeChange, + NonceChange, + SlotChanges, + StorageChange, + ) + from ethereum.state import Address as AmsterdamAddress + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U32, U64, U256 + + return [ + AccountChanges( + address=AmsterdamAddress(bytes(account.address)), + storage_changes=tuple( + SlotChanges( + slot=U256(int(slot.slot)), + changes=tuple( + StorageChange( + block_access_index=U32( + int(change.block_access_index) + ), + new_value=U256(int(change.post_value)), + ) + for change in slot.slot_changes + ), + ) + for slot in account.storage_changes + ), + storage_reads=tuple( + U256(int(slot)) for slot in account.storage_reads + ), + balance_changes=tuple( + BalanceChange( + block_access_index=U32(int(change.block_access_index)), + post_balance=U256(int(change.post_balance)), + ) + for change in account.balance_changes + ), + nonce_changes=tuple( + NonceChange( + block_access_index=U32(int(change.block_access_index)), + new_nonce=U64(int(change.post_nonce)), + ) + for change in account.nonce_changes + ), + code_changes=tuple( + CodeChange( + block_access_index=U32(int(change.block_access_index)), + new_code=AmsterdamBytes(bytes(change.new_code)), + ) + for change in account.code_changes + ), + ) + for account in block_access_list.root + ] + + +def build_amsterdam_stateless_artifacts_from_t8n( + *, + fork: Fork, + block_number: int, + timestamp: int, + header: FixtureHeader, + previous_env: Environment, + txs: List[Transaction], + result: Result, + withdrawals: List[Withdrawal] | None, + requests_list: List[Bytes] | None, + execution_witness: ExecutionWitness, + block_access_list: BlockAccessList, + chain_id: int, +) -> tuple[Bytes, Bytes] | None: + """ + Build Amsterdam stateless input/output bytes from t8n witness artifacts. + + Returns ``None`` when the finalized request list cannot be decoded into + the Amsterdam request container, matching the existing EELS t8n behavior. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam" or block_number == 0: + return None + + from ethereum.forks.amsterdam.blocks import ( + Block as AmsterdamBlock, + ) + from ethereum.forks.amsterdam.blocks import ( + Header as AmsterdamHeader, + ) + from ethereum.forks.amsterdam.execution_engine.requests import ( + decode_execution_requests, + ) + from ethereum.forks.amsterdam.stateless import ( + StatelessValidationResult, + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + serialize_stateless_output, + ) + from ethereum.forks.amsterdam.stateless_host import ( + build_chain_config, + build_stateless_input, + serialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U64 + + parent_number = ZeroPaddedHexNumber(block_number - 1) + parent_header_rlp = previous_env.block_headers.get(parent_number) + if parent_header_rlp is None: + return None + parent_header = _decode_amsterdam_header_bytes(parent_header_rlp) + if parent_header is None: + return None + if Hash(parent_header_rlp.keccak256()) != header.parent_hash: + return None + + current_header = _decode_amsterdam_header_bytes(header.rlp) + if not isinstance(current_header, AmsterdamHeader): + return None + + try: + execution_requests = decode_execution_requests( + tuple( + AmsterdamBytes(bytes(request)) + for request in requests_list or [] + ) + ) + except Exception: + return None + + rejected_indices = { + int(rejected.index) for rejected in result.rejected_transactions + } + accepted_txs = tuple( + AmsterdamBytes(bytes(tx.rlp())) + for index, tx in enumerate(txs) + if index not in rejected_indices + ) + block = AmsterdamBlock( + header=current_header, + transactions=accepted_txs, + ommers=(), + withdrawals=_convert_amsterdam_withdrawals(withdrawals), + ) + stateless_input = build_stateless_input( + block, + execution_witness=_convert_amsterdam_execution_witness( + execution_witness + ), + execution_requests=execution_requests, + block_access_list=_convert_amsterdam_block_access_list( + block_access_list + ), + chain_id=U64(chain_id), + ) + stateless_input_bytes = serialize_stateless_input(stateless_input) + # TODO: Replace this shortcut with a client-provided validation result + # or a real stateless guest run once Geth witness generation is complete. + stateless_output = StatelessValidationResult( + new_payload_request_root=compute_new_payload_request_root( + stateless_input + ), + successful_validation=True, + chain_config=build_chain_config(U64(chain_id)), + ) + stateless_output_bytes = serialize_stateless_output(stateless_output) + return ( + Bytes(bytes(stateless_input_bytes)), + Bytes(bytes(stateless_output_bytes)), + ) + + def decode_amsterdam_stateless_output( *, fork: Fork, @@ -1281,6 +1524,32 @@ def generate_block_data( stateless_output_bytes = ( transition_tool_output.result.stateless_output_bytes ) + if ( + not skip_stateless_for_block + and t8n_witness is not None + and bal is not None + and ( + stateless_input_bytes is None or stateless_output_bytes is None + ) + ): + stateless_artifacts = build_amsterdam_stateless_artifacts_from_t8n( + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + header=header, + previous_env=previous_env, + txs=txs, + result=transition_tool_output.result, + withdrawals=env.withdrawals, + requests_list=requests_list, + execution_witness=t8n_witness, + block_access_list=bal, + chain_id=self.chain_id, + ) + if stateless_artifacts is not None: + stateless_input_bytes, stateless_output_bytes = ( + stateless_artifacts + ) stateless_output = decode_amsterdam_stateless_output( fork=fork, block_number=int(env.number), From 45583d6222ebae45fbd17305609a9fd1ad48815a Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 26 May 2026 10:29:43 -0300 Subject: [PATCH 214/265] stateless: properly handle invalid ssz-bytes input with sentinel value --- src/ethereum/forks/amsterdam/stateless.py | 21 ++++++++++- .../forks/amsterdam/stateless_guest.py | 36 +++++++++++++++++-- tests/json_loader/test_stateless_guest.py | 18 ++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 8d103f86118..83cc9806ca7 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -219,13 +219,32 @@ class StatelessValidationResult: """ Result returned by stateless validation. - Note: We use return values to denote "public inputs" + Note: We use return values to denote "public inputs". + + If ``successful_validation`` is ``False``, the remaining fields are + only meaningful when the input bytes were decoded successfully. If + decoding failed before a ``StatelessInput`` existed, + ``run_stateless_guest`` returns a failed result with sentinel defaults. """ new_payload_request_root: Hash32 + """ + SSZ root of the decoded ``NewPayloadRequest``. This is zero when + ``run_stateless_guest`` cannot decode the input bytes. + """ + successful_validation: bool + """ + Whether the decoded stateless input validated successfully. ``False`` + means validation failed or the guest input bytes could not be decoded. + """ + chain_config: ChainConfig + """ + Chain configuration decoded from the input. This is a sentinel default + when ``run_stateless_guest`` cannot decode the input bytes. + """ def compute_new_payload_request_root( diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 529ab8edcea..5fa01bed508 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -3,8 +3,15 @@ """ from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64 + +from ethereum.crypto.hash import Hash32 from .stateless import ( + ChainConfig, + ForkActivation, + ForkConfig, + ProtocolFork, StatelessInput, StatelessValidationResult, verify_stateless_new_payload, @@ -44,12 +51,37 @@ def deserialize_stateless_input(data: Bytes) -> StatelessInput: return ssz_to_stateless_input(ssz_obj) +def _default_failed_stateless_output() -> StatelessValidationResult: + """ + Return the sentinel result used when stateless input cannot be decoded. + """ + return StatelessValidationResult( + new_payload_request_root=Hash32(b"\0" * 32), + successful_validation=False, + chain_config=ChainConfig( + chain_id=U64(0), + active_fork=ForkConfig( + fork=ProtocolFork.Frontier, + activation=ForkActivation( + block_number=None, + timestamp=None, + ), + blob_schedule=None, + ), + ), + ) + + def run_stateless_guest(input_bytes: Bytes) -> Bytes: """ Run the stateless guest with serialized input, return serialized output. """ - stateless_input = deserialize_stateless_input(input_bytes) - stateless_output = verify_stateless_new_payload(stateless_input) + try: + stateless_input = deserialize_stateless_input(input_bytes) + except Exception: + stateless_output = _default_failed_stateless_output() + else: + stateless_output = verify_stateless_new_payload(stateless_input) output_bytes = serialize_stateless_output(stateless_output) return output_bytes diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 818cc2d3fc7..ccd06fc6ed0 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -33,6 +33,7 @@ ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, + run_stateless_guest, serialize_stateless_output, ) from ethereum.forks.amsterdam.stateless_host import ( @@ -342,6 +343,23 @@ def test_failed_validation(self) -> None: assert recovered == original +class TestRunStatelessGuest: + """Test stateless guest input and output handling.""" + + def test_invalid_input_bytes_return_failed_validation(self) -> None: + """Malformed input returns a failed result with sentinel fields.""" + encoded = run_stateless_guest(Bytes(b"")) + result = deserialize_stateless_output(encoded) + + assert result.new_payload_request_root == Hash32(b"\0" * 32) + assert not result.successful_validation + assert result.chain_config.chain_id == U64(0) + assert result.chain_config.active_fork.fork == ProtocolFork.Frontier + assert result.chain_config.active_fork.activation.block_number is None + assert result.chain_config.active_fork.activation.timestamp is None + assert result.chain_config.active_fork.blob_schedule is None + + class TestComputeNewPayloadRequestRoot: """Test compute_new_payload_request_root.""" From ac9961fd833046f9364a502767479985cdc17884 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 26 May 2026 11:57:34 -0300 Subject: [PATCH 215/265] add support for stateless bytes input modifier --- .../src/execution_testing/specs/blockchain.py | 144 +++++++++++++++--- 1 file changed, 123 insertions(+), 21 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 857ed054d88..cec9235dcd7 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -295,6 +295,39 @@ def rerun_amsterdam_stateless_guest_with_overrides( ) +def rerun_amsterdam_stateless_guest_with_input_bytes( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> tuple[Bytes, Bytes, bool]: + """ + Rerun the Amsterdam stateless guest with raw stateless input bytes. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless guest raw input rerun is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_guest import run_stateless_guest + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + guest_input_bytes = AmsterdamBytes(bytes(stateless_input_bytes)) + stateless_output_bytes = run_stateless_guest(guest_input_bytes) + stateless_output = deserialize_stateless_output(stateless_output_bytes) + + return ( + Bytes(bytes(guest_input_bytes)), + Bytes(bytes(stateless_output_bytes)), + stateless_output.successful_validation, + ) + + def get_amsterdam_stateless_input_public_key_data( *, fork: Fork, @@ -666,6 +699,27 @@ def assert_amsterdam_stateless_output_chain_config( ) +def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: + """ + Return whether output is the invalid stateless input sentinel. + """ + from ethereum.forks.amsterdam.stateless import ProtocolFork + from ethereum_types.numeric import U64 + + chain_config = stateless_output.chain_config + active_fork = chain_config.active_fork + activation = active_fork.activation + return ( + not stateless_output.successful_validation + and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 + and chain_config.chain_id == U64(0) + and active_fork.fork == ProtocolFork.Frontier + and activation.block_number is None + and activation.timestamp is None + and active_fork.blob_schedule is None + ) + + class Header(CamelModel): """Header type used to describe block header properties in test specs.""" @@ -829,6 +883,14 @@ class Block(Header): If set, mutate the stateless input transaction public keys before rerunning the guest for invalid tests. """ + stateless_input_bytes_modifier: Callable[[Bytes], Bytes] | None = Field( + default=None, + exclude=True, + ) + """ + If set, mutate the serialized stateless input bytes before rerunning the + guest for invalid tests. + """ expected_stateless_validation_success: bool | None = None """ If set, assert the stateless guest result matches this expectation. This @@ -994,10 +1056,10 @@ def get_fixture_block( if self.execution_witness else None, stateless_input_bytes=self.stateless_input_bytes - if self.stateless_input_bytes + if self.stateless_input_bytes is not None else None, stateless_output_bytes=self.stateless_output_bytes - if self.stateless_output_bytes + if self.stateless_output_bytes is not None else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -1286,11 +1348,16 @@ def generate_block_data( ) public_keys_modifier = block.stateless_input_public_keys_modifier has_public_keys_modifier = public_keys_modifier is not None + stateless_input_bytes_modifier = block.stateless_input_bytes_modifier + has_stateless_input_bytes_modifier = ( + stateless_input_bytes_modifier is not None + ) expected_success = block.expected_stateless_validation_success omit_stateless_artifacts = block.rlp_modifier is not None if omit_stateless_artifacts and ( has_witness_expectation or has_public_keys_modifier + or has_stateless_input_bytes_modifier or expected_success is not None ): raise AssertionError( @@ -1301,12 +1368,13 @@ def generate_block_data( if self.skip_stateless_validation and ( has_witness_expectation or has_public_keys_modifier + or has_stateless_input_bytes_modifier or expected_success is not None ): raise AssertionError( "skip_stateless_validation cannot be combined with " "execution witness expectations, stateless input public-key " - "modifiers, or " + "modifiers, stateless input byte modifiers, or " "expected_stateless_validation_success" ) skip_stateless_for_block = ( @@ -1566,6 +1634,11 @@ def generate_block_data( "Mutated stateless input public-key tests must set " "expected_stateless_validation_success explicitly" ) + if has_stateless_input_bytes_modifier and expected_success is None: + raise AssertionError( + "Mutated stateless input byte tests must set " + "expected_stateless_validation_success explicitly" + ) public_keys: Tuple[Bytes, ...] | None = None should_verify_stateless_input_public_keys = ( stateless_input_bytes is not None @@ -1618,10 +1691,11 @@ def generate_block_data( stateless_output.successful_validation ) - should_rerun_stateless_guest = ( + should_rerun_structured_stateless_guest = ( has_witness_modifier or has_public_keys_modifier ) - if should_rerun_stateless_guest: + final_successful_validation = canonical_successful_validation + if should_rerun_structured_stateless_guest: if stateless_input_bytes is None: raise Exception( "Stateless guest rerun requires stateless input bytes" @@ -1658,30 +1732,58 @@ def generate_block_data( timestamp=int(env.timestamp), stateless_output_bytes=stateless_output_bytes, ) - if ( - expected_success is not None - and successful_validation != expected_success - ): - raise AssertionError( - "Stateless guest validation result mismatch: " - f"got {successful_validation}, " - f"want {expected_success}" + final_successful_validation = successful_validation + + if has_stateless_input_bytes_modifier: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest raw input rerun requires stateless " + "input bytes" ) - elif ( + if stateless_input_bytes_modifier is None: + raise Exception("Stateless input bytes modifier is required") + stateless_input_bytes = stateless_input_bytes_modifier( + stateless_input_bytes + ) + ( + stateless_input_bytes, + stateless_output_bytes, + successful_validation, + ) = rerun_amsterdam_stateless_guest_with_input_bytes( + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + stateless_input_bytes=stateless_input_bytes, + ) + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + stateless_output_bytes=stateless_output_bytes, + ) + final_successful_validation = successful_validation + + if ( expected_success is not None - and canonical_successful_validation != expected_success + and final_successful_validation != expected_success ): raise AssertionError( - "Canonical stateless guest validation result mismatch: " - f"got {canonical_successful_validation}, " + "Stateless guest validation result mismatch: " + f"got {final_successful_validation}, " f"want {expected_success}" ) - assert_amsterdam_stateless_output_chain_config( - block_number=int(env.number), - chain_id=self.chain_id, - stateless_output=stateless_output, + skip_chain_config_assertion = ( + has_stateless_input_bytes_modifier + and stateless_output is not None + and is_invalid_stateless_input_sentinel(stateless_output) ) + if not skip_chain_config_assertion: + assert_amsterdam_stateless_output_chain_config( + block_number=int(env.number), + chain_id=self.chain_id, + stateless_output=stateless_output, + ) built_block = BuiltBlock( header=header, From 09fbf0327eb43c0c9bcb19b128dd77972e8b231a Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 26 May 2026 13:48:48 -0300 Subject: [PATCH 216/265] add invalid stateless input tests --- .../test_stateless_input_bytes.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py new file mode 100644 index 00000000000..6b1d4c79007 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -0,0 +1,102 @@ +"""Stateless input byte validation tests.""" + +from typing import Callable + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +StatelessInputBytesModifier = Callable[[Bytes], Bytes] + + +def empty_input_bytes(input_bytes: Bytes) -> Bytes: + """Replace stateless input bytes with empty input.""" + del input_bytes + return Bytes(b"") + + +def incomplete_schema_id(input_bytes: Bytes) -> Bytes: + """Keep only one byte of the schema id.""" + return Bytes(input_bytes[:1]) + + +def unsupported_schema_id(input_bytes: Bytes) -> Bytes: + """Replace the schema id while keeping the SSZ body.""" + return Bytes(b"\x00\x02" + input_bytes[2:]) + + +def missing_ssz_body(input_bytes: Bytes) -> Bytes: + """Keep only the schema id.""" + return Bytes(input_bytes[:2]) + + +def truncated_ssz_body(input_bytes: Bytes) -> Bytes: + """Drop the final SSZ body byte.""" + return Bytes(input_bytes[:-1]) + + +def trailing_garbage(input_bytes: Bytes) -> Bytes: + """Append extra bytes after the SSZ body.""" + return Bytes(input_bytes + b"\x00") + + +def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: + """ + Corrupt the first SSZ container offset. + + The stateless input starts with a 2-byte schema id, followed by the + SSZ-encoded ``SszStatelessInput`` container. Its first four SSZ bytes + encode the offset to the first variable-size field. Setting that offset + to 1 makes it point inside the fixed-size section, so the SSZ decoder + must reject the input before stateless validation can run. + """ + return Bytes(input_bytes[:2] + b"\x01\x00\x00\x00" + input_bytes[6:]) + + +@pytest.mark.parametrize( + "modifier", + [ + pytest.param(empty_input_bytes, id="empty_input_bytes"), + pytest.param(incomplete_schema_id, id="incomplete_schema_id"), + pytest.param(unsupported_schema_id, id="unsupported_schema_id"), + pytest.param(missing_ssz_body, id="missing_ssz_body"), + pytest.param(truncated_ssz_body, id="truncated_ssz_body"), + pytest.param(trailing_garbage, id="trailing_garbage"), + pytest.param(invalid_first_ssz_offset, id="invalid_first_ssz_offset"), + ], +) +def test_invalid_stateless_input_bytes_are_rejected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + modifier: StatelessInputBytesModifier, +) -> None: + """Invalid stateless input bytes fail guest validation.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_bytes_modifier=modifier, + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) From 85f779b96292183abb403d1de580f0db061e7c22 Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Tue, 26 May 2026 17:47:32 -0300 Subject: [PATCH 217/265] refactor: update SSZ max-length constants and improve comments for clarity --- src/ethereum/forks/amsterdam/stateless_ssz.py | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 928644c6e03..4f28b614cf2 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -40,6 +40,9 @@ # --- SSZ max-length constants --- +# Consensus-spec bounds mirrored by NewPayloadRequest and its nested +# containers. These should track the corresponding consensus-spec values. + MAX_EXTRA_DATA_BYTES = 32 MAX_BYTES_PER_TRANSACTION = 2**30 MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 @@ -48,16 +51,37 @@ MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**13 MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = 2**4 MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD = 2**1 -MAX_BLOCK_ACCESS_LIST_BYTES = 2**24 -MAX_WITNESS_NODES = 2**20 -MAX_WITNESS_CODES = 2**16 + +# Stateless witness resource bounds. These are local limits chosen for this +# schema, not consensus-spec SSZ constants. + +# BAL permits block_gas_limit // 2_000 items. At 500M gas, that is +# 250_000 account accesses; budgeting 16 witness nodes per account gives +# 4_000_000 nodes, rounded up to 2**22. This targets an account-heavy +# witness, since deep storage tries are harder to construct. Depth of +# 16 is around double the depth of mainnet account trie. +MAX_WITNESS_NODES = 2**22 + +# Enough room for one pre-state bytecode read per BAL item at 500M gas: +# 500_000_000 // 2_000 = 250_000, rounded up to 2**18. +MAX_WITNESS_CODES = 2**18 + +# Execution only exposes the previous 256 block hashes. MAX_WITNESS_HEADERS = 256 -MAX_BYTES_PER_WITNESS_NODE = 2**20 -MAX_BYTES_PER_CODE = 2**24 +# As defined in EIP-7954. +MAX_BYTES_PER_CODE = 2**16 MAX_BYTES_PER_HEADER = 2**10 +# Full secured-trie branch nodes are 532 bytes when all 16 children are +# represented by hashes, which is the largest normal state witness node. +# 2**10 is the next power of two, with almost twice the needed capacity. +MAX_BYTES_PER_WITNESS_NODE = 2**10 + MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 MAX_BLOB_SCHEDULES_PER_FORK = 1 -MAX_PUBLIC_KEYS = 2**20 +# One public key is supplied per transaction. Every valid transaction consumes +# at least 21_000 gas, so a 500M gas block can contain at most +# 500_000_000 // 21_000 = 23_809 transactions, rounded up to next power of 2. +MAX_PUBLIC_KEYS = 2**15 PUBLIC_KEY_BYTES = 65 # Amsterdam SSZ stateless input schema identifier. @@ -108,7 +132,7 @@ class SszExecutionPayload(Container): withdrawals: SszList[SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] blob_gas_used: uint64 excess_blob_gas: uint64 - block_access_list: ByteList[MAX_BLOCK_ACCESS_LIST_BYTES] + block_access_list: ByteList[MAX_BYTES_PER_TRANSACTION] slot_number: uint64 @@ -289,7 +313,7 @@ def _payload_to_ssz( ), blob_gas_used=uint64(int(p.blob_gas_used)), excess_blob_gas=uint64(int(p.excess_blob_gas)), - block_access_list=ByteList[MAX_BLOCK_ACCESS_LIST_BYTES]( + block_access_list=ByteList[MAX_BYTES_PER_TRANSACTION]( bytes(p.block_access_list) ), slot_number=uint64(int(p.slot_number)), From 049f416582ecada6fe5793f1511a19aee17ffa1d Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 27 May 2026 09:51:29 -0300 Subject: [PATCH 218/265] prune protocol fork variants Signed-off-by: jsign --- src/ethereum/forks/amsterdam/stateless.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 83cc9806ca7..235ec4d4c21 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -91,7 +91,6 @@ class ProtocolFork(StrEnum): TangerineWhistle = "TangerineWhistle" SpuriousDragon = "SpuriousDragon" Byzantium = "Byzantium" - Constantinople = "Constantinople" ConstantinopleFix = "ConstantinopleFix" Istanbul = "Istanbul" MuirGlacier = "MuirGlacier" @@ -106,9 +105,6 @@ class ProtocolFork(StrEnum): Osaka = "Osaka" BPO1 = "BPO1" BPO2 = "BPO2" - BPO3 = "BPO3" - BPO4 = "BPO4" - BPO5 = "BPO5" Amsterdam = "Amsterdam" From 215249bde87882dfbc8287e0c33309632fa089bb Mon Sep 17 00:00:00 2001 From: Ignacio Hagopian Date: Wed, 27 May 2026 12:46:23 -0300 Subject: [PATCH 219/265] Update src/ethereum/forks/amsterdam/stateless.py Co-authored-by: Ruben Buniatyan --- src/ethereum/forks/amsterdam/stateless.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 235ec4d4c21..df3465fd549 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -91,7 +91,7 @@ class ProtocolFork(StrEnum): TangerineWhistle = "TangerineWhistle" SpuriousDragon = "SpuriousDragon" Byzantium = "Byzantium" - ConstantinopleFix = "ConstantinopleFix" + StPetersburg = "StPetersburg" Istanbul = "Istanbul" MuirGlacier = "MuirGlacier" Berlin = "Berlin" From 5b4aae0150844ef345e2a9c388ac7ff4595446fb Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 27 May 2026 13:22:02 -0300 Subject: [PATCH 220/265] blockchain stateless refactor Signed-off-by: jsign --- .../src/execution_testing/specs/blockchain.py | 906 +------------- .../specs/blockchain_stateless.py | 1054 +++++++++++++++++ 2 files changed, 1099 insertions(+), 861 deletions(-) create mode 100644 packages/testing/src/execution_testing/specs/blockchain_stateless.py diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index cec9235dcd7..d2520a61eff 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -102,6 +102,12 @@ ) from .base import BaseTest, FillResult, OpMode, verify_result +from .blockchain_stateless import ( + apply_execution_witness_expectations, + finalize_stateless_artifacts, + stateless_artifacts_from_t8n, + stateless_options_for_block, +) from .debugging import print_traces from .helpers import verify_block, verify_transactions @@ -155,571 +161,6 @@ def count_blobs(txs: List[Transaction]) -> int: ) -def execution_witness_implicit_codes_for_block( - *, - fork: Fork, - alloc: Alloc | LazyAlloc, - block_number: int, - timestamp: int, -) -> List[Bytes]: - """ - Return ambient witness bytecodes implied by block-level execution. - - These codes are resolved from the effective pre-state for the block, not - from raw fork defaults, so test `pre` overrides are respected. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - addresses = active_fork.execution_witness_implicit_code_addresses( - block_number=block_number, - timestamp=timestamp, - ) - if not addresses: - return [] - - effective_alloc = alloc.get() if isinstance(alloc, LazyAlloc) else alloc - - codes: List[Bytes] = [] - seen: set[Bytes] = set() - for address in addresses: - if address not in effective_alloc: - continue - account = effective_alloc[address] - if account is None or len(account.code) == 0: - continue - code = Bytes(account.code) - if code in seen: - continue - codes.append(code) - seen.add(code) - return codes - - -def with_execution_witness_implicit_codes( - *, - expectation: ExecutionWitnessCodesExpectation, - fork: Fork, - alloc: Alloc | LazyAlloc, - block_number: int, - timestamp: int, -) -> ExecutionWitnessCodesExpectation: - """Return expectation copy with ambient block-level codes added.""" - codes_present = list(expectation.codes_present) - seen = set(codes_present) - - for code in execution_witness_implicit_codes_for_block( - fork=fork, - alloc=alloc, - block_number=block_number, - timestamp=timestamp, - ): - if code in seen: - continue - codes_present.append(code) - seen.add(code) - - return expectation.model_copy(update={"codes_present": codes_present}) - - -def rerun_amsterdam_stateless_guest_with_overrides( - *, - fork: Fork, - block_number: int, - timestamp: int, - original_stateless_input_bytes: Bytes, - execution_witness: ExecutionWitness | None = None, - public_keys: Tuple[Bytes, ...] | None = None, -) -> tuple[Bytes, Bytes, bool]: - """ - Rebuild the stateless input with test overrides and rerun the guest. - - Amsterdam is currently the only fork with stateless guest support in this - repository, so the rerun path is kept Amsterdam-specific. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam": - raise Exception( - "Execution witness guest rerun is only supported for Amsterdam" - ) - - from ethereum.forks.amsterdam.stateless import ( - ExecutionWitness as AmsterdamExecutionWitness, - ) - from ethereum.forks.amsterdam.stateless import ( - StatelessInput as AmsterdamStatelessInput, - ) - from ethereum.forks.amsterdam.stateless_guest import ( - deserialize_stateless_input, - run_stateless_guest, - ) - from ethereum.forks.amsterdam.stateless_host import ( - deserialize_stateless_output, - serialize_stateless_input, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - - original_input = deserialize_stateless_input( - AmsterdamBytes(bytes(original_stateless_input_bytes)) - ) - rebuilt_witness = original_input.witness - if execution_witness is not None: - rebuilt_witness = AmsterdamExecutionWitness( - state=tuple( - AmsterdamBytes(bytes(node)) for node in execution_witness.state - ), - codes=tuple( - AmsterdamBytes(bytes(code)) for code in execution_witness.codes - ), - headers=tuple( - AmsterdamBytes(bytes(header)) - for header in execution_witness.headers - ), - ) - rebuilt_input = AmsterdamStatelessInput( - new_payload_request=original_input.new_payload_request, - witness=rebuilt_witness, - chain_config=original_input.chain_config, - public_keys=( - tuple(AmsterdamBytes(bytes(key)) for key in public_keys) - if public_keys is not None - else original_input.public_keys - ), - ) - rebuilt_input_bytes = serialize_stateless_input(rebuilt_input) - rebuilt_output_bytes = run_stateless_guest(rebuilt_input_bytes) - rebuilt_output = deserialize_stateless_output(rebuilt_output_bytes) - - return ( - Bytes(bytes(rebuilt_input_bytes)), - Bytes(bytes(rebuilt_output_bytes)), - rebuilt_output.successful_validation, - ) - - -def rerun_amsterdam_stateless_guest_with_input_bytes( - *, - fork: Fork, - block_number: int, - timestamp: int, - stateless_input_bytes: Bytes, -) -> tuple[Bytes, Bytes, bool]: - """ - Rerun the Amsterdam stateless guest with raw stateless input bytes. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam": - raise Exception( - "Stateless guest raw input rerun is only supported for Amsterdam" - ) - - from ethereum.forks.amsterdam.stateless_guest import run_stateless_guest - from ethereum.forks.amsterdam.stateless_host import ( - deserialize_stateless_output, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - - guest_input_bytes = AmsterdamBytes(bytes(stateless_input_bytes)) - stateless_output_bytes = run_stateless_guest(guest_input_bytes) - stateless_output = deserialize_stateless_output(stateless_output_bytes) - - return ( - Bytes(bytes(guest_input_bytes)), - Bytes(bytes(stateless_output_bytes)), - stateless_output.successful_validation, - ) - - -def get_amsterdam_stateless_input_public_key_data( - *, - fork: Fork, - block_number: int, - timestamp: int, - stateless_input_bytes: Bytes, -) -> tuple[Tuple[Bytes, ...], Tuple[Bytes, ...]]: - """ - Decode Amsterdam stateless input public keys and payload transactions. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam": - raise Exception( - "Stateless input public-key decoding is only supported for " - "Amsterdam" - ) - - from ethereum.forks.amsterdam.stateless_guest import ( - deserialize_stateless_input, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - - stateless_input = deserialize_stateless_input( - AmsterdamBytes(bytes(stateless_input_bytes)) - ) - public_keys = tuple( - Bytes(bytes(public_key)) for public_key in stateless_input.public_keys - ) - payload_transactions = tuple( - Bytes(bytes(transaction)) - for transaction in ( - stateless_input.new_payload_request.execution_payload.transactions - ) - ) - return public_keys, payload_transactions - - -def verify_stateless_input_public_keys( - public_keys: Tuple[Bytes, ...], - payload_transactions: Tuple[Bytes, ...], - chain_id: int, -) -> None: - """ - Verify that every payload transaction has its recovered public key. - """ - payload_transaction_count = len(payload_transactions) - if len(public_keys) != payload_transaction_count: - raise AssertionError( - "Stateless input public key count does not match payload " - f"transactions: got {len(public_keys)} public keys for " - f"{payload_transaction_count} transactions" - ) - - from ethereum.forks.amsterdam.transactions import ( - decode_transaction, - recover_transaction_public_key, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - from ethereum_types.numeric import U64 - - for index, (public_key, payload_transaction) in enumerate( - zip(public_keys, payload_transactions, strict=True) - ): - transaction = decode_transaction( - AmsterdamBytes(bytes(payload_transaction)) - ) - expected_public_key = recover_transaction_public_key( - U64(chain_id), - transaction, - ) - if bytes(public_key) != bytes(expected_public_key): - raise AssertionError( - "Stateless input public key " - f"{index} does not match recovered transaction public key" - ) - - -def _decode_amsterdam_header_bytes(header_rlp: Bytes) -> Any | None: - """ - Decode an Amsterdam or immediate pre-Amsterdam RLP header. - """ - from ethereum.forks.amsterdam.stateless import _decode_header - from ethereum_types.bytes import Bytes as AmsterdamBytes - - try: - return _decode_header(AmsterdamBytes(bytes(header_rlp))) - except Exception: - return None - - -def _convert_amsterdam_execution_witness( - execution_witness: ExecutionWitness, -) -> Any: - """ - Convert fixture execution witness data to Amsterdam fork types. - """ - from ethereum.forks.amsterdam.stateless import ( - ExecutionWitness as AmsterdamExecutionWitness, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - - return AmsterdamExecutionWitness( - state=tuple( - AmsterdamBytes(bytes(node)) for node in execution_witness.state - ), - codes=tuple( - AmsterdamBytes(bytes(code)) for code in execution_witness.codes - ), - headers=tuple( - AmsterdamBytes(bytes(header)) - for header in execution_witness.headers - ), - ) - - -def _convert_amsterdam_withdrawals( - withdrawals: List[Withdrawal] | None, -) -> Any: - """ - Convert fixture withdrawals to Amsterdam fork withdrawals. - """ - from ethereum.forks.amsterdam.blocks import ( - Withdrawal as AmsterdamWithdrawal, - ) - from ethereum.state import Address as AmsterdamAddress - from ethereum_types.numeric import U64, U256 - - if withdrawals is None: - return () - return tuple( - AmsterdamWithdrawal( - index=U64(int(withdrawal.index)), - validator_index=U64(int(withdrawal.validator_index)), - address=AmsterdamAddress(bytes(withdrawal.address)), - amount=U256(int(withdrawal.amount)), - ) - for withdrawal in withdrawals - ) - - -def _convert_amsterdam_block_access_list( - block_access_list: BlockAccessList, -) -> Any: - """ - Convert fixture BAL data to Amsterdam fork BAL data. - """ - from ethereum.forks.amsterdam.block_access_lists import ( - AccountChanges, - BalanceChange, - CodeChange, - NonceChange, - SlotChanges, - StorageChange, - ) - from ethereum.state import Address as AmsterdamAddress - from ethereum_types.bytes import Bytes as AmsterdamBytes - from ethereum_types.numeric import U32, U64, U256 - - return [ - AccountChanges( - address=AmsterdamAddress(bytes(account.address)), - storage_changes=tuple( - SlotChanges( - slot=U256(int(slot.slot)), - changes=tuple( - StorageChange( - block_access_index=U32( - int(change.block_access_index) - ), - new_value=U256(int(change.post_value)), - ) - for change in slot.slot_changes - ), - ) - for slot in account.storage_changes - ), - storage_reads=tuple( - U256(int(slot)) for slot in account.storage_reads - ), - balance_changes=tuple( - BalanceChange( - block_access_index=U32(int(change.block_access_index)), - post_balance=U256(int(change.post_balance)), - ) - for change in account.balance_changes - ), - nonce_changes=tuple( - NonceChange( - block_access_index=U32(int(change.block_access_index)), - new_nonce=U64(int(change.post_nonce)), - ) - for change in account.nonce_changes - ), - code_changes=tuple( - CodeChange( - block_access_index=U32(int(change.block_access_index)), - new_code=AmsterdamBytes(bytes(change.new_code)), - ) - for change in account.code_changes - ), - ) - for account in block_access_list.root - ] - - -def build_amsterdam_stateless_artifacts_from_t8n( - *, - fork: Fork, - block_number: int, - timestamp: int, - header: FixtureHeader, - previous_env: Environment, - txs: List[Transaction], - result: Result, - withdrawals: List[Withdrawal] | None, - requests_list: List[Bytes] | None, - execution_witness: ExecutionWitness, - block_access_list: BlockAccessList, - chain_id: int, -) -> tuple[Bytes, Bytes] | None: - """ - Build Amsterdam stateless input/output bytes from t8n witness artifacts. - - Returns ``None`` when the finalized request list cannot be decoded into - the Amsterdam request container, matching the existing EELS t8n behavior. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam" or block_number == 0: - return None - - from ethereum.forks.amsterdam.blocks import ( - Block as AmsterdamBlock, - ) - from ethereum.forks.amsterdam.blocks import ( - Header as AmsterdamHeader, - ) - from ethereum.forks.amsterdam.execution_engine.requests import ( - decode_execution_requests, - ) - from ethereum.forks.amsterdam.stateless import ( - StatelessValidationResult, - compute_new_payload_request_root, - ) - from ethereum.forks.amsterdam.stateless_guest import ( - serialize_stateless_output, - ) - from ethereum.forks.amsterdam.stateless_host import ( - build_chain_config, - build_stateless_input, - serialize_stateless_input, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - from ethereum_types.numeric import U64 - - parent_number = ZeroPaddedHexNumber(block_number - 1) - parent_header_rlp = previous_env.block_headers.get(parent_number) - if parent_header_rlp is None: - return None - parent_header = _decode_amsterdam_header_bytes(parent_header_rlp) - if parent_header is None: - return None - if Hash(parent_header_rlp.keccak256()) != header.parent_hash: - return None - - current_header = _decode_amsterdam_header_bytes(header.rlp) - if not isinstance(current_header, AmsterdamHeader): - return None - - try: - execution_requests = decode_execution_requests( - tuple( - AmsterdamBytes(bytes(request)) - for request in requests_list or [] - ) - ) - except Exception: - return None - - rejected_indices = { - int(rejected.index) for rejected in result.rejected_transactions - } - accepted_txs = tuple( - AmsterdamBytes(bytes(tx.rlp())) - for index, tx in enumerate(txs) - if index not in rejected_indices - ) - block = AmsterdamBlock( - header=current_header, - transactions=accepted_txs, - ommers=(), - withdrawals=_convert_amsterdam_withdrawals(withdrawals), - ) - stateless_input = build_stateless_input( - block, - execution_witness=_convert_amsterdam_execution_witness( - execution_witness - ), - execution_requests=execution_requests, - block_access_list=_convert_amsterdam_block_access_list( - block_access_list - ), - chain_id=U64(chain_id), - ) - stateless_input_bytes = serialize_stateless_input(stateless_input) - # TODO: Replace this shortcut with a client-provided validation result - # or a real stateless guest run once Geth witness generation is complete. - stateless_output = StatelessValidationResult( - new_payload_request_root=compute_new_payload_request_root( - stateless_input - ), - successful_validation=True, - chain_config=build_chain_config(U64(chain_id)), - ) - stateless_output_bytes = serialize_stateless_output(stateless_output) - return ( - Bytes(bytes(stateless_input_bytes)), - Bytes(bytes(stateless_output_bytes)), - ) - - -def decode_amsterdam_stateless_output( - *, - fork: Fork, - block_number: int, - timestamp: int, - stateless_output_bytes: Bytes | None, -) -> Any | None: - """ - Decode Amsterdam stateless output, if available for the active fork. - - Amsterdam is currently the only fork with stateless guest support in this - repository, so the decode path is kept Amsterdam-specific. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam" or stateless_output_bytes is None: - return None - - from ethereum.forks.amsterdam.stateless_host import ( - deserialize_stateless_output, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - - return deserialize_stateless_output( - AmsterdamBytes(bytes(stateless_output_bytes)) - ) - - -def assert_amsterdam_stateless_output_chain_config( - *, - block_number: int, - chain_id: int, - stateless_output: Any | None, -) -> None: - """ - Assert the stateless output reports the fixed Amsterdam chain config. - """ - if stateless_output is None: - return - - from ethereum.forks.amsterdam.stateless_host import build_chain_config - from ethereum_types.numeric import U64 - - expected_chain_config = build_chain_config(U64(chain_id)) - if stateless_output.chain_config != expected_chain_config: - raise AssertionError( - "Stateless output chain_config mismatch for block " - f"{block_number}: got {stateless_output.chain_config}, " - f"want {expected_chain_config}" - ) - - -def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: - """ - Return whether output is the invalid stateless input sentinel. - """ - from ethereum.forks.amsterdam.stateless import ProtocolFork - from ethereum_types.numeric import U64 - - chain_config = stateless_output.chain_config - active_fork = chain_config.active_fork - activation = active_fork.activation - return ( - not stateless_output.successful_validation - and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 - and chain_config.chain_id == U64(0) - and active_fork.fork == ProtocolFork.Frontier - and activation.block_number is None - and activation.timestamp is None - and active_fork.blob_schedule is None - ) - - class Header(CamelModel): """Header type used to describe block header properties in test specs.""" @@ -1341,44 +782,9 @@ def generate_block_data( "exception must be the last transaction in the block" ) - has_witness_expectation = ( - block.expected_execution_witness_state is not None - or block.expected_execution_witness_codes is not None - or block.expected_execution_witness_headers is not None - ) - public_keys_modifier = block.stateless_input_public_keys_modifier - has_public_keys_modifier = public_keys_modifier is not None - stateless_input_bytes_modifier = block.stateless_input_bytes_modifier - has_stateless_input_bytes_modifier = ( - stateless_input_bytes_modifier is not None - ) - expected_success = block.expected_stateless_validation_success - omit_stateless_artifacts = block.rlp_modifier is not None - if omit_stateless_artifacts and ( - has_witness_expectation - or has_public_keys_modifier - or has_stateless_input_bytes_modifier - or expected_success is not None - ): - raise AssertionError( - "Blocks with rlp_modifier omit stateless artifacts because " - "they are generated before the RLP mutation. SSZ/stateless " - "mutation tests require a separate explicit mechanism." - ) - if self.skip_stateless_validation and ( - has_witness_expectation - or has_public_keys_modifier - or has_stateless_input_bytes_modifier - or expected_success is not None - ): - raise AssertionError( - "skip_stateless_validation cannot be combined with " - "execution witness expectations, stateless input public-key " - "modifiers, stateless input byte modifiers, or " - "expected_stateless_validation_success" - ) - skip_stateless_for_block = ( - self.skip_stateless_validation or omit_stateless_artifacts + stateless_options = stateless_options_for_block( + block=block, + skip_stateless_validation=self.skip_stateless_validation, ) transition_tool_output = t8n.evaluate( @@ -1390,7 +796,7 @@ def generate_block_data( chain_id=self.chain_id, reward=fork.get_reward(), blob_schedule=fork.blob_schedule(), - skip_stateless_validation=skip_stateless_for_block, + skip_stateless_validation=stateless_options.skip_validation, ), slow_request=self.is_tx_gas_heavy_test, ) @@ -1525,265 +931,41 @@ def generate_block_data( # header hash header.block_access_list_hash = Hash(bal.rlp.keccak256()) - # If expected witness state/codes defined, verify against actual t8n_witness = transition_tool_output.result.execution_witness - execution_witness = t8n_witness - state_expectation = block.expected_execution_witness_state - if state_expectation is not None and execution_witness is not None: - state_expectation.verify_against(execution_witness) - execution_witness = state_expectation.modify_if_invalid_test( - execution_witness - ) - - if ( - block.expected_execution_witness_codes is not None - and execution_witness is not None - ): - effective_codes_expectation = ( - with_execution_witness_implicit_codes( - expectation=block.expected_execution_witness_codes, - fork=fork, - alloc=previous_alloc, - block_number=env.number, - timestamp=env.timestamp, - ) - ) - effective_codes_expectation.verify_against(execution_witness) - execution_witness = ( - block.expected_execution_witness_codes.modify_if_invalid_test( - execution_witness - ) - ) - - # If expected witness headers defined, verify against actual - headers_expectation = block.expected_execution_witness_headers - if headers_expectation is not None and execution_witness is not None: - headers_expectation.verify_against( - execution_witness, - parent_hash=header.parent_hash, - fork=fork, - ) - execution_witness = headers_expectation.modify_if_invalid_test( - execution_witness - ) - - has_witness_modifier = ( - ( - block.expected_execution_witness_state is not None - and ( - block.expected_execution_witness_state._modifier - is not None - ) - ) - or ( - block.expected_execution_witness_codes is not None - and block.expected_execution_witness_codes._modifier - is not None - ) - or ( - block.expected_execution_witness_headers is not None - and block.expected_execution_witness_headers._modifier - is not None - ) - ) - stateless_input_bytes = ( - transition_tool_output.result.stateless_input_bytes - ) - stateless_output_bytes = ( - transition_tool_output.result.stateless_output_bytes - ) - if ( - not skip_stateless_for_block - and t8n_witness is not None - and bal is not None - and ( - stateless_input_bytes is None or stateless_output_bytes is None - ) - ): - stateless_artifacts = build_amsterdam_stateless_artifacts_from_t8n( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - header=header, - previous_env=previous_env, - txs=txs, - result=transition_tool_output.result, - withdrawals=env.withdrawals, - requests_list=requests_list, - execution_witness=t8n_witness, - block_access_list=bal, - chain_id=self.chain_id, - ) - if stateless_artifacts is not None: - stateless_input_bytes, stateless_output_bytes = ( - stateless_artifacts - ) - stateless_output = decode_amsterdam_stateless_output( + stateless_artifacts = apply_execution_witness_expectations( + block=block, fork=fork, + previous_alloc=previous_alloc, block_number=int(env.number), timestamp=int(env.timestamp), - stateless_output_bytes=stateless_output_bytes, - ) - if has_witness_modifier and expected_success is None: - raise AssertionError( - "Mutated execution witness tests must set " - "expected_stateless_validation_success explicitly" - ) - if has_public_keys_modifier and expected_success is None: - raise AssertionError( - "Mutated stateless input public-key tests must set " - "expected_stateless_validation_success explicitly" - ) - if has_stateless_input_bytes_modifier and expected_success is None: - raise AssertionError( - "Mutated stateless input byte tests must set " - "expected_stateless_validation_success explicitly" - ) - public_keys: Tuple[Bytes, ...] | None = None - should_verify_stateless_input_public_keys = ( - stateless_input_bytes is not None - # The block could be invalid because of invalid txs, thus - # the public keys might not be properly constructed given they - # can't be decoded and thus provided in the execution witness. - and block.exception is None + parent_hash=header.parent_hash, + execution_witness=t8n_witness, ) - if stateless_input_bytes is not None and ( - should_verify_stateless_input_public_keys - or has_public_keys_modifier - ): - payload_transactions: Tuple[Bytes, ...] - ( - public_keys, - payload_transactions, - ) = get_amsterdam_stateless_input_public_key_data( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - stateless_input_bytes=stateless_input_bytes, - ) - if should_verify_stateless_input_public_keys: - verify_stateless_input_public_keys( - public_keys, - payload_transactions, - self.chain_id, - ) - elif has_public_keys_modifier: - raise Exception( - "Stateless input public-key mutation requires stateless " - "input bytes" - ) - canonical_successful_validation: bool | None = None - if ( - has_witness_modifier - or has_public_keys_modifier - or expected_success is not None - ): - if stateless_output_bytes is None: - raise Exception( - "Stateless guest verification requires stateless output " - "bytes" - ) - if stateless_output is None: - raise Exception( - "Stateless output decoding is only supported for Amsterdam" - ) - canonical_successful_validation = ( - stateless_output.successful_validation - ) - - should_rerun_structured_stateless_guest = ( - has_witness_modifier or has_public_keys_modifier + stateless_artifacts = stateless_artifacts_from_t8n( + options=stateless_options, + artifacts=stateless_artifacts, + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + header=header, + previous_env=previous_env, + txs=txs, + result=transition_tool_output.result, + withdrawals=env.withdrawals, + requests_list=requests_list, + execution_witness=t8n_witness, + block_access_list=bal, + chain_id=self.chain_id, ) - final_successful_validation = canonical_successful_validation - if should_rerun_structured_stateless_guest: - if stateless_input_bytes is None: - raise Exception( - "Stateless guest rerun requires stateless input bytes" - ) - if has_witness_modifier and execution_witness is None: - raise Exception( - "Stateless guest witness mutation rerun requires " - "execution witness" - ) - modified_public_keys: Tuple[Bytes, ...] | None = None - if public_keys_modifier is not None: - if public_keys is None: - raise Exception( - "Stateless guest rerun requires public keys" - ) - modified_public_keys = public_keys_modifier(public_keys) - ( - stateless_input_bytes, - stateless_output_bytes, - successful_validation, - ) = rerun_amsterdam_stateless_guest_with_overrides( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - original_stateless_input_bytes=stateless_input_bytes, - execution_witness=( - execution_witness if has_witness_modifier else None - ), - public_keys=modified_public_keys, - ) - stateless_output = decode_amsterdam_stateless_output( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - stateless_output_bytes=stateless_output_bytes, - ) - final_successful_validation = successful_validation - - if has_stateless_input_bytes_modifier: - if stateless_input_bytes is None: - raise Exception( - "Stateless guest raw input rerun requires stateless " - "input bytes" - ) - if stateless_input_bytes_modifier is None: - raise Exception("Stateless input bytes modifier is required") - stateless_input_bytes = stateless_input_bytes_modifier( - stateless_input_bytes - ) - ( - stateless_input_bytes, - stateless_output_bytes, - successful_validation, - ) = rerun_amsterdam_stateless_guest_with_input_bytes( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - stateless_input_bytes=stateless_input_bytes, - ) - stateless_output = decode_amsterdam_stateless_output( - fork=fork, - block_number=int(env.number), - timestamp=int(env.timestamp), - stateless_output_bytes=stateless_output_bytes, - ) - final_successful_validation = successful_validation - - if ( - expected_success is not None - and final_successful_validation != expected_success - ): - raise AssertionError( - "Stateless guest validation result mismatch: " - f"got {final_successful_validation}, " - f"want {expected_success}" - ) - - skip_chain_config_assertion = ( - has_stateless_input_bytes_modifier - and stateless_output is not None - and is_invalid_stateless_input_sentinel(stateless_output) + stateless_artifacts = finalize_stateless_artifacts( + options=stateless_options, + artifacts=stateless_artifacts, + block=block, + fork=fork, + block_number=int(env.number), + timestamp=int(env.timestamp), + chain_id=self.chain_id, ) - if not skip_chain_config_assertion: - assert_amsterdam_stateless_output_chain_config( - block_number=int(env.number), - chain_id=self.chain_id, - stateless_output=stateless_output, - ) built_block = BuiltBlock( header=header, @@ -1800,10 +982,12 @@ def generate_block_data( rlp_modifier=block.rlp_modifier, fork=fork, block_access_list=bal, - execution_witness=execution_witness, - execution_witness_mutated=has_witness_modifier, - stateless_input_bytes=stateless_input_bytes, - stateless_output_bytes=stateless_output_bytes, + execution_witness=stateless_artifacts.execution_witness, + execution_witness_mutated=( + stateless_artifacts.execution_witness_mutated + ), + stateless_input_bytes=stateless_artifacts.stateless_input_bytes, + stateless_output_bytes=stateless_artifacts.stateless_output_bytes, ) try: @@ -1819,7 +1003,7 @@ def generate_block_data( block.expected_block_access_list is not None and block.expected_block_access_list._modifier is not None ) - and not has_witness_modifier + and not stateless_artifacts.execution_witness_mutated ): # Only verify block level exception if: - No transaction # exception was raised, because these are not reported as block diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py new file mode 100644 index 00000000000..ab6313fcb01 --- /dev/null +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -0,0 +1,1054 @@ +"""Stateless helpers for blockchain test generation.""" + +from dataclasses import dataclass, replace +from typing import Any, Callable, List, Protocol, Tuple + +from execution_testing.base_types import ( + Bytes, + Hash, + ZeroPaddedHexNumber, +) +from execution_testing.client_clis import LazyAlloc, Result +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.forks import Fork +from execution_testing.test_types import ( + Alloc, + Environment, + ExecutionWitness, + Transaction, + Withdrawal, +) +from execution_testing.test_types.block_access_list import BlockAccessList +from execution_testing.test_types.execution_witness import ( + ExecutionWitnessCodesExpectation, + ExecutionWitnessHeadersExpectation, + ExecutionWitnessStateExpectation, +) +from execution_testing.test_types.execution_witness.modifiers import ( + PublicKeyModifier, +) + + +class StatelessBlockProtocol(Protocol): + """Block fields needed by stateless validation orchestration.""" + + @property + def rlp_modifier(self) -> object | None: + """RLP modifier configured for the block.""" + ... + + @property + def expected_execution_witness_codes( + self, + ) -> ExecutionWitnessCodesExpectation | None: + """Expected execution witness codes.""" + ... + + @property + def expected_execution_witness_state( + self, + ) -> ExecutionWitnessStateExpectation | None: + """Expected execution witness state.""" + ... + + @property + def expected_execution_witness_headers( + self, + ) -> ExecutionWitnessHeadersExpectation | None: + """Expected execution witness headers.""" + ... + + @property + def stateless_input_public_keys_modifier( + self, + ) -> PublicKeyModifier | None: + """Public-key modifier for stateless input reruns.""" + ... + + @property + def stateless_input_bytes_modifier( + self, + ) -> Callable[[Bytes], Bytes] | None: + """Serialized stateless input modifier for raw-input reruns.""" + ... + + @property + def expected_stateless_validation_success(self) -> bool | None: + """Expected stateless guest validation result.""" + ... + + @property + def exception(self) -> object | None: + """Block exception expectation.""" + ... + + +@dataclass(frozen=True) +class StatelessBlockOptions: + """Stateless options derived before transition-tool execution.""" + + skip_validation: bool + has_witness_expectation: bool + public_keys_modifier: PublicKeyModifier | None + stateless_input_bytes_modifier: Callable[[Bytes], Bytes] | None + expected_validation_success: bool | None + + @property + def has_public_keys_modifier(self) -> bool: + """Whether stateless input public keys should be mutated.""" + return self.public_keys_modifier is not None + + @property + def has_stateless_input_bytes_modifier(self) -> bool: + """Whether raw stateless input bytes should be mutated.""" + return self.stateless_input_bytes_modifier is not None + + +@dataclass(frozen=True) +class StatelessValidationArtifacts: + """Stateless artifacts passed between blockchain generation phases.""" + + execution_witness: ExecutionWitness | None + execution_witness_mutated: bool + stateless_input_bytes: Bytes | None = None + stateless_output_bytes: Bytes | None = None + + +def stateless_options_for_block( + *, + block: StatelessBlockProtocol, + skip_stateless_validation: bool, +) -> StatelessBlockOptions: + """Derive stateless options and reject incompatible block settings.""" + has_witness_expectation = ( + block.expected_execution_witness_state is not None + or block.expected_execution_witness_codes is not None + or block.expected_execution_witness_headers is not None + ) + public_keys_modifier = block.stateless_input_public_keys_modifier + has_public_keys_modifier = public_keys_modifier is not None + stateless_input_bytes_modifier = block.stateless_input_bytes_modifier + has_stateless_input_bytes_modifier = ( + stateless_input_bytes_modifier is not None + ) + expected_success = block.expected_stateless_validation_success + omit_stateless_artifacts = block.rlp_modifier is not None + + if omit_stateless_artifacts and ( + has_witness_expectation + or has_public_keys_modifier + or has_stateless_input_bytes_modifier + or expected_success is not None + ): + raise AssertionError( + "Blocks with rlp_modifier omit stateless artifacts because " + "they are generated before the RLP mutation. SSZ/stateless " + "mutation tests require a separate explicit mechanism." + ) + if skip_stateless_validation and ( + has_witness_expectation + or has_public_keys_modifier + or has_stateless_input_bytes_modifier + or expected_success is not None + ): + raise AssertionError( + "skip_stateless_validation cannot be combined with " + "execution witness expectations, stateless input public-key " + "modifiers, stateless input byte modifiers, or " + "expected_stateless_validation_success" + ) + + return StatelessBlockOptions( + skip_validation=skip_stateless_validation or omit_stateless_artifacts, + has_witness_expectation=has_witness_expectation, + public_keys_modifier=public_keys_modifier, + stateless_input_bytes_modifier=stateless_input_bytes_modifier, + expected_validation_success=expected_success, + ) + + +def apply_execution_witness_expectations( + *, + block: StatelessBlockProtocol, + fork: Fork, + previous_alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, + parent_hash: Hash, + execution_witness: ExecutionWitness | None, +) -> StatelessValidationArtifacts: + """Verify and apply execution witness expectations for a block.""" + adjusted_witness = execution_witness + state_expectation = block.expected_execution_witness_state + if state_expectation is not None and adjusted_witness is not None: + state_expectation.verify_against(adjusted_witness) + adjusted_witness = state_expectation.modify_if_invalid_test( + adjusted_witness + ) + + codes_expectation = block.expected_execution_witness_codes + if codes_expectation is not None and adjusted_witness is not None: + effective_codes_expectation = with_execution_witness_implicit_codes( + expectation=codes_expectation, + fork=fork, + alloc=previous_alloc, + block_number=block_number, + timestamp=timestamp, + ) + effective_codes_expectation.verify_against(adjusted_witness) + adjusted_witness = codes_expectation.modify_if_invalid_test( + adjusted_witness + ) + + headers_expectation = block.expected_execution_witness_headers + if headers_expectation is not None and adjusted_witness is not None: + headers_expectation.verify_against( + adjusted_witness, + parent_hash=parent_hash, + fork=fork, + ) + adjusted_witness = headers_expectation.modify_if_invalid_test( + adjusted_witness + ) + + return StatelessValidationArtifacts( + execution_witness=adjusted_witness, + execution_witness_mutated=_has_execution_witness_modifier(block), + ) + + +def stateless_artifacts_from_t8n( + *, + options: StatelessBlockOptions, + artifacts: StatelessValidationArtifacts, + fork: Fork, + block_number: int, + timestamp: int, + header: FixtureHeader, + previous_env: Environment, + txs: List[Transaction], + result: Result, + withdrawals: List[Withdrawal] | None, + requests_list: List[Bytes] | None, + execution_witness: ExecutionWitness | None, + block_access_list: BlockAccessList | None, + chain_id: int, +) -> StatelessValidationArtifacts: + """Collect or derive serialized stateless artifacts from t8n output.""" + stateless_input_bytes = result.stateless_input_bytes + stateless_output_bytes = result.stateless_output_bytes + if ( + not options.skip_validation + and execution_witness is not None + and block_access_list is not None + and (stateless_input_bytes is None or stateless_output_bytes is None) + ): + built_artifacts = build_amsterdam_stateless_artifacts_from_t8n( + fork=fork, + block_number=block_number, + timestamp=timestamp, + header=header, + previous_env=previous_env, + txs=txs, + result=result, + withdrawals=withdrawals, + requests_list=requests_list, + execution_witness=execution_witness, + block_access_list=block_access_list, + chain_id=chain_id, + ) + if built_artifacts is not None: + stateless_input_bytes, stateless_output_bytes = built_artifacts + + return replace( + artifacts, + stateless_input_bytes=stateless_input_bytes, + stateless_output_bytes=stateless_output_bytes, + ) + + +def finalize_stateless_artifacts( + *, + options: StatelessBlockOptions, + artifacts: StatelessValidationArtifacts, + block: StatelessBlockProtocol, + fork: Fork, + block_number: int, + timestamp: int, + chain_id: int, +) -> StatelessValidationArtifacts: + """Verify, mutate, and rerun stateless guest artifacts as needed.""" + stateless_input_bytes = artifacts.stateless_input_bytes + stateless_output_bytes = artifacts.stateless_output_bytes + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_output_bytes=stateless_output_bytes, + ) + + has_witness_modifier = artifacts.execution_witness_mutated + if has_witness_modifier and options.expected_validation_success is None: + raise AssertionError( + "Mutated execution witness tests must set " + "expected_stateless_validation_success explicitly" + ) + if ( + options.has_public_keys_modifier + and options.expected_validation_success is None + ): + raise AssertionError( + "Mutated stateless input public-key tests must set " + "expected_stateless_validation_success explicitly" + ) + if ( + options.has_stateless_input_bytes_modifier + and options.expected_validation_success is None + ): + raise AssertionError( + "Mutated stateless input byte tests must set " + "expected_stateless_validation_success explicitly" + ) + + public_keys: Tuple[Bytes, ...] | None = None + should_verify_stateless_input_public_keys = ( + stateless_input_bytes is not None + # The block could be invalid because of invalid txs, thus + # the public keys might not be properly constructed given they + # can't be decoded and thus provided in the execution witness. + and block.exception is None + ) + if stateless_input_bytes is not None and ( + should_verify_stateless_input_public_keys + or options.has_public_keys_modifier + ): + payload_transactions: Tuple[Bytes, ...] + public_keys, payload_transactions = ( + get_amsterdam_stateless_input_public_key_data( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_input_bytes=stateless_input_bytes, + ) + ) + if should_verify_stateless_input_public_keys: + verify_stateless_input_public_keys( + public_keys, + payload_transactions, + chain_id, + ) + elif options.has_public_keys_modifier: + raise Exception( + "Stateless input public-key mutation requires stateless " + "input bytes" + ) + + canonical_successful_validation: bool | None = None + if ( + has_witness_modifier + or options.has_public_keys_modifier + or options.expected_validation_success is not None + ): + if stateless_output_bytes is None: + raise Exception( + "Stateless guest verification requires stateless output bytes" + ) + if stateless_output is None: + raise Exception( + "Stateless output decoding is only supported for Amsterdam" + ) + canonical_successful_validation = ( + stateless_output.successful_validation + ) + + should_rerun_structured_stateless_guest = ( + has_witness_modifier or options.has_public_keys_modifier + ) + final_successful_validation = canonical_successful_validation + if should_rerun_structured_stateless_guest: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest rerun requires stateless input bytes" + ) + if has_witness_modifier and artifacts.execution_witness is None: + raise Exception( + "Stateless guest witness mutation rerun requires " + "execution witness" + ) + modified_public_keys: Tuple[Bytes, ...] | None = None + if options.public_keys_modifier is not None: + if public_keys is None: + raise Exception("Stateless guest rerun requires public keys") + modified_public_keys = options.public_keys_modifier(public_keys) + ( + stateless_input_bytes, + stateless_output_bytes, + successful_validation, + ) = rerun_amsterdam_stateless_guest_with_overrides( + fork=fork, + block_number=block_number, + timestamp=timestamp, + original_stateless_input_bytes=stateless_input_bytes, + execution_witness=( + artifacts.execution_witness if has_witness_modifier else None + ), + public_keys=modified_public_keys, + ) + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_output_bytes=stateless_output_bytes, + ) + final_successful_validation = successful_validation + + if options.has_stateless_input_bytes_modifier: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest raw input rerun requires stateless " + "input bytes" + ) + stateless_input_bytes_modifier = options.stateless_input_bytes_modifier + if stateless_input_bytes_modifier is None: + raise Exception("Stateless input bytes modifier is required") + stateless_input_bytes = stateless_input_bytes_modifier( + stateless_input_bytes + ) + ( + stateless_input_bytes, + stateless_output_bytes, + successful_validation, + ) = rerun_amsterdam_stateless_guest_with_input_bytes( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_input_bytes=stateless_input_bytes, + ) + stateless_output = decode_amsterdam_stateless_output( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_output_bytes=stateless_output_bytes, + ) + final_successful_validation = successful_validation + + if ( + options.expected_validation_success is not None + and final_successful_validation != options.expected_validation_success + ): + raise AssertionError( + "Stateless guest validation result mismatch: " + f"got {final_successful_validation}, " + f"want {options.expected_validation_success}" + ) + + skip_chain_config_assertion = ( + options.has_stateless_input_bytes_modifier + and stateless_output is not None + and is_invalid_stateless_input_sentinel(stateless_output) + ) + if not skip_chain_config_assertion: + assert_amsterdam_stateless_output_chain_config( + block_number=block_number, + chain_id=chain_id, + stateless_output=stateless_output, + ) + + return replace( + artifacts, + stateless_input_bytes=stateless_input_bytes, + stateless_output_bytes=stateless_output_bytes, + ) + + +def execution_witness_implicit_codes_for_block( + *, + fork: Fork, + alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, +) -> List[Bytes]: + """ + Return ambient witness bytecodes implied by block-level execution. + + These codes are resolved from the effective pre-state for the block, not + from raw fork defaults, so test `pre` overrides are respected. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + addresses = active_fork.execution_witness_implicit_code_addresses( + block_number=block_number, + timestamp=timestamp, + ) + if not addresses: + return [] + + effective_alloc = alloc.get() if isinstance(alloc, LazyAlloc) else alloc + + codes: List[Bytes] = [] + seen: set[Bytes] = set() + for address in addresses: + if address not in effective_alloc: + continue + account = effective_alloc[address] + if account is None or len(account.code) == 0: + continue + code = Bytes(account.code) + if code in seen: + continue + codes.append(code) + seen.add(code) + return codes + + +def with_execution_witness_implicit_codes( + *, + expectation: ExecutionWitnessCodesExpectation, + fork: Fork, + alloc: Alloc | LazyAlloc, + block_number: int, + timestamp: int, +) -> ExecutionWitnessCodesExpectation: + """Return expectation copy with ambient block-level codes added.""" + codes_present = list(expectation.codes_present) + seen = set(codes_present) + + for code in execution_witness_implicit_codes_for_block( + fork=fork, + alloc=alloc, + block_number=block_number, + timestamp=timestamp, + ): + if code in seen: + continue + codes_present.append(code) + seen.add(code) + + return expectation.model_copy(update={"codes_present": codes_present}) + + +def rerun_amsterdam_stateless_guest_with_overrides( + *, + fork: Fork, + block_number: int, + timestamp: int, + original_stateless_input_bytes: Bytes, + execution_witness: ExecutionWitness | None = None, + public_keys: Tuple[Bytes, ...] | None = None, +) -> tuple[Bytes, Bytes, bool]: + """ + Rebuild the stateless input with test overrides and rerun the guest. + + Amsterdam is currently the only fork with stateless guest support in this + repository, so the rerun path is kept Amsterdam-specific. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Execution witness guest rerun is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless import ( + ExecutionWitness as AmsterdamExecutionWitness, + ) + from ethereum.forks.amsterdam.stateless import ( + StatelessInput as AmsterdamStatelessInput, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + run_stateless_guest, + ) + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + serialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + original_input = deserialize_stateless_input( + AmsterdamBytes(bytes(original_stateless_input_bytes)) + ) + rebuilt_witness = original_input.witness + if execution_witness is not None: + rebuilt_witness = AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) + rebuilt_input = AmsterdamStatelessInput( + new_payload_request=original_input.new_payload_request, + witness=rebuilt_witness, + chain_config=original_input.chain_config, + public_keys=( + tuple(AmsterdamBytes(bytes(key)) for key in public_keys) + if public_keys is not None + else original_input.public_keys + ), + ) + rebuilt_input_bytes = serialize_stateless_input(rebuilt_input) + rebuilt_output_bytes = run_stateless_guest(rebuilt_input_bytes) + rebuilt_output = deserialize_stateless_output(rebuilt_output_bytes) + + return ( + Bytes(bytes(rebuilt_input_bytes)), + Bytes(bytes(rebuilt_output_bytes)), + rebuilt_output.successful_validation, + ) + + +def rerun_amsterdam_stateless_guest_with_input_bytes( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> tuple[Bytes, Bytes, bool]: + """ + Rerun the Amsterdam stateless guest with raw stateless input bytes. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless guest raw input rerun is only supported for Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_guest import run_stateless_guest + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + guest_input_bytes = AmsterdamBytes(bytes(stateless_input_bytes)) + stateless_output_bytes = run_stateless_guest(guest_input_bytes) + stateless_output = deserialize_stateless_output(stateless_output_bytes) + + return ( + Bytes(bytes(guest_input_bytes)), + Bytes(bytes(stateless_output_bytes)), + stateless_output.successful_validation, + ) + + +def get_amsterdam_stateless_input_public_key_data( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> tuple[Tuple[Bytes, ...], Tuple[Bytes, ...]]: + """ + Decode Amsterdam stateless input public keys and payload transactions. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + raise Exception( + "Stateless input public-key decoding is only supported for " + "Amsterdam" + ) + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(stateless_input_bytes)) + ) + public_keys = tuple( + Bytes(bytes(public_key)) for public_key in stateless_input.public_keys + ) + payload_transactions = tuple( + Bytes(bytes(transaction)) + for transaction in ( + stateless_input.new_payload_request.execution_payload.transactions + ) + ) + return public_keys, payload_transactions + + +def verify_stateless_input_public_keys( + public_keys: Tuple[Bytes, ...], + payload_transactions: Tuple[Bytes, ...], + chain_id: int, +) -> None: + """ + Verify that every payload transaction has its recovered public key. + """ + payload_transaction_count = len(payload_transactions) + if len(public_keys) != payload_transaction_count: + raise AssertionError( + "Stateless input public key count does not match payload " + f"transactions: got {len(public_keys)} public keys for " + f"{payload_transaction_count} transactions" + ) + + from ethereum.forks.amsterdam.transactions import ( + decode_transaction, + recover_transaction_public_key, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U64 + + for index, (public_key, payload_transaction) in enumerate( + zip(public_keys, payload_transactions, strict=True) + ): + transaction = decode_transaction( + AmsterdamBytes(bytes(payload_transaction)) + ) + expected_public_key = recover_transaction_public_key( + U64(chain_id), + transaction, + ) + if bytes(public_key) != bytes(expected_public_key): + raise AssertionError( + "Stateless input public key " + f"{index} does not match recovered transaction public key" + ) + + +def _decode_amsterdam_header_bytes(header_rlp: Bytes) -> Any | None: + """ + Decode an Amsterdam or immediate pre-Amsterdam RLP header. + """ + from ethereum.forks.amsterdam.stateless import _decode_header + from ethereum_types.bytes import Bytes as AmsterdamBytes + + try: + return _decode_header(AmsterdamBytes(bytes(header_rlp))) + except Exception: + return None + + +def _convert_amsterdam_execution_witness( + execution_witness: ExecutionWitness, +) -> Any: + """ + Convert fixture execution witness data to Amsterdam fork types. + """ + from ethereum.forks.amsterdam.stateless import ( + ExecutionWitness as AmsterdamExecutionWitness, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + return AmsterdamExecutionWitness( + state=tuple( + AmsterdamBytes(bytes(node)) for node in execution_witness.state + ), + codes=tuple( + AmsterdamBytes(bytes(code)) for code in execution_witness.codes + ), + headers=tuple( + AmsterdamBytes(bytes(header)) + for header in execution_witness.headers + ), + ) + + +def _convert_amsterdam_withdrawals( + withdrawals: List[Withdrawal] | None, +) -> Any: + """ + Convert fixture withdrawals to Amsterdam fork withdrawals. + """ + from ethereum.forks.amsterdam.blocks import ( + Withdrawal as AmsterdamWithdrawal, + ) + from ethereum.state import Address as AmsterdamAddress + from ethereum_types.numeric import U64, U256 + + if withdrawals is None: + return () + return tuple( + AmsterdamWithdrawal( + index=U64(int(withdrawal.index)), + validator_index=U64(int(withdrawal.validator_index)), + address=AmsterdamAddress(bytes(withdrawal.address)), + amount=U256(int(withdrawal.amount)), + ) + for withdrawal in withdrawals + ) + + +def _convert_amsterdam_block_access_list( + block_access_list: BlockAccessList, +) -> Any: + """ + Convert fixture BAL data to Amsterdam fork BAL data. + """ + import importlib + + block_access_lists = importlib.import_module( + "ethereum.forks.amsterdam.block_access_lists" + ) + + def bal_type(name: str) -> Any: + return getattr(block_access_lists, name) + + account_changes = bal_type("AccountChanges") + balance_change = bal_type("BalanceChange") + code_change = bal_type("CodeChange") + nonce_change = bal_type("NonceChange") + slot_changes = bal_type("SlotChanges") + storage_change = bal_type("StorageChange") + + from ethereum.state import Address as AmsterdamAddress + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U32, U64, U256 + + return [ + account_changes( + address=AmsterdamAddress(bytes(account.address)), + storage_changes=tuple( + slot_changes( + slot=U256(int(slot.slot)), + changes=tuple( + storage_change( + block_access_index=U32( + int(change.block_access_index) + ), + new_value=U256(int(change.post_value)), + ) + for change in slot.slot_changes + ), + ) + for slot in account.storage_changes + ), + storage_reads=tuple( + U256(int(slot)) for slot in account.storage_reads + ), + balance_changes=tuple( + balance_change( + block_access_index=U32(int(change.block_access_index)), + post_balance=U256(int(change.post_balance)), + ) + for change in account.balance_changes + ), + nonce_changes=tuple( + nonce_change( + block_access_index=U32(int(change.block_access_index)), + new_nonce=U64(int(change.post_nonce)), + ) + for change in account.nonce_changes + ), + code_changes=tuple( + code_change( + block_access_index=U32(int(change.block_access_index)), + new_code=AmsterdamBytes(bytes(change.new_code)), + ) + for change in account.code_changes + ), + ) + for account in block_access_list.root + ] + + +def build_amsterdam_stateless_artifacts_from_t8n( + *, + fork: Fork, + block_number: int, + timestamp: int, + header: FixtureHeader, + previous_env: Environment, + txs: List[Transaction], + result: Result, + withdrawals: List[Withdrawal] | None, + requests_list: List[Bytes] | None, + execution_witness: ExecutionWitness, + block_access_list: BlockAccessList, + chain_id: int, +) -> tuple[Bytes, Bytes] | None: + """ + Build Amsterdam stateless input/output bytes from t8n witness artifacts. + + Returns ``None`` when the finalized request list cannot be decoded into + the Amsterdam request container, matching the existing EELS t8n behavior. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam" or block_number == 0: + return None + + from ethereum.forks.amsterdam.blocks import ( + Block as AmsterdamBlock, + ) + from ethereum.forks.amsterdam.blocks import ( + Header as AmsterdamHeader, + ) + from ethereum.forks.amsterdam.execution_engine.requests import ( + decode_execution_requests, + ) + from ethereum.forks.amsterdam.stateless import ( + StatelessValidationResult, + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + serialize_stateless_output, + ) + from ethereum.forks.amsterdam.stateless_host import ( + build_chain_config, + build_stateless_input, + serialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + from ethereum_types.numeric import U64 + + parent_number = ZeroPaddedHexNumber(block_number - 1) + parent_header_rlp = previous_env.block_headers.get(parent_number) + if parent_header_rlp is None: + return None + parent_header = _decode_amsterdam_header_bytes(parent_header_rlp) + if parent_header is None: + return None + if Hash(parent_header_rlp.keccak256()) != header.parent_hash: + return None + + current_header = _decode_amsterdam_header_bytes(header.rlp) + if not isinstance(current_header, AmsterdamHeader): + return None + + try: + execution_requests = decode_execution_requests( + tuple( + AmsterdamBytes(bytes(request)) + for request in requests_list or [] + ) + ) + except Exception: + return None + + rejected_indices = { + int(rejected.index) for rejected in result.rejected_transactions + } + accepted_txs = tuple( + AmsterdamBytes(bytes(tx.rlp())) + for index, tx in enumerate(txs) + if index not in rejected_indices + ) + block = AmsterdamBlock( + header=current_header, + transactions=accepted_txs, + ommers=(), + withdrawals=_convert_amsterdam_withdrawals(withdrawals), + ) + stateless_input = build_stateless_input( + block, + execution_witness=_convert_amsterdam_execution_witness( + execution_witness + ), + execution_requests=execution_requests, + block_access_list=_convert_amsterdam_block_access_list( + block_access_list + ), + chain_id=U64(chain_id), + ) + stateless_input_bytes = serialize_stateless_input(stateless_input) + # TODO: Replace this shortcut with a client-provided validation result + # or a real stateless guest run once Geth witness generation is complete. + stateless_output = StatelessValidationResult( + new_payload_request_root=compute_new_payload_request_root( + stateless_input + ), + successful_validation=True, + chain_config=build_chain_config(U64(chain_id)), + ) + stateless_output_bytes = serialize_stateless_output(stateless_output) + return ( + Bytes(bytes(stateless_input_bytes)), + Bytes(bytes(stateless_output_bytes)), + ) + + +def decode_amsterdam_stateless_output( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_output_bytes: Bytes | None, +) -> Any | None: + """ + Decode Amsterdam stateless output, if available for the active fork. + + Amsterdam is currently the only fork with stateless guest support in this + repository, so the decode path is kept Amsterdam-specific. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam" or stateless_output_bytes is None: + return None + + from ethereum.forks.amsterdam.stateless_host import ( + deserialize_stateless_output, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + return deserialize_stateless_output( + AmsterdamBytes(bytes(stateless_output_bytes)) + ) + + +def assert_amsterdam_stateless_output_chain_config( + *, + block_number: int, + chain_id: int, + stateless_output: Any | None, +) -> None: + """ + Assert the stateless output reports the fixed Amsterdam chain config. + """ + if stateless_output is None: + return + + from ethereum.forks.amsterdam.stateless_host import build_chain_config + from ethereum_types.numeric import U64 + + expected_chain_config = build_chain_config(U64(chain_id)) + if stateless_output.chain_config != expected_chain_config: + raise AssertionError( + "Stateless output chain_config mismatch for block " + f"{block_number}: got {stateless_output.chain_config}, " + f"want {expected_chain_config}" + ) + + +def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: + """ + Return whether output is the invalid stateless input sentinel. + """ + from ethereum.forks.amsterdam.stateless import ProtocolFork + from ethereum_types.numeric import U64 + + chain_config = stateless_output.chain_config + active_fork = chain_config.active_fork + activation = active_fork.activation + return ( + not stateless_output.successful_validation + and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 + and chain_config.chain_id == U64(0) + and active_fork.fork == ProtocolFork.Frontier + and activation.block_number is None + and activation.timestamp is None + and active_fork.blob_schedule is None + ) + + +def _has_execution_witness_modifier( + block: StatelessBlockProtocol, +) -> bool: + """Return whether any execution witness expectation mutates the witness.""" + return ( + ( + block.expected_execution_witness_state is not None + and block.expected_execution_witness_state._modifier is not None + ) + or ( + block.expected_execution_witness_codes is not None + and block.expected_execution_witness_codes._modifier is not None + ) + or ( + block.expected_execution_witness_headers is not None + and block.expected_execution_witness_headers._modifier is not None + ) + ) From cb4980cdde5b0ed5ef4c7896f631630d7489be29 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 27 May 2026 13:24:45 -0300 Subject: [PATCH 221/265] lints --- vulture_whitelist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index f471f0a6c16..11ec937573e 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -212,8 +212,7 @@ ProtocolFork.TangerineWhistle ProtocolFork.SpuriousDragon ProtocolFork.Byzantium -ProtocolFork.Constantinople -ProtocolFork.ConstantinopleFix +ProtocolFork.StPetersburg ProtocolFork.Istanbul ProtocolFork.MuirGlacier ProtocolFork.Berlin From 579898f1d2d8333828f89c44116a427fafa35d30 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 27 May 2026 15:14:27 -0300 Subject: [PATCH 222/265] refactor: simplify stateless input handling and update function names for clarity --- .../specs/blockchain_stateless.py | 70 ++++++++----------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index ab6313fcb01..166f4a99e77 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -88,7 +88,6 @@ class StatelessBlockOptions: """Stateless options derived before transition-tool execution.""" skip_validation: bool - has_witness_expectation: bool public_keys_modifier: PublicKeyModifier | None stateless_input_bytes_modifier: Callable[[Bytes], Bytes] | None expected_validation_success: bool | None @@ -160,7 +159,6 @@ def stateless_options_for_block( return StatelessBlockOptions( skip_validation=skip_stateless_validation or omit_stateless_artifacts, - has_witness_expectation=has_witness_expectation, public_keys_modifier=public_keys_modifier, stateless_input_bytes_modifier=stateless_input_bytes_modifier, expected_validation_success=expected_success, @@ -361,11 +359,11 @@ def finalize_stateless_artifacts( stateless_output.successful_validation ) - should_rerun_structured_stateless_guest = ( + has_structured_stateless_overrides = ( has_witness_modifier or options.has_public_keys_modifier ) final_successful_validation = canonical_successful_validation - if should_rerun_structured_stateless_guest: + if has_structured_stateless_overrides: if stateless_input_bytes is None: raise Exception( "Stateless guest rerun requires stateless input bytes" @@ -380,28 +378,25 @@ def finalize_stateless_artifacts( if public_keys is None: raise Exception("Stateless guest rerun requires public keys") modified_public_keys = options.public_keys_modifier(public_keys) - ( - stateless_input_bytes, - stateless_output_bytes, - successful_validation, - ) = rerun_amsterdam_stateless_guest_with_overrides( - fork=fork, - block_number=block_number, - timestamp=timestamp, - original_stateless_input_bytes=stateless_input_bytes, - execution_witness=( - artifacts.execution_witness if has_witness_modifier else None - ), - public_keys=modified_public_keys, - ) - stateless_output = decode_amsterdam_stateless_output( - fork=fork, - block_number=block_number, - timestamp=timestamp, - stateless_output_bytes=stateless_output_bytes, + stateless_input_bytes = ( + rebuild_amsterdam_stateless_input_with_overrides( + fork=fork, + block_number=block_number, + timestamp=timestamp, + original_stateless_input_bytes=stateless_input_bytes, + execution_witness=( + artifacts.execution_witness + if has_witness_modifier + else None + ), + public_keys=modified_public_keys, + ) ) - final_successful_validation = successful_validation + should_rerun_stateless_guest = ( + has_structured_stateless_overrides + or options.has_stateless_input_bytes_modifier + ) if options.has_stateless_input_bytes_modifier: if stateless_input_bytes is None: raise Exception( @@ -414,6 +409,12 @@ def finalize_stateless_artifacts( stateless_input_bytes = stateless_input_bytes_modifier( stateless_input_bytes ) + + if should_rerun_stateless_guest: + if stateless_input_bytes is None: + raise Exception( + "Stateless guest rerun requires stateless input bytes" + ) ( stateless_input_bytes, stateless_output_bytes, @@ -526,7 +527,7 @@ def with_execution_witness_implicit_codes( return expectation.model_copy(update={"codes_present": codes_present}) -def rerun_amsterdam_stateless_guest_with_overrides( +def rebuild_amsterdam_stateless_input_with_overrides( *, fork: Fork, block_number: int, @@ -534,17 +535,17 @@ def rerun_amsterdam_stateless_guest_with_overrides( original_stateless_input_bytes: Bytes, execution_witness: ExecutionWitness | None = None, public_keys: Tuple[Bytes, ...] | None = None, -) -> tuple[Bytes, Bytes, bool]: +) -> Bytes: """ - Rebuild the stateless input with test overrides and rerun the guest. + Rebuild the stateless input bytes with test overrides. Amsterdam is currently the only fork with stateless guest support in this - repository, so the rerun path is kept Amsterdam-specific. + repository, so the rebuild path is kept Amsterdam-specific. """ active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) if active_fork.name() != "Amsterdam": raise Exception( - "Execution witness guest rerun is only supported for Amsterdam" + "Execution witness input rebuild is only supported for Amsterdam" ) from ethereum.forks.amsterdam.stateless import ( @@ -555,10 +556,8 @@ def rerun_amsterdam_stateless_guest_with_overrides( ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, - run_stateless_guest, ) from ethereum.forks.amsterdam.stateless_host import ( - deserialize_stateless_output, serialize_stateless_input, ) from ethereum_types.bytes import Bytes as AmsterdamBytes @@ -591,14 +590,7 @@ def rerun_amsterdam_stateless_guest_with_overrides( ), ) rebuilt_input_bytes = serialize_stateless_input(rebuilt_input) - rebuilt_output_bytes = run_stateless_guest(rebuilt_input_bytes) - rebuilt_output = deserialize_stateless_output(rebuilt_output_bytes) - - return ( - Bytes(bytes(rebuilt_input_bytes)), - Bytes(bytes(rebuilt_output_bytes)), - rebuilt_output.successful_validation, - ) + return Bytes(bytes(rebuilt_input_bytes)) def rerun_amsterdam_stateless_guest_with_input_bytes( From 8942aecbdab41477d4f8e43f8fd0a75c2af2a980 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 28 May 2026 20:11:02 -0300 Subject: [PATCH 223/265] add chain config test cases --- .../specs/blockchain_stateless.py | 52 ++++++- .../test_witness_validation_chain_config.py | 139 ++++++++++++++++++ 2 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 166f4a99e77..38d306d1824 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -449,10 +449,26 @@ def finalize_stateless_artifacts( and is_invalid_stateless_input_sentinel(stateless_output) ) if not skip_chain_config_assertion: + expected_chain_config = None + if options.has_stateless_input_bytes_modifier: + if stateless_input_bytes is None: + raise Exception( + "Stateless output chain_config assertion requires " + "stateless input bytes" + ) + expected_chain_config = ( + decode_amsterdam_stateless_input_chain_config( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_input_bytes=stateless_input_bytes, + ) + ) assert_amsterdam_stateless_output_chain_config( block_number=block_number, chain_id=chain_id, stateless_output=stateless_output, + expected_chain_config=expected_chain_config, ) return replace( @@ -981,22 +997,50 @@ def decode_amsterdam_stateless_output( ) +def decode_amsterdam_stateless_input_chain_config( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes, +) -> Any | None: + """ + Decode the chain config from Amsterdam stateless input bytes. + """ + active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) + if active_fork.name() != "Amsterdam": + return None + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(stateless_input_bytes)) + ) + return stateless_input.chain_config + + def assert_amsterdam_stateless_output_chain_config( *, block_number: int, chain_id: int, stateless_output: Any | None, + expected_chain_config: Any | None = None, ) -> None: """ - Assert the stateless output reports the fixed Amsterdam chain config. + Assert the stateless output reports the expected Amsterdam chain config. """ if stateless_output is None: return - from ethereum.forks.amsterdam.stateless_host import build_chain_config - from ethereum_types.numeric import U64 + if expected_chain_config is None: + from ethereum.forks.amsterdam.stateless_host import build_chain_config + from ethereum_types.numeric import U64 + + expected_chain_config = build_chain_config(U64(chain_id)) - expected_chain_config = build_chain_config(U64(chain_id)) if stateless_output.chain_config != expected_chain_config: raise AssertionError( "Stateless output chain_config mismatch for block " diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py new file mode 100644 index 00000000000..68750f6e4eb --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py @@ -0,0 +1,139 @@ +"""Stateless chain-config validation tests.""" + +from dataclasses import replace +from typing import Any, Callable + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Transaction, +) + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +StatelessInputBytesModifier = Callable[[Bytes], Bytes] +ChainConfigBuilder = Callable[[Any], Any] + + +def replace_chain_config( + build_chain_config: ChainConfigBuilder, +) -> StatelessInputBytesModifier: + """Replace only the decoded stateless input chain_config.""" + + def modifier(input_bytes: Bytes) -> Bytes: + from ethereum_types.bytes import Bytes as AmsterdamBytes + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + serialize_stateless_input, + ) + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(input_bytes)) + ) + modified_input = replace( + stateless_input, + chain_config=build_chain_config(stateless_input), + ) + return Bytes(bytes(serialize_stateless_input(modified_input))) + + return modifier + + +def future_timestamp_activation_chain_config(stateless_input: Any) -> Any: + """Move Amsterdam activation one second after the payload timestamp.""" + from ethereum_types.numeric import U64 + + from ethereum.forks.amsterdam.stateless import ( + ForkActivation, + ProtocolFork, + ) + + payload = stateless_input.new_payload_request.execution_payload + chain_config = stateless_input.chain_config + active_fork = chain_config.active_fork + return replace( + chain_config, + active_fork=replace( + active_fork, + fork=ProtocolFork.Amsterdam, + activation=ForkActivation( + block_number=None, + timestamp=U64(int(payload.timestamp) + 1), + ), + ), + ) + + +def wrong_chain_id_chain_config(stateless_input: Any) -> Any: + """Change chain_id from 1 to 2.""" + from ethereum_types.numeric import U64 + + chain_config = stateless_input.chain_config + if int(chain_config.chain_id) != 1: + raise AssertionError( + f"expected canonical chain_id 1, got {chain_config.chain_id}" + ) + return replace(chain_config, chain_id=U64(2)) + + +def test_validation_chain_config_future_timestamp_activation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A future Amsterdam timestamp activation fails validation.""" + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[], + stateless_input_bytes_modifier=replace_chain_config( + future_timestamp_activation_chain_config + ), + expected_stateless_validation_success=False, + ) + ], + post={}, + ) + + +def test_validation_chain_config_wrong_chain_id_legacy_signature( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """A protected legacy signature for chain 1 fails under chain 2.""" + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction( + chain_id=1, + sender=sender, + to=recipient, + value=1, + gas_limit=21_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_bytes_modifier=replace_chain_config( + wrong_chain_id_chain_config + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) From 531b92244cbf2317208bafc8047f5d7b948753eb Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 28 May 2026 21:46:25 -0300 Subject: [PATCH 224/265] add test pubkey check on wrong parity case Signed-off-by: jsign --- .../test_witness_public_keys.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py index 00dff01dee4..76e6f8402d3 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -1,6 +1,7 @@ """Stateless input transaction public-key tests.""" import pytest +from coincurve.keys import PublicKey from execution_testing import ( Account, Alloc, @@ -87,3 +88,89 @@ def test_stateless_input_invalid_public_key_is_rejected( sender: Account(nonce=1), }, ) + + +def test_stateless_input_opposite_y_parity_public_key_is_rejected( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + An ECDSA-valid key from the other recovery candidate is rejected. + + This catches implementations that only verify the supplied key against + ``(r, s, message_hash)`` without also binding it to the transaction's + y-parity bit. + """ + recipient = pre.fund_eoa() + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=recipient, + value=0, + gas_limit=21_000, + max_fee_per_gas=10, + max_priority_fee_per_gas=0, + ).with_signature_and_sender() + invalid_public_key = _opposite_y_parity_public_key(tx) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_public_keys_modifier=( + replace_public_key_at(0, invalid_public_key) + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + }, + ) + + +def _recover_public_key( + tx: Transaction, + y_parity: int, + signing_hash: bytes, +) -> Bytes: + """Recover an uncompressed SEC1 public key for ``y_parity``.""" + signature = ( + int(tx.r).to_bytes(32, byteorder="big") + + int(tx.s).to_bytes(32, byteorder="big") + + bytes([y_parity]) + ) + public_key = PublicKey.from_signature_and_message( + signature, + signing_hash, + hasher=None, + ) + return Bytes(public_key.format(compressed=False)) + + +def _opposite_y_parity_public_key(tx: Transaction) -> Bytes: + """Recover the other ECDSA-valid public key for a typed transaction.""" + signed_tx = tx.with_signature_and_sender() + if int(signed_tx.ty) == 0: + raise AssertionError("expected a typed transaction") + + y_parity = int(signed_tx.v) + if y_parity not in (0, 1): + raise AssertionError(f"expected y_parity 0 or 1, got {y_parity}") + + signing_hash = bytes(signed_tx.rlp_signing_bytes().keccak256()) + canonical_public_key = _recover_public_key( + signed_tx, + y_parity, + signing_hash, + ) + alternate_public_key = _recover_public_key( + signed_tx, + y_parity ^ 1, + signing_hash, + ) + if alternate_public_key == canonical_public_key: + raise AssertionError("alternate recovery id produced canonical key") + return alternate_public_key + From 351b4c6cb4aa597468d537beeecdead1c68b5356 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 28 May 2026 21:57:26 -0300 Subject: [PATCH 225/265] add extra assertions to check that we are testing the twisted situation --- .../test_witness_public_keys.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py index 76e6f8402d3..1ced4fd4856 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -1,9 +1,11 @@ """Stateless input transaction public-key tests.""" import pytest +from coincurve import ecdsa from coincurve.keys import PublicKey from execution_testing import ( Account, + Address, Alloc, Block, BlockchainTestFiller, @@ -172,5 +174,34 @@ def _opposite_y_parity_public_key(tx: Transaction) -> Bytes: ) if alternate_public_key == canonical_public_key: raise AssertionError("alternate recovery id produced canonical key") + if _address_from_public_key(canonical_public_key) != signed_tx.sender: + raise AssertionError("canonical public key does not derive sender") + if _address_from_public_key(alternate_public_key) == signed_tx.sender: + raise AssertionError("alternate public key derives sender") + if not _signature_verifies(signed_tx, alternate_public_key, signing_hash): + raise AssertionError("alternate public key does not verify signature") return alternate_public_key +def _address_from_public_key(public_key: Bytes) -> Address: + """Derive the sender address from an uncompressed SEC1 public key.""" + return Address(Bytes(public_key[1:]).keccak256()[12:]) + + +def _signature_verifies( + tx: Transaction, + public_key: Bytes, + signing_hash: bytes, +) -> bool: + """Return whether ``public_key`` verifies the transaction signature.""" + compact_signature = int(tx.r).to_bytes(32, byteorder="big") + int( + tx.s + ).to_bytes(32, byteorder="big") + der_signature = ecdsa.cdata_to_der( + ecdsa.deserialize_compact(compact_signature) + ) + return PublicKey(bytes(public_key)).verify( + der_signature, + signing_hash, + hasher=None, + ) + From 1904537a776520a503f92d2743913d953e64f6f3 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 28 May 2026 21:58:34 -0300 Subject: [PATCH 226/265] lint --- .../eip8025_optional_proofs/test_witness_public_keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py index 1ced4fd4856..d39346e76dd 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -182,6 +182,7 @@ def _opposite_y_parity_public_key(tx: Transaction) -> Bytes: raise AssertionError("alternate public key does not verify signature") return alternate_public_key + def _address_from_public_key(public_key: Bytes) -> Address: """Derive the sender address from an uncompressed SEC1 public key.""" return Address(Bytes(public_key[1:]).keccak256()[12:]) @@ -204,4 +205,3 @@ def _signature_verifies( signing_hash, hasher=None, ) - From c7b5d472dab7550338458f38f56c9a66b3fcbbba Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 11 Jun 2026 09:53:02 -0300 Subject: [PATCH 227/265] add empty-trie sentinel to witness state tests and update documentation --- .../eip8025_optional_proofs/test_witness_state_writes.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py index 35bba6e4549..38606068e6d 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_writes.py @@ -6,6 +6,7 @@ Alloc, Block, BlockchainTestFiller, + Bytes, ExecutionWitnessStateExpectation, Op, Transaction, @@ -160,8 +161,8 @@ def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( """ Empty pre-state storage should not require any storage proof nodes. - The nodes created solely by the insertion are post-state material and - must not appear in the witness. + The empty-trie RLP sentinel and nodes created solely by the insertion + are not pre-state material and must not appear in the witness. """ insert_slot = 1 insert_value = large_storage_value(insert_slot) @@ -176,6 +177,7 @@ def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( ) assert not proof_nodes assert post_state_only_nodes + empty_trie_sentinel = Bytes(b"\x80") contract = pre.deploy_contract( code=Op.SSTORE(insert_slot, insert_value) + Op.STOP, @@ -191,7 +193,8 @@ def test_witness_state_sstore_into_empty_storage_omits_post_state_nodes( txs=[tx], expected_execution_witness_state=( ExecutionWitnessStateExpectation( - nodes_absent=post_state_only_nodes, + nodes_absent=post_state_only_nodes + + [empty_trie_sentinel], ) ), ) From 655cd27ba3b5f20885abd6ac1907c348cd42e835 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Mon, 22 Jun 2026 13:41:54 -0300 Subject: [PATCH 228/265] fix(consume): handle witness negative tests --- .../test_via_engine_witness.py | 52 +++++++++++++++---- .../testing/src/execution_testing/rpc/rpc.py | 15 +++++- 2 files changed, 57 insertions(+), 10 deletions(-) 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 index 8690bf4930d..c4209f6ae1d 100644 --- 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 @@ -54,8 +54,13 @@ def _send_payload_with_witness( engine_rpc: EngineRPC, engine_ssz_rpc: EngineSszRPC, payload: FixtureEngineNewPayload, -) -> NewPayloadWithWitnessResponse: - """Execute one payload through the configured witness endpoint.""" +) -> NewPayloadWithWitnessResponse | JSONRPCError: + """ + Execute one payload through the configured witness endpoint. + + Return the response, or the caught Engine API error for the assertion to + validate against the fixture's expected ``error_code``. + """ try: if use_ssz_transport: return engine_ssz_rpc.new_payload_with_witness(*payload.params) @@ -66,24 +71,48 @@ def _send_payload_with_witness( except EngineWitnessEndpointNotImplementedError as e: pytest.skip(str(e)) except JSONRPCError as e: - if e.code == _JSONRPC_METHOD_NOT_FOUND: + # An unimplemented endpoint is a transport skip, but only when no + # error was expected; otherwise the error is a result to assert. + if payload.error_code is None and e.code == _JSONRPC_METHOD_NOT_FOUND: pytest.skip( "client does not support " f"engine_newPayloadWithWitnessV" f"{payload.new_payload_version}: {e.message}" ) - raise + return e def _assert_witness_response( *, payload: FixtureEngineNewPayload, payload_number: int, - response: NewPayloadWithWitnessResponse, + result: NewPayloadWithWitnessResponse | JSONRPCError, payload_timing: TimingData, use_ssz_transport: bool, ) -> None: - """Assert one witness response matches the fixture payload.""" + """Assert one witness result (response or error) matches the fixture.""" + if isinstance(result, JSONRPCError): + # The client raised an Engine API error; a negative test expects it. + if payload.error_code is None: + raise LoggedError( + f"Payload {payload_number}: unexpected error: " + f"{result.code} - {result.message}" + ) + if result.code != payload.error_code: + raise LoggedError( + f"Payload {payload_number}: unexpected error code: " + f"got {result.code}, expected {payload.error_code}" + ) + return + + if payload.error_code is not None: + # Negative test expected an Engine API error, but got a response. + raise LoggedError( + f"Payload {payload_number}: client did not raise the expected " + f"Engine API error code {payload.error_code}" + ) + + response = result expected_status = ( PayloadStatusEnum.VALID if payload.valid() @@ -217,7 +246,7 @@ def test_blockchain_via_engine_witness( use_ssz_transport=use_ssz_transport, ) ): - witness_response = _send_payload_with_witness( + witness_result = _send_payload_with_witness( use_ssz_transport=use_ssz_transport, engine_rpc=engine_rpc, engine_ssz_rpc=engine_ssz_rpc, @@ -227,12 +256,17 @@ def test_blockchain_via_engine_witness( _assert_witness_response( payload=payload, payload_number=payload_number, - response=witness_response, + result=witness_result, payload_timing=payload_timing, use_ssz_transport=use_ssz_transport, ) - if payload.valid(): + # A raised error means the block was rejected, so there is no + # canonical block to advance the forkchoice to. + if ( + not isinstance(witness_result, JSONRPCError) + and payload.valid() + ): with payload_timing.time( f"engine_forkchoiceUpdatedV" f"{payload.forkchoice_updated_version}" diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index f52609f144c..9c2d3a058a6 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -43,6 +43,7 @@ ForkchoiceUpdateResponse, GetBlobsResponse, GetPayloadResponse, + JSONRPCError, JSONRPCRequest, JSONRPCResponse, NewPayloadWithWitnessResponse, @@ -1411,7 +1412,19 @@ def new_payload_with_witness( raise EngineWitnessEndpointNotImplementedError( url, response.status_code ) - response.raise_for_status() + + # Engine API errors arrive as an HTTP error + JSON {code, message}. + if not response.ok: + try: + error = response.json() + except ValueError: + error = None + if isinstance(error, dict) and "code" in error: + raise JSONRPCError( + code=error["code"], + message=error.get("message", ""), + ) + response.raise_for_status() content_type = response.headers.get("Content-Type", "") if "application/octet-stream" not in content_type: From 7549d3b08f9a06f30acef8d372942846870d9f80 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 24 Jun 2026 15:21:30 -0300 Subject: [PATCH 229/265] fix(generators): skip tests that use system contracts violating max-bytecode limits which is problematic to guest program SSZ input limits --- .../tools/utility/generators.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/testing/src/execution_testing/tools/utility/generators.py b/packages/testing/src/execution_testing/tools/utility/generators.py index 028a114a6c4..85bf0607ad2 100644 --- a/packages/testing/src/execution_testing/tools/utility/generators.py +++ b/packages/testing/src/execution_testing/tools/utility/generators.py @@ -36,13 +36,16 @@ class SystemContractTestType(StrEnum): def param(self) -> Any: """Return the parameter for the test.""" - return pytest.param( - self, - id=self.value, - marks=pytest.mark.exception_test - if self != SystemContractTestType.GAS_LIMIT - else [], - ) + marks: List[Any] = [] + if self != SystemContractTestType.GAS_LIMIT: + marks.append(pytest.mark.exception_test) + if self in ( + SystemContractTestType.GAS_LIMIT, + SystemContractTestType.OUT_OF_GAS_ERROR, + ): + marks.append(pytest.mark.skip_stateless_validation) + + return pytest.param(self, id=self.value, marks=marks) class ContractAddressHasBalance(StrEnum): From f03c2e0af2df95cd2eed029ba4ea7140acd028c7 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 24 Jun 2026 15:59:44 -0300 Subject: [PATCH 230/265] fix(zkevm): update evm-type from develop to eels for consistency in feature configuration --- .github/configs/feature.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index a6169e648c3..3c7d5d7b8f3 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -19,6 +19,6 @@ glamsterdam-devnet: feature_only: true zkevm: - evm-type: develop + evm-type: eels fill-params: -m "blockchain_test or blockchain_test_engine" --fork=Amsterdam ./tests/ feature_only: true From 5657bd94337b040f7f2d9596fcf8e72547867815 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 9 Jul 2026 16:41:26 -0300 Subject: [PATCH 231/265] fix 8025 tests from recent amsterdam changes --- .../execution_testing/forks/forks/forks.py | 8 +++++++ .../forks/tests/test_forks.py | 2 ++ .../eip8025_optional_proofs/gas_helpers.py | 17 +++++++++++++ .../test_stateless_input_bytes.py | 11 ++++++++- .../test_witness_bytecodes_extcode.py | 24 ++++++++++++------- .../test_witness_state_invariants.py | 11 ++++++++- .../test_witness_validation_chain_config.py | 6 ++++- .../test_witness_validation_state.py | 14 ++++++++--- 8 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 tests/amsterdam/eip8025_optional_proofs/gas_helpers.py diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index be0f3920e8c..e9151619b81 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1655,6 +1655,14 @@ def execution_witness_implicit_code_addresses( """Include tracked block-level system code.""" del block_number, timestamp return [ + Address( + 0x0000BFF46984E3725691FA540A8C7589300D8282, + label="BUILDER_DEPOSIT_CONTRACT_ADDRESS", + ), + Address( + 0x000064D678505AD48F8CCB093BC65613800E8282, + label="BUILDER_EXIT_CONTRACT_ADDRESS", + ), Address( 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02, label="BEACON_ROOTS_ADDRESS", diff --git a/packages/testing/src/execution_testing/forks/tests/test_forks.py b/packages/testing/src/execution_testing/forks/tests/test_forks.py index 8f9affc054c..0b6fa635eee 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/forks/tests/test_forks.py @@ -235,6 +235,8 @@ def test_amsterdam_execution_witness_implicit_code_addresses() -> None: ] assert addresses == [ + "0x0000bff46984e3725691fa540a8c7589300d8282", + "0x000064d678505ad48f8ccb093bc65613800e8282", "0x000f3df6d732807ef1319fb7b8bb8522d0beac02", "0x00000961ef480eb55e80d19ad83579a64c007002", "0x0000bbddc7ce488642fb579f8b00f3a590007251", diff --git a/tests/amsterdam/eip8025_optional_proofs/gas_helpers.py b/tests/amsterdam/eip8025_optional_proofs/gas_helpers.py new file mode 100644 index 00000000000..d8fe67c7711 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/gas_helpers.py @@ -0,0 +1,17 @@ +"""Gas helpers for Amsterdam EIP-8025 tests.""" + +from execution_testing import Fork, RecipientType + + +def empty_account_value_transfer_gas_limit(fork: Fork) -> int: + """Return the gas needed to transfer value to an empty account.""" + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + return_cost_deducted_prior_execution=True, + ) + top_frame_state_gas = fork.transaction_top_frame_state_gas( + sends_value=True, + recipient_type=RecipientType.EMPTY_ACCOUNT, + ) + return intrinsic_gas + top_frame_state_gas diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py index 6b1d4c79007..ec3ba0aa03a 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -9,9 +9,12 @@ Block, BlockchainTestFiller, Bytes, + Fork, Transaction, ) +from .gas_helpers import empty_account_value_transfer_gas_limit + pytestmark = pytest.mark.valid_from("Amsterdam") REFERENCE_SPEC_GIT_PATH = "N/A" @@ -77,6 +80,7 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: ], ) def test_invalid_stateless_input_bytes_are_rejected( + fork: Fork, pre: Alloc, blockchain_test: BlockchainTestFiller, modifier: StatelessInputBytesModifier, @@ -84,7 +88,12 @@ def test_invalid_stateless_input_bytes_are_rejected( """Invalid stateless input bytes fail guest validation.""" sender = pre.fund_eoa() recipient = pre.fund_eoa(amount=0) - tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) blockchain_test( pre=pre, diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py index 45eae760fe7..8f24e16ce02 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_bytecodes_extcode.py @@ -9,7 +9,9 @@ BlockchainTestFiller, Bytes, ExecutionWitnessCodesExpectation, + Fork, Op, + RecipientType, Transaction, ) @@ -261,29 +263,26 @@ def test_witness_codes_extcodehash_only( ) -# Intrinsic tx cost (21000) + PUSH20 (3) + EXTCODESIZE cold (2600). -GAS_EXACT_EXTCODESIZE_COLD = 21_000 + 3 + 2600 - - @pytest.mark.parametrize( - "gas_limit,expect_in_witness", + "gas_delta,expect_in_witness", [ pytest.param( - GAS_EXACT_EXTCODESIZE_COLD - 1, + -1, False, id="oog", ), pytest.param( - GAS_EXACT_EXTCODESIZE_COLD, + 0, True, id="just_enough", ), ], ) def test_witness_codes_extcodesize_cold_gas_boundary( + fork: Fork, pre: Alloc, blockchain_test: BlockchainTestFiller, - gas_limit: int, + gas_delta: int, expect_in_witness: bool, ) -> None: """ @@ -299,9 +298,16 @@ def test_witness_codes_extcodesize_cold_gas_boundary( target_code = Op.PUSH1(0x42) + Op.POP + Op.STOP target = pre.deploy_contract(code=target_code) - caller_code = Op.EXTCODESIZE(target) + Op.POP + Op.STOP + extcodesize_code = Op.EXTCODESIZE(target) + caller_code = extcodesize_code + Op.POP + Op.STOP caller = pre.deploy_contract(code=caller_code) + tx_intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + recipient_type=RecipientType.CONTRACT, + return_cost_deducted_prior_execution=True, + ) + gas_limit = tx_intrinsic_gas + extcodesize_code.gas_cost(fork) + gas_delta + sender = pre.fund_eoa() tx = Transaction( sender=sender, diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py index cf1fd84d06f..e0141ce82d6 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_state_invariants.py @@ -7,9 +7,12 @@ Block, BlockchainTestFiller, ExecutionWitnessStateExpectation, + Fork, Transaction, ) +from .gas_helpers import empty_account_value_transfer_gas_limit + pytestmark = pytest.mark.valid_from("Amsterdam") REFERENCE_SPEC_GIT_PATH = "N/A" @@ -17,6 +20,7 @@ def test_witness_state_structural_invariants( + fork: Fork, pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -28,7 +32,12 @@ def test_witness_state_structural_invariants( """ sender = pre.fund_eoa() recipient = pre.fund_eoa(amount=0) - tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) blockchain_test( pre=pre, diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py index 68750f6e4eb..5d5fe1dd15a 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py @@ -10,9 +10,12 @@ Block, BlockchainTestFiller, Bytes, + Fork, Transaction, ) +from .gas_helpers import empty_account_value_transfer_gas_limit + pytestmark = pytest.mark.valid_from("Amsterdam") REFERENCE_SPEC_GIT_PATH = "N/A" @@ -107,6 +110,7 @@ def test_validation_chain_config_future_timestamp_activation( def test_validation_chain_config_wrong_chain_id_legacy_signature( + fork: Fork, pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -118,7 +122,7 @@ def test_validation_chain_config_wrong_chain_id_legacy_signature( sender=sender, to=recipient, value=1, - gas_limit=21_000, + gas_limit=empty_account_value_transfer_gas_limit(fork), ) blockchain_test( diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py index f57ea1aecc9..bb333d9fb1a 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_state.py @@ -10,6 +10,7 @@ BlockchainTestFiller, Bytes, ExecutionWitnessStateExpectation, + Fork, Op, Transaction, ) @@ -22,6 +23,7 @@ from ethereum.forks.amsterdam.incremental_mpt import compact_to_nibbles +from .gas_helpers import empty_account_value_transfer_gas_limit from .state_helpers import ( as_storage, build_large_storage, @@ -244,6 +246,7 @@ def test_validation_state_missing_sender_account_proof_leaf_node( def test_validation_state_missing_absent_account_proof_node( + fork: Fork, pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: @@ -269,11 +272,16 @@ def test_validation_state_missing_absent_account_proof_node( [sender, *Amsterdam.execution_witness_implicit_code_addresses()], ) ) - assert len(recipient_only_nodes) == 1 + assert recipient_only_nodes proof_nodes = collect_account_proof_nodes(full_alloc, [recipient]) - removed_node = recipient_only_nodes[0] + removed_node = _required_node(recipient_only_nodes) - tx = Transaction(sender=sender, to=recipient, value=1, gas_limit=21_000) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) blockchain_test( pre=pre, From 7aac95460d92cdc5caf368acd1b9bb6f62a598cd Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 8 Jul 2026 21:52:25 -0300 Subject: [PATCH 232/265] remove BlobSchedule --- .../specs/blockchain_stateless.py | 1 - src/ethereum/forks/amsterdam/stateless.py | 35 ---------- .../forks/amsterdam/stateless_guest.py | 1 - .../forks/amsterdam/stateless_host.py | 11 ---- src/ethereum/forks/amsterdam/stateless_ssz.py | 66 ------------------- tests/json_loader/test_stateless_guest.py | 12 ---- 6 files changed, 126 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 38d306d1824..aa949e2c54d 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -1066,7 +1066,6 @@ def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: and active_fork.fork == ProtocolFork.Frontier and activation.block_number is None and activation.timestamp is None - and active_fork.blob_schedule is None ) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 0015f055e91..bc21ddc0c06 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -21,11 +21,6 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork import ChainContext from .fork_types import VersionedHash -from .vm.gas import ( - BLOB_BASE_FEE_UPDATE_FRACTION, - BLOB_SCHEDULE_MAX, - BLOB_SCHEDULE_TARGET, -) from .witness_state import WitnessState, build_code_db, build_node_db @@ -147,19 +142,6 @@ class ForkActivation: timestamp: U64 | None -@final -@slotted_freezable -@dataclass -class BlobSchedule: - """ - Effective blob parameters for a protocol fork. - """ - - target: U64 - max: U64 - base_fee_update_fraction: U64 - - @final @slotted_freezable @dataclass @@ -170,7 +152,6 @@ class ForkConfig: fork: ProtocolFork activation: ForkActivation - blob_schedule: BlobSchedule | None @final @@ -326,17 +307,6 @@ def _is_activation_active( return True -def _expected_amsterdam_blob_schedule() -> BlobSchedule: - """ - Return the blob schedule currently compiled into the Amsterdam guest. - """ - return BlobSchedule( - target=BLOB_SCHEDULE_TARGET, - max=BLOB_SCHEDULE_MAX, - base_fee_update_fraction=U64(BLOB_BASE_FEE_UPDATE_FRACTION), - ) - - def validate_chain_config( chain_config: ChainConfig, new_payload_request: NewPayloadRequest, @@ -357,11 +327,6 @@ def validate_chain_config( f"Amsterdam stateless guest cannot execute {active_fork.fork}" ) - if active_fork.blob_schedule != _expected_amsterdam_blob_schedule(): - raise UnsupportedForkConfigError( - "ChainConfig active_fork blob_schedule does not match Amsterdam" - ) - return active_fork diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 5fa01bed508..f0ab991d4a7 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -66,7 +66,6 @@ def _default_failed_stateless_output() -> StatelessValidationResult: block_number=None, timestamp=None, ), - blob_schedule=None, ), ), ) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 00e1821b341..3d70fb5b6a2 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -16,7 +16,6 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash from .stateless import ( - BlobSchedule, ChainConfig, ExecutionWitness, ForkActivation, @@ -38,11 +37,6 @@ decode_transaction, recover_transaction_public_key, ) -from .vm.gas import ( - BLOB_BASE_FEE_UPDATE_FRACTION, - BLOB_SCHEDULE_MAX, - BLOB_SCHEDULE_TARGET, -) def serialize_stateless_input( @@ -75,11 +69,6 @@ def build_chain_config(chain_id: U64) -> ChainConfig: block_number=None, timestamp=U64(0), ), - blob_schedule=BlobSchedule( - target=BLOB_SCHEDULE_TARGET, - max=BLOB_SCHEDULE_MAX, - base_fee_update_fraction=U64(BLOB_BASE_FEE_UPDATE_FRACTION), - ), ), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 4f28b614cf2..0156d1480a7 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -28,7 +28,6 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import Bloom, VersionedHash from .stateless import ( - BlobSchedule, ChainConfig, ExecutionWitness, ForkActivation, @@ -77,7 +76,6 @@ MAX_BYTES_PER_WITNESS_NODE = 2**10 MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 -MAX_BLOB_SCHEDULES_PER_FORK = 1 # One public key is supplied per transaction. Every valid transaction consumes # at least 21_000 gas, so a 500M gas block can contain at most # 500_000_000 // 21_000 = 23_809 transactions, rounded up to next power of 2. @@ -198,25 +196,11 @@ class SszForkActivation(Container): timestamp: SszOptionalForkActivationValue -class SszBlobSchedule(Container): - """SSZ container mirroring ``BlobSchedule``.""" - - target: uint64 - max: uint64 - base_fee_update_fraction: uint64 - - -SszOptionalBlobSchedule: TypeAlias = SszList[ - SszBlobSchedule, MAX_BLOB_SCHEDULES_PER_FORK -] - - class SszForkConfig(Container): """SSZ container mirroring ``ForkConfig``.""" fork: uint64 activation: SszForkActivation - blob_schedule: SszOptionalBlobSchedule class SszChainConfig(Container): @@ -539,50 +523,6 @@ def _ssz_to_fork_activation( ) -def _blob_schedule_to_ssz( - blob_schedule: BlobSchedule, -) -> SszBlobSchedule: - """Convert a BlobSchedule to its SSZ form.""" - return SszBlobSchedule( - target=uint64(int(blob_schedule.target)), - max=uint64(int(blob_schedule.max)), - base_fee_update_fraction=uint64( - int(blob_schedule.base_fee_update_fraction) - ), - ) - - -def _ssz_to_blob_schedule( - ssz_blob_schedule: SszBlobSchedule, -) -> BlobSchedule: - """Convert an SSZ blob schedule back.""" - return BlobSchedule( - target=U64(ssz_blob_schedule.target), - max=U64(ssz_blob_schedule.max), - base_fee_update_fraction=U64( - ssz_blob_schedule.base_fee_update_fraction - ), - ) - - -def _optional_blob_schedule_to_ssz( - blob_schedule: BlobSchedule | None, -) -> SszOptionalBlobSchedule: - """Convert an optional BlobSchedule to a zero-or-one SSZ list.""" - if blob_schedule is None: - return SszOptionalBlobSchedule() - return SszOptionalBlobSchedule(_blob_schedule_to_ssz(blob_schedule)) - - -def _ssz_to_optional_blob_schedule( - ssz_blob_schedule: SszOptionalBlobSchedule, -) -> BlobSchedule | None: - """Convert a zero-or-one SSZ blob schedule list back.""" - if ssz_blob_schedule.length() == 0: - return None - return _ssz_to_blob_schedule(ssz_blob_schedule.get(0)) - - def _fork_config_to_ssz( fork_config: ForkConfig, ) -> SszForkConfig: @@ -590,9 +530,6 @@ def _fork_config_to_ssz( return SszForkConfig( fork=_protocol_fork_to_ssz(fork_config.fork), activation=_fork_activation_to_ssz(fork_config.activation), - blob_schedule=_optional_blob_schedule_to_ssz( - fork_config.blob_schedule - ), ) @@ -603,9 +540,6 @@ def _ssz_to_fork_config( return ForkConfig( fork=_ssz_to_protocol_fork(ssz_fork_config.fork), activation=_ssz_to_fork_activation(ssz_fork_config.activation), - blob_schedule=_ssz_to_optional_blob_schedule( - ssz_fork_config.blob_schedule - ), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index ccd06fc6ed0..6e2c87bb4e3 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -20,7 +20,6 @@ ) from ethereum.forks.amsterdam.fork_types import Bloom from ethereum.forks.amsterdam.stateless import ( - BlobSchedule, ChainConfig, ExecutionWitness, ForkActivation, @@ -46,11 +45,6 @@ STATELESS_INPUT_SCHEMA_ID_BYTES, stateless_input_to_ssz, ) -from ethereum.forks.amsterdam.vm.gas import ( - BLOB_BASE_FEE_UPDATE_FRACTION, - BLOB_SCHEDULE_MAX, - BLOB_SCHEDULE_TARGET, -) from ethereum.state import Address, Root _RNG = random.Random(0xDEADBEEF) @@ -131,11 +125,6 @@ def _expected_amsterdam_chain_config(chain_id: U64) -> ChainConfig: block_number=None, timestamp=U64(0), ), - blob_schedule=BlobSchedule( - target=BLOB_SCHEDULE_TARGET, - max=BLOB_SCHEDULE_MAX, - base_fee_update_fraction=U64(BLOB_BASE_FEE_UPDATE_FRACTION), - ), ), ) @@ -357,7 +346,6 @@ def test_invalid_input_bytes_return_failed_validation(self) -> None: assert result.chain_config.active_fork.fork == ProtocolFork.Frontier assert result.chain_config.active_fork.activation.block_number is None assert result.chain_config.active_fork.activation.timestamp is None - assert result.chain_config.active_fork.blob_schedule is None class TestComputeNewPayloadRequestRoot: From b2daa2faa9b65b9509be5e874a351663785d4020 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 9 Jul 2026 15:09:02 -0300 Subject: [PATCH 233/265] fix(schema): update stateless input schema ID for Amsterdam revision --- src/ethereum/forks/amsterdam/stateless_ssz.py | 13 +++++++-- .../test_stateless_input_bytes.py | 14 ++++++---- tests/json_loader/test_stateless_guest.py | 28 +++++++++++++++---- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 0156d1480a7..69dc46e3655 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -82,8 +82,17 @@ MAX_PUBLIC_KEYS = 2**15 PUBLIC_KEY_BYTES = 65 -# Amsterdam SSZ stateless input schema identifier. -STATELESS_INPUT_SCHEMA_ID = 0x0001 +# Stateless guest input bytes are schema-prefixed: +# schema_id || encoded_payload +# schema_id is fork_index || schema_revision. fork_index is the 1-based +# ProtocolFork order, with BPO forks counted. Amsterdam is fork 0x15, and +# revision 0x01 uses SSZ encode(SszStatelessInput) for the payload. This +# prefix selects byte decoding and is separate from SszForkConfig.fork. +STATELESS_INPUT_SCHEMA_FORK_INDEX = 0x15 +STATELESS_INPUT_SCHEMA_REVISION = 0x01 +STATELESS_INPUT_SCHEMA_ID = ( + STATELESS_INPUT_SCHEMA_FORK_INDEX << 8 +) | STATELESS_INPUT_SCHEMA_REVISION STATELESS_INPUT_SCHEMA_ID_SIZE = 2 STATELESS_INPUT_SCHEMA_ID_BYTES = STATELESS_INPUT_SCHEMA_ID.to_bytes( STATELESS_INPUT_SCHEMA_ID_SIZE, diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py index ec3ba0aa03a..487772e8bc1 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -35,8 +35,8 @@ def incomplete_schema_id(input_bytes: Bytes) -> Bytes: def unsupported_schema_id(input_bytes: Bytes) -> Bytes: - """Replace the schema id while keeping the SSZ body.""" - return Bytes(b"\x00\x02" + input_bytes[2:]) + """Replace the schema id with an unsupported Amsterdam revision.""" + return Bytes(b"\x15\x02" + input_bytes[2:]) def missing_ssz_body(input_bytes: Bytes) -> Bytes: @@ -59,10 +59,12 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: Corrupt the first SSZ container offset. The stateless input starts with a 2-byte schema id, followed by the - SSZ-encoded ``SszStatelessInput`` container. Its first four SSZ bytes - encode the offset to the first variable-size field. Setting that offset - to 1 makes it point inside the fixed-size section, so the SSZ decoder - must reject the input before stateless validation can run. + encoded payload selected by that schema. For Amsterdam schema 0x1501, + the payload is an SSZ-encoded ``SszStatelessInput`` container. Its + first four SSZ bytes encode the offset to the first variable-size field. + Setting that offset to 1 makes it point inside the fixed-size section, + so the SSZ decoder must reject the input before stateless validation + can run. """ return Bytes(input_bytes[:2] + b"\x01\x00\x00\x00" + input_bytes[6:]) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 6e2c87bb4e3..9c9d23ba28f 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -42,7 +42,10 @@ serialize_stateless_input, ) from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_FORK_INDEX, + STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_BYTES, + STATELESS_INPUT_SCHEMA_REVISION, stateless_input_to_ssz, ) from ethereum.state import Address, Root @@ -50,6 +53,15 @@ _RNG = random.Random(0xDEADBEEF) +def test_stateless_input_schema_id_identifies_amsterdam_revision() -> None: + """Amsterdam stateless input schema id is fork_index || revision.""" + assert tuple(ProtocolFork).index(ProtocolFork.Amsterdam) + 1 == 0x15 + assert STATELESS_INPUT_SCHEMA_FORK_INDEX == 0x15 + assert STATELESS_INPUT_SCHEMA_REVISION == 0x01 + assert STATELESS_INPUT_SCHEMA_ID == 0x1501 + assert STATELESS_INPUT_SCHEMA_ID_BYTES == b"\x15\x01" + + def _rb(n: int) -> bytes: """Return ``n`` pseudo-random bytes.""" return bytes(_RNG.getrandbits(8) for _ in range(n)) @@ -293,13 +305,19 @@ def test_empty_input_rejected(self) -> None: def test_one_byte_input_rejected(self) -> None: """Reject input that does not contain a full schema id.""" with pytest.raises(ValueError, match="missing schema id"): - deserialize_stateless_input(Bytes(b"\x01")) + deserialize_stateless_input(Bytes(b"\x15")) - def test_unknown_schema_id_rejected(self) -> None: - """Reject input with a schema id other than Amsterdam's.""" + def test_unsupported_schema_revision_rejected(self) -> None: + """Reject an unsupported Amsterdam schema revision.""" encoded = serialize_stateless_input(_make_stateless_input()) - with pytest.raises(ValueError, match="Unsupported stateless input"): - deserialize_stateless_input(Bytes(b"\x00\x02" + encoded[2:])) + with pytest.raises(ValueError, match="0x1502"): + deserialize_stateless_input(Bytes(b"\x15\x02" + encoded[2:])) + + def test_unsupported_schema_fork_rejected(self) -> None: + """Reject an unsupported stateless input schema fork.""" + encoded = serialize_stateless_input(_make_stateless_input()) + with pytest.raises(ValueError, match="0x1601"): + deserialize_stateless_input(Bytes(b"\x16\x01" + encoded[2:])) def test_legacy_raw_ssz_input_rejected(self) -> None: """Reject unprefixed SSZ input bytes.""" From 98b97c1236ce0fa5c3d40b9c9a8aa119bbdd54ac Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 9 Jul 2026 15:46:16 -0300 Subject: [PATCH 234/265] fix(tests): rename unsupported_schema_id to unsupported_schema_revision and add unsupported_schema_fork --- .../test_stateless_input_bytes.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py index 487772e8bc1..550aaa6f271 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -34,11 +34,16 @@ def incomplete_schema_id(input_bytes: Bytes) -> Bytes: return Bytes(input_bytes[:1]) -def unsupported_schema_id(input_bytes: Bytes) -> Bytes: +def unsupported_schema_revision(input_bytes: Bytes) -> Bytes: """Replace the schema id with an unsupported Amsterdam revision.""" return Bytes(b"\x15\x02" + input_bytes[2:]) +def unsupported_schema_fork(input_bytes: Bytes) -> Bytes: + """Replace the schema id with an unsupported fork.""" + return Bytes(b"\x16\x01" + input_bytes[2:]) + + def missing_ssz_body(input_bytes: Bytes) -> Bytes: """Keep only the schema id.""" return Bytes(input_bytes[:2]) @@ -74,7 +79,11 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: [ pytest.param(empty_input_bytes, id="empty_input_bytes"), pytest.param(incomplete_schema_id, id="incomplete_schema_id"), - pytest.param(unsupported_schema_id, id="unsupported_schema_id"), + pytest.param( + unsupported_schema_revision, + id="unsupported_schema_revision", + ), + pytest.param(unsupported_schema_fork, id="unsupported_schema_fork"), pytest.param(missing_ssz_body, id="missing_ssz_body"), pytest.param(truncated_ssz_body, id="truncated_ssz_body"), pytest.param(trailing_garbage, id="trailing_garbage"), From 8427d0e73004c8a9540439978dfab4ea1ecf131f Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 10 Jul 2026 09:38:25 -0300 Subject: [PATCH 235/265] remove `fork` redundant field --- .../specs/blockchain_stateless.py | 2 - src/ethereum/forks/amsterdam/stateless.py | 60 ++++++++----------- .../forks/amsterdam/stateless_guest.py | 2 - .../forks/amsterdam/stateless_host.py | 2 - src/ethereum/forks/amsterdam/stateless_ssz.py | 28 +-------- .../test_witness_validation_chain_config.py | 6 +- tests/json_loader/test_stateless_guest.py | 4 +- vulture_whitelist.py | 3 - 8 files changed, 29 insertions(+), 78 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index aa949e2c54d..5de21f181cf 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -1053,7 +1053,6 @@ def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: """ Return whether output is the invalid stateless input sentinel. """ - from ethereum.forks.amsterdam.stateless import ProtocolFork from ethereum_types.numeric import U64 chain_config = stateless_output.chain_config @@ -1063,7 +1062,6 @@ def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: not stateless_output.successful_validation and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 and chain_config.chain_id == U64(0) - and active_fork.fork == ProtocolFork.Frontier and activation.block_number is None and activation.timestamp is None ) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index bc21ddc0c06..ed581afa150 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -3,7 +3,7 @@ """ from dataclasses import dataclass -from enum import StrEnum +from enum import IntEnum from typing import List, Sequence, Tuple, final from ethereum_rlp import rlp @@ -78,32 +78,32 @@ class NewPayloadRequestHeader: execution_requests: ExecutionRequests -class ProtocolFork(StrEnum): +class ProtocolFork(IntEnum): """ - Semantic execution-layer fork names understood by stateless inputs. + Stable execution-layer fork identifiers used by stateless schemas. """ - Frontier = "Frontier" - Homestead = "Homestead" - DAOFork = "DAOFork" - TangerineWhistle = "TangerineWhistle" - SpuriousDragon = "SpuriousDragon" - Byzantium = "Byzantium" - StPetersburg = "StPetersburg" - Istanbul = "Istanbul" - MuirGlacier = "MuirGlacier" - Berlin = "Berlin" - London = "London" - ArrowGlacier = "ArrowGlacier" - GrayGlacier = "GrayGlacier" - Paris = "Paris" - Shanghai = "Shanghai" - Cancun = "Cancun" - Prague = "Prague" - Osaka = "Osaka" - BPO1 = "BPO1" - BPO2 = "BPO2" - Amsterdam = "Amsterdam" + Frontier = 0x01 + Homestead = 0x02 + DAOFork = 0x03 + TangerineWhistle = 0x04 + SpuriousDragon = 0x05 + Byzantium = 0x06 + StPetersburg = 0x07 + Istanbul = 0x08 + MuirGlacier = 0x09 + Berlin = 0x0A + London = 0x0B + ArrowGlacier = 0x0C + GrayGlacier = 0x0D + Paris = 0x0E + Shanghai = 0x0F + Cancun = 0x10 + Prague = 0x11 + Osaka = 0x12 + BPO1 = 0x13 + BPO2 = 0x14 + Amsterdam = 0x15 class ChainConfigValidationError(Exception): @@ -124,12 +124,6 @@ class InvalidForkActivationError(ChainConfigValidationError): """ -class UnsupportedForkConfigError(ChainConfigValidationError): - """ - Raised when this guest cannot execute the configured active fork. - """ - - @final @slotted_freezable @dataclass @@ -150,7 +144,6 @@ class ForkConfig: Per-fork configuration needed to interpret stateless inputs. """ - fork: ProtocolFork activation: ForkActivation @@ -322,11 +315,6 @@ def validate_chain_config( "ChainConfig active_fork is not active for the target payload" ) - if active_fork.fork != ProtocolFork.Amsterdam: - raise UnsupportedForkConfigError( - f"Amsterdam stateless guest cannot execute {active_fork.fork}" - ) - return active_fork diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index f0ab991d4a7..112f23b2bd5 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -11,7 +11,6 @@ ChainConfig, ForkActivation, ForkConfig, - ProtocolFork, StatelessInput, StatelessValidationResult, verify_stateless_new_payload, @@ -61,7 +60,6 @@ def _default_failed_stateless_output() -> StatelessValidationResult: chain_config=ChainConfig( chain_id=U64(0), active_fork=ForkConfig( - fork=ProtocolFork.Frontier, activation=ForkActivation( block_number=None, timestamp=None, diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 3d70fb5b6a2..72b7ad28310 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -20,7 +20,6 @@ ExecutionWitness, ForkActivation, ForkConfig, - ProtocolFork, StatelessInput, StatelessValidationResult, ) @@ -64,7 +63,6 @@ def build_chain_config(chain_id: U64) -> ChainConfig: return ChainConfig( chain_id=chain_id, active_fork=ForkConfig( - fork=ProtocolFork.Amsterdam, activation=ForkActivation( block_number=None, timestamp=U64(0), diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 69dc46e3655..9edeb48a7f5 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -84,11 +84,9 @@ # Stateless guest input bytes are schema-prefixed: # schema_id || encoded_payload -# schema_id is fork_index || schema_revision. fork_index is the 1-based -# ProtocolFork order, with BPO forks counted. Amsterdam is fork 0x15, and -# revision 0x01 uses SSZ encode(SszStatelessInput) for the payload. This -# prefix selects byte decoding and is separate from SszForkConfig.fork. -STATELESS_INPUT_SCHEMA_FORK_INDEX = 0x15 +# schema_id is fork_index || schema_revision. Amsterdam is fork 0x15, and +# revision 0x01 uses SSZ encode(SszStatelessInput) for the payload. +STATELESS_INPUT_SCHEMA_FORK_INDEX = ProtocolFork.Amsterdam STATELESS_INPUT_SCHEMA_REVISION = 0x01 STATELESS_INPUT_SCHEMA_ID = ( STATELESS_INPUT_SCHEMA_FORK_INDEX << 8 @@ -208,7 +206,6 @@ class SszForkActivation(Container): class SszForkConfig(Container): """SSZ container mirroring ``ForkConfig``.""" - fork: uint64 activation: SszForkActivation @@ -239,23 +236,6 @@ class SszStatelessValidationResult(Container): # --- Conversion helpers --- -PROTOCOL_FORKS = tuple(ProtocolFork) - - -def _protocol_fork_to_ssz(fork: ProtocolFork) -> uint64: - """Convert a ProtocolFork to its SSZ enum value.""" - protocol_fork = ProtocolFork(fork) - return uint64(PROTOCOL_FORKS.index(protocol_fork)) - - -def _ssz_to_protocol_fork(ssz_fork: uint64) -> ProtocolFork: - """Convert an SSZ enum value back to a ProtocolFork.""" - try: - return PROTOCOL_FORKS[int(ssz_fork)] - except IndexError as error: - raise ValueError(f"Unknown protocol fork value: {ssz_fork}") from error - - def _withdrawal_to_ssz(w: Withdrawal) -> SszWithdrawal: """Convert a Withdrawal to its SSZ form.""" return SszWithdrawal( @@ -537,7 +517,6 @@ def _fork_config_to_ssz( ) -> SszForkConfig: """Convert a ForkConfig to its SSZ form.""" return SszForkConfig( - fork=_protocol_fork_to_ssz(fork_config.fork), activation=_fork_activation_to_ssz(fork_config.activation), ) @@ -547,7 +526,6 @@ def _ssz_to_fork_config( ) -> ForkConfig: """Convert an SSZ fork config back.""" return ForkConfig( - fork=_ssz_to_protocol_fork(ssz_fork_config.fork), activation=_ssz_to_fork_activation(ssz_fork_config.activation), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py index 5d5fe1dd15a..7397fe85e20 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py @@ -56,10 +56,7 @@ def future_timestamp_activation_chain_config(stateless_input: Any) -> Any: """Move Amsterdam activation one second after the payload timestamp.""" from ethereum_types.numeric import U64 - from ethereum.forks.amsterdam.stateless import ( - ForkActivation, - ProtocolFork, - ) + from ethereum.forks.amsterdam.stateless import ForkActivation payload = stateless_input.new_payload_request.execution_payload chain_config = stateless_input.chain_config @@ -68,7 +65,6 @@ def future_timestamp_activation_chain_config(stateless_input: Any) -> Any: chain_config, active_fork=replace( active_fork, - fork=ProtocolFork.Amsterdam, activation=ForkActivation( block_number=None, timestamp=U64(int(payload.timestamp) + 1), diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 9c9d23ba28f..787897b4515 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -55,7 +55,7 @@ def test_stateless_input_schema_id_identifies_amsterdam_revision() -> None: """Amsterdam stateless input schema id is fork_index || revision.""" - assert tuple(ProtocolFork).index(ProtocolFork.Amsterdam) + 1 == 0x15 + assert STATELESS_INPUT_SCHEMA_FORK_INDEX is ProtocolFork.Amsterdam assert STATELESS_INPUT_SCHEMA_FORK_INDEX == 0x15 assert STATELESS_INPUT_SCHEMA_REVISION == 0x01 assert STATELESS_INPUT_SCHEMA_ID == 0x1501 @@ -132,7 +132,6 @@ def _expected_amsterdam_chain_config(chain_id: U64) -> ChainConfig: return ChainConfig( chain_id=chain_id, active_fork=ForkConfig( - fork=ProtocolFork.Amsterdam, activation=ForkActivation( block_number=None, timestamp=U64(0), @@ -361,7 +360,6 @@ def test_invalid_input_bytes_return_failed_validation(self) -> None: assert result.new_payload_request_root == Hash32(b"\0" * 32) assert not result.successful_validation assert result.chain_config.chain_id == U64(0) - assert result.chain_config.active_fork.fork == ProtocolFork.Frontier assert result.chain_config.active_fork.activation.block_number is None assert result.chain_config.active_fork.activation.timestamp is None diff --git a/vulture_whitelist.py b/vulture_whitelist.py index ca4e5698874..6ef82c2762b 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -237,6 +237,3 @@ ProtocolFork.Osaka ProtocolFork.BPO1 ProtocolFork.BPO2 -ProtocolFork.BPO3 -ProtocolFork.BPO4 -ProtocolFork.BPO5 From 24e65da409d3f0796e5daceef69073719a457994 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 10 Jul 2026 12:50:00 -0300 Subject: [PATCH 236/265] fix(tests): add test for rejected transactions omitting public keys --- .../forks/amsterdam/stateless_host.py | 10 +++- tests/json_loader/test_stateless_guest.py | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 72b7ad28310..108758bb865 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -9,6 +9,7 @@ from ethereum_types.numeric import U64 from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidSignatureError from .block_access_lists import BlockAccessList from .blocks import Block @@ -108,7 +109,14 @@ def build_stateless_input( tx_obj = decode_transaction(tx) except Exception: continue - public_keys.append(recover_transaction_public_key(chain_id, tx_obj)) + try: + public_keys.append( + recover_transaction_public_key(chain_id, tx_obj) + ) + except InvalidSignatureError: + # Rejected transactions remain in invalid payloads passed to the + # guest, but cannot provide a recoverable public key. + continue if isinstance(tx_obj, BlobTransaction): versioned_hashes.extend(tx_obj.blob_versioned_hashes) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 787897b4515..b29d0b8f4f9 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -4,6 +4,7 @@ from typing import Tuple import pytest +from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U64, U256, Uint @@ -48,6 +49,7 @@ STATELESS_INPUT_SCHEMA_REVISION, stateless_input_to_ssz, ) +from ethereum.forks.amsterdam.transactions import LegacyTransaction from ethereum.state import Address, Root _RNG = random.Random(0xDEADBEEF) @@ -217,6 +219,54 @@ def test_includes_amsterdam_chain_config(self) -> None: ) assert stateless_input.chain_config == chain_config + @pytest.mark.parametrize( + ("v", "r", "s"), + [ + pytest.param(27, 0, 1, id="invalid-signature"), + pytest.param(39, 1, 1, id="wrong-chain-id"), + ], + ) + def test_rejected_transaction_omits_public_key( + self, v: int, r: int, s: int + ) -> None: + """Keep rejected transactions without requiring a public key.""" + tx = LegacyTransaction( + nonce=U256(0), + gas_price=Uint(1), + gas=Uint(21_000), + to=Address(b"\x00" * 20), + value=U256(0), + data=Bytes(b""), + v=U256(v), + r=U256(r), + s=U256(s), + ) + block = Block( + header=_make_header(), + transactions=(tx,), + ommers=(), + withdrawals=(), + ) + stateless_input = build_stateless_input( + block, + execution_witness=ExecutionWitness( + state=(), + codes=(), + headers=(), + ), + execution_requests=ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + ), + block_access_list=[], + chain_id=U64(1), + ) + + payload = stateless_input.new_payload_request.execution_payload + assert payload.transactions == (Bytes(rlp.encode(tx)),) + assert stateless_input.public_keys == () + class TestSerializeStatelessInput: """Test serialize_stateless_input.""" From 3f2859dc162243997f9f460fea5451a7ce7011b5 Mon Sep 17 00:00:00 2001 From: jsign Date: Sat, 11 Jul 2026 17:58:01 -0300 Subject: [PATCH 237/265] fill stateless output bytes always with python spec. remain to find workaround for external filling Signed-off-by: jsign --- .../specs/blockchain_stateless.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 5de21f181cf..1fdd999cd07 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -889,15 +889,10 @@ def build_amsterdam_stateless_artifacts_from_t8n( from ethereum.forks.amsterdam.execution_engine.requests import ( decode_execution_requests, ) - from ethereum.forks.amsterdam.stateless import ( - StatelessValidationResult, - compute_new_payload_request_root, - ) from ethereum.forks.amsterdam.stateless_guest import ( - serialize_stateless_output, + run_stateless_guest, ) from ethereum.forks.amsterdam.stateless_host import ( - build_chain_config, build_stateless_input, serialize_stateless_input, ) @@ -954,16 +949,7 @@ def build_amsterdam_stateless_artifacts_from_t8n( chain_id=U64(chain_id), ) stateless_input_bytes = serialize_stateless_input(stateless_input) - # TODO: Replace this shortcut with a client-provided validation result - # or a real stateless guest run once Geth witness generation is complete. - stateless_output = StatelessValidationResult( - new_payload_request_root=compute_new_payload_request_root( - stateless_input - ), - successful_validation=True, - chain_config=build_chain_config(U64(chain_id)), - ) - stateless_output_bytes = serialize_stateless_output(stateless_output) + stateless_output_bytes = run_stateless_guest(stateless_input_bytes) return ( Bytes(bytes(stateless_input_bytes)), Bytes(bytes(stateless_output_bytes)), From 36eaf03382e331cef78b756bdff250403be9eeb3 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 13 Jul 2026 12:20:48 -0300 Subject: [PATCH 238/265] feat: add builder deposit and exit request types with encoding/decoding support --- .../amsterdam/execution_engine/requests.py | 100 +++++++++++- src/ethereum/forks/amsterdam/stateless_ssz.py | 83 ++++++++++ tests/json_loader/test_execution_requests.py | 145 ++++++++++++++++++ tests/json_loader/test_stateless_guest.py | 28 ++++ 4 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 tests/json_loader/test_execution_requests.py diff --git a/src/ethereum/forks/amsterdam/execution_engine/requests.py b/src/ethereum/forks/amsterdam/execution_engine/requests.py index 6b84b46f619..326a94a672a 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/requests.py +++ b/src/ethereum/forks/amsterdam/execution_engine/requests.py @@ -2,11 +2,8 @@ Typed execution-layer requests and engine-API wire-form codecs. The consensus layer defines ``ExecutionRequests`` as a typed Container -holding ``deposits``, ``withdrawals``, and ``consolidations`` lists. At -the engine-API boundary, that Container is flattened to a tuple of -opaque blobs of the form ``TYPE_BYTE || concat(serialize(item))``, -ordered ascending by type. This module exposes the typed Container and -the boundary codecs (mirrors CL's ``get_execution_requests_list()``). +holding deposit, withdrawal, consolidation, builder deposit, and builder exit +lists. """ from dataclasses import dataclass @@ -20,6 +17,8 @@ from ethereum.state import Address from ..requests import ( + BUILDER_DEPOSIT_REQUEST_TYPE, + BUILDER_EXIT_REQUEST_TYPE, CONSOLIDATION_REQUEST_TYPE, DEPOSIT_REQUEST_TYPE, WITHDRAWAL_REQUEST_TYPE, @@ -28,6 +27,8 @@ DEPOSIT_REQUEST_SIZE = 48 + 32 + 8 + 96 + 8 WITHDRAWAL_REQUEST_SIZE = 20 + 48 + 8 CONSOLIDATION_REQUEST_SIZE = 20 + 48 + 48 +BUILDER_DEPOSIT_REQUEST_SIZE = 184 +BUILDER_EXIT_REQUEST_SIZE = 68 @final @@ -65,6 +66,28 @@ class ConsolidationRequest: target_pubkey: Bytes48 +@final +@slotted_freezable +@dataclass +class BuilderDepositRequest: + """A single EIP-8282 builder deposit request.""" + + pubkey: Bytes48 + withdrawal_credentials: Bytes32 + amount: U64 + signature: Bytes96 + + +@final +@slotted_freezable +@dataclass +class BuilderExitRequest: + """A single EIP-8282 builder exit request.""" + + source_address: Address + pubkey: Bytes48 + + @final @slotted_freezable @dataclass @@ -78,6 +101,8 @@ class ExecutionRequests: deposits: Tuple[DepositRequest, ...] withdrawals: Tuple[WithdrawalRequest, ...] consolidations: Tuple[ConsolidationRequest, ...] + builder_deposits: Tuple[BuilderDepositRequest, ...] + builder_exits: Tuple[BuilderExitRequest, ...] def _encode_deposit(d: DepositRequest) -> Bytes: @@ -106,6 +131,19 @@ def _encode_consolidation(c: ConsolidationRequest) -> Bytes: ) +def _encode_builder_deposit(b: BuilderDepositRequest) -> Bytes: + return Bytes( + bytes(b.pubkey) + + bytes(b.withdrawal_credentials) + + bytes(b.amount.to_le_bytes8()) + + bytes(b.signature) + ) + + +def _encode_builder_exit(b: BuilderExitRequest) -> Bytes: + return Bytes(bytes(b.source_address) + bytes(b.pubkey)) + + def _decode_deposit(payload: Bytes) -> DepositRequest: return DepositRequest( pubkey=Bytes48(payload[0:48]), @@ -132,6 +170,22 @@ def _decode_consolidation(payload: Bytes) -> ConsolidationRequest: ) +def _decode_builder_deposit(payload: Bytes) -> BuilderDepositRequest: + return BuilderDepositRequest( + pubkey=Bytes48(payload[0:48]), + withdrawal_credentials=Bytes32(payload[48:80]), + amount=U64.from_le_bytes(payload[80:88]), + signature=Bytes96(payload[88:184]), + ) + + +def _decode_builder_exit(payload: Bytes) -> BuilderExitRequest: + return BuilderExitRequest( + source_address=Address(payload[0:20]), + pubkey=Bytes48(payload[20:68]), + ) + + def encode_execution_requests( requests: ExecutionRequests, ) -> Tuple[Bytes, ...]: @@ -155,6 +209,16 @@ def encode_execution_requests( _encode_consolidation(c) for c in requests.consolidations ) output.append(Bytes(CONSOLIDATION_REQUEST_TYPE + body)) + if requests.builder_deposits: + body = b"".join( + _encode_builder_deposit(b) for b in requests.builder_deposits + ) + output.append(Bytes(BUILDER_DEPOSIT_REQUEST_TYPE + body)) + if requests.builder_exits: + body = b"".join( + _encode_builder_exit(b) for b in requests.builder_exits + ) + output.append(Bytes(BUILDER_EXIT_REQUEST_TYPE + body)) return tuple(output) @@ -171,6 +235,8 @@ def decode_execution_requests( deposits: Tuple[DepositRequest, ...] = () withdrawals: Tuple[WithdrawalRequest, ...] = () consolidations: Tuple[ConsolidationRequest, ...] = () + builder_deposits: Tuple[BuilderDepositRequest, ...] = () + builder_exits: Tuple[BuilderExitRequest, ...] = () last_type = -1 for blob in wire: @@ -212,6 +278,28 @@ def decode_execution_requests( ) for i in range(0, len(body), CONSOLIDATION_REQUEST_SIZE) ) + elif type_byte == BUILDER_DEPOSIT_REQUEST_TYPE: + if len(body) % BUILDER_DEPOSIT_REQUEST_SIZE != 0: + raise InvalidBlock( + "Invalid builder deposit request payload length" + ) + builder_deposits = tuple( + _decode_builder_deposit( + Bytes(body[i : i + BUILDER_DEPOSIT_REQUEST_SIZE]) + ) + for i in range(0, len(body), BUILDER_DEPOSIT_REQUEST_SIZE) + ) + elif type_byte == BUILDER_EXIT_REQUEST_TYPE: + if len(body) % BUILDER_EXIT_REQUEST_SIZE != 0: + raise InvalidBlock( + "Invalid builder exit request payload length" + ) + builder_exits = tuple( + _decode_builder_exit( + Bytes(body[i : i + BUILDER_EXIT_REQUEST_SIZE]) + ) + for i in range(0, len(body), BUILDER_EXIT_REQUEST_SIZE) + ) else: raise InvalidBlock( f"Unknown execution request type byte {type_byte!r}" @@ -221,4 +309,6 @@ def decode_execution_requests( deposits=deposits, withdrawals=withdrawals, consolidations=consolidations, + builder_deposits=builder_deposits, + builder_exits=builder_exits, ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 9edeb48a7f5..f0f4db58fd0 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -20,6 +20,8 @@ from .blocks import Withdrawal from .execution_engine.requests import ( + BuilderDepositRequest, + BuilderExitRequest, ConsolidationRequest, DepositRequest, ExecutionRequests, @@ -50,6 +52,8 @@ MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**13 MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = 2**4 MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD = 2**1 +MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**6 +MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD = 2**4 # Stateless witness resource bounds. These are local limits chosen for this # schema, not consensus-spec SSZ constants. @@ -167,6 +171,22 @@ class SszConsolidationRequest(Container): target_pubkey: ByteVector[48] +class SszBuilderDepositRequest(Container): + """SSZ container mirroring ``BuilderDepositRequest``.""" + + pubkey: ByteVector[48] + withdrawal_credentials: Bytes32 + amount: uint64 + signature: ByteVector[96] + + +class SszBuilderExitRequest(Container): + """SSZ container mirroring ``BuilderExitRequest``.""" + + source_address: ByteVector[20] + pubkey: ByteVector[48] + + class SszExecutionRequests(Container): """SSZ container mirroring ``ExecutionRequests``.""" @@ -177,6 +197,12 @@ class SszExecutionRequests(Container): consolidations: SszList[ SszConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD ] + builder_deposits: SszList[ + SszBuilderDepositRequest, MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD + ] + builder_exits: SszList[ + SszBuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD + ] class SszNewPayloadRequest(Container): @@ -386,6 +412,50 @@ def _ssz_to_consolidation_request( ) +def _builder_deposit_request_to_ssz( + b: BuilderDepositRequest, +) -> SszBuilderDepositRequest: + """Convert a BuilderDepositRequest to its SSZ form.""" + return SszBuilderDepositRequest( + pubkey=ByteVector[48](bytes(b.pubkey)), + withdrawal_credentials=Bytes32(bytes(b.withdrawal_credentials)), + amount=uint64(int(b.amount)), + signature=ByteVector[96](bytes(b.signature)), + ) + + +def _ssz_to_builder_deposit_request( + sb: SszBuilderDepositRequest, +) -> BuilderDepositRequest: + """Convert an SSZ builder deposit request back.""" + return BuilderDepositRequest( + pubkey=Bytes48(bytes(sb.pubkey)), + withdrawal_credentials=Bytes32(bytes(sb.withdrawal_credentials)), + amount=U64(sb.amount), + signature=Bytes96(bytes(sb.signature)), + ) + + +def _builder_exit_request_to_ssz( + b: BuilderExitRequest, +) -> SszBuilderExitRequest: + """Convert a BuilderExitRequest to its SSZ form.""" + return SszBuilderExitRequest( + source_address=ByteVector[20](bytes(b.source_address)), + pubkey=ByteVector[48](bytes(b.pubkey)), + ) + + +def _ssz_to_builder_exit_request( + sb: SszBuilderExitRequest, +) -> BuilderExitRequest: + """Convert an SSZ builder exit request back.""" + return BuilderExitRequest( + source_address=Address(bytes(sb.source_address)), + pubkey=Bytes48(bytes(sb.pubkey)), + ) + + def _execution_requests_to_ssz( er: ExecutionRequests, ) -> SszExecutionRequests: @@ -400,6 +470,13 @@ def _execution_requests_to_ssz( consolidations=SszList[ SszConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD ](_consolidation_request_to_ssz(c) for c in er.consolidations), + builder_deposits=SszList[ + SszBuilderDepositRequest, + MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD, + ](_builder_deposit_request_to_ssz(b) for b in er.builder_deposits), + builder_exits=SszList[ + SszBuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD + ](_builder_exit_request_to_ssz(b) for b in er.builder_exits), ) @@ -415,6 +492,12 @@ def _ssz_to_execution_requests( consolidations=tuple( _ssz_to_consolidation_request(sc) for sc in ser.consolidations ), + builder_deposits=tuple( + _ssz_to_builder_deposit_request(sb) for sb in ser.builder_deposits + ), + builder_exits=tuple( + _ssz_to_builder_exit_request(sb) for sb in ser.builder_exits + ), ) diff --git a/tests/json_loader/test_execution_requests.py b/tests/json_loader/test_execution_requests.py new file mode 100644 index 00000000000..767d3e14e7d --- /dev/null +++ b/tests/json_loader/test_execution_requests.py @@ -0,0 +1,145 @@ +"""Tests for Amsterdam typed execution request codecs.""" + +import pytest +from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 +from ethereum_types.numeric import U64 + +from ethereum.exceptions import InvalidBlock +from ethereum.forks.amsterdam.execution_engine.requests import ( + BuilderDepositRequest, + BuilderExitRequest, + ConsolidationRequest, + DepositRequest, + ExecutionRequests, + WithdrawalRequest, + decode_execution_requests, + encode_execution_requests, +) +from ethereum.state import Address + + +def _builder_deposit() -> BuilderDepositRequest: + return BuilderDepositRequest( + pubkey=Bytes48(b"\x11" * 48), + withdrawal_credentials=Bytes32(b"\x22" * 32), + amount=U64(0x0102030405060708), + signature=Bytes96(b"\x33" * 96), + ) + + +def _builder_exit() -> BuilderExitRequest: + return BuilderExitRequest( + source_address=Address(b"\x44" * 20), + pubkey=Bytes48(b"\x55" * 48), + ) + + +def _all_execution_requests() -> ExecutionRequests: + return ExecutionRequests( + deposits=( + DepositRequest( + pubkey=Bytes48(b"\x01" * 48), + withdrawal_credentials=Bytes32(b"\x02" * 32), + amount=U64(3), + signature=Bytes96(b"\x04" * 96), + index=U64(5), + ), + ), + withdrawals=( + WithdrawalRequest( + source_address=Address(b"\x06" * 20), + validator_pubkey=Bytes48(b"\x07" * 48), + amount=U64(8), + ), + ), + consolidations=( + ConsolidationRequest( + source_address=Address(b"\x09" * 20), + source_pubkey=Bytes48(b"\x0a" * 48), + target_pubkey=Bytes48(b"\x0b" * 48), + ), + ), + builder_deposits=(_builder_deposit(),), + builder_exits=(_builder_exit(),), + ) + + +def test_builder_deposit_encode_decode() -> None: + """Builder deposit amounts use little-endian wire encoding.""" + request = _builder_deposit() + requests = ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(request,), + builder_exits=(), + ) + + wire = encode_execution_requests(requests) + + assert wire == ( + Bytes( + b"\x03" + + bytes(request.pubkey) + + bytes(request.withdrawal_credentials) + + b"\x08\x07\x06\x05\x04\x03\x02\x01" + + bytes(request.signature) + ), + ) + assert decode_execution_requests(wire) == requests + + +def test_builder_exit_encode_decode() -> None: + """Builder exits encode the source address followed by the pubkey.""" + request = _builder_exit() + requests = ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(request,), + ) + + wire = encode_execution_requests(requests) + + assert wire == ( + Bytes(b"\x04" + bytes(request.source_address) + bytes(request.pubkey)), + ) + assert decode_execution_requests(wire) == requests + + +def test_all_execution_request_types_roundtrip_in_order() -> None: + """All five request types round trip in strict ascending order.""" + requests = _all_execution_requests() + + wire = encode_execution_requests(requests) + + assert tuple(blob[0] for blob in wire) == (0, 1, 2, 3, 4) + assert decode_execution_requests(wire) == requests + + +@pytest.mark.parametrize( + ("type_byte", "invalid_payload_size", "message"), + [ + pytest.param(b"\x03", 183, "builder deposit", id="builder-deposit"), + pytest.param(b"\x04", 67, "builder exit", id="builder-exit"), + ], +) +def test_decode_rejects_invalid_builder_request_payload_length( + type_byte: bytes, + invalid_payload_size: int, + message: str, +) -> None: + """Builder request payloads must contain whole wire records.""" + wire = (Bytes(type_byte + b"\x00" * invalid_payload_size),) + + with pytest.raises(InvalidBlock, match=message): + decode_execution_requests(wire) + + +def test_decode_rejects_non_ascending_builder_request_types() -> None: + """Builder request types cannot be duplicated or misordered.""" + wire = encode_execution_requests(_all_execution_requests()) + + with pytest.raises(InvalidBlock, match="strict ascending type order"): + decode_execution_requests((*wire[:3], wire[4], wire[3])) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index b29d0b8f4f9..0f5d39ab6e0 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -12,6 +12,8 @@ from ethereum.forks.amsterdam.block_access_lists import BlockAccessList from ethereum.forks.amsterdam.blocks import Block, Header from ethereum.forks.amsterdam.execution_engine.requests import ( + BuilderDepositRequest, + BuilderExitRequest, DepositRequest, ExecutionRequests, ) @@ -152,6 +154,22 @@ def _make_deposit_request() -> DepositRequest: ) +def _make_builder_deposit_request() -> BuilderDepositRequest: + return BuilderDepositRequest( + pubkey=Bytes48(_rb(48)), + withdrawal_credentials=Bytes32(_rb(32)), + amount=U64(_RNG.randint(0, 2**64 - 1)), + signature=Bytes96(_rb(96)), + ) + + +def _make_builder_exit_request() -> BuilderExitRequest: + return BuilderExitRequest( + source_address=Address(_rb(20)), + pubkey=Bytes48(_rb(48)), + ) + + def _make_stateless_input() -> StatelessInput: versioned_hashes: Tuple[Hash32, ...] = (Hash32(_rb(32)), Hash32(_rb(32))) return StatelessInput( @@ -166,6 +184,8 @@ def _make_stateless_input() -> StatelessInput: ), withdrawals=(), consolidations=(), + builder_deposits=(_make_builder_deposit_request(),), + builder_exits=(_make_builder_exit_request(),), ), ), witness=ExecutionWitness( @@ -213,6 +233,8 @@ def test_includes_amsterdam_chain_config(self) -> None: deposits=(), withdrawals=(), consolidations=(), + builder_deposits=(), + builder_exits=(), ), block_access_list=block_access_list, chain_id=U64(123), @@ -258,6 +280,8 @@ def test_rejected_transaction_omits_public_key( deposits=(), withdrawals=(), consolidations=(), + builder_deposits=(), + builder_exits=(), ), block_access_list=[], chain_id=U64(1), @@ -290,6 +314,8 @@ def test_empty_witness(self) -> None: deposits=(), withdrawals=(), consolidations=(), + builder_deposits=(), + builder_exits=(), ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), @@ -336,6 +362,8 @@ def test_empty_witness(self) -> None: deposits=(), withdrawals=(), consolidations=(), + builder_deposits=(), + builder_exits=(), ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), From c9ae13645b6a1f57e4255fd1594b971e87f2e6b8 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 13 Jul 2026 12:21:04 -0300 Subject: [PATCH 239/265] test: skip stateless validation for extra builder deposit and exit requests that violate SSZ limits --- .../test_modified_builder_contract.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py b/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py index ead0b94e08b..3ad43135ac4 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py @@ -121,12 +121,14 @@ def run_modified_requests_test( Spec.MAX_DEPOSIT_REQUESTS_PER_BLOCK + 1 ), id="max_plus_1_builder_deposit_requests", + marks=pytest.mark.skip_stateless_validation, ), pytest.param( builder_deposit_list_with_custom_fee( Spec.MAX_DEPOSIT_REQUESTS_PER_BLOCK + 2 ), id="max_plus_2_builder_deposit_requests", + marks=pytest.mark.skip_stateless_validation, ), ], ) @@ -172,12 +174,14 @@ def test_extra_builder_deposits( Spec.MAX_EXIT_REQUESTS_PER_BLOCK + 1 ), id="max_plus_1_builder_exit_requests", + marks=pytest.mark.skip_stateless_validation, ), pytest.param( builder_exit_list_with_custom_fee( Spec.MAX_EXIT_REQUESTS_PER_BLOCK + 2 ), id="max_plus_2_builder_exit_requests", + marks=pytest.mark.skip_stateless_validation, ), ], ) From 1399cbea37dfb17634c51d0b3ab6cf46c44f4c50 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 13 Jul 2026 12:31:49 -0300 Subject: [PATCH 240/265] fix: remove trailing whitespace in docstring of requests.py --- src/ethereum/forks/amsterdam/execution_engine/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/execution_engine/requests.py b/src/ethereum/forks/amsterdam/execution_engine/requests.py index 326a94a672a..77c2f749014 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/requests.py +++ b/src/ethereum/forks/amsterdam/execution_engine/requests.py @@ -3,7 +3,7 @@ The consensus layer defines ``ExecutionRequests`` as a typed Container holding deposit, withdrawal, consolidation, builder deposit, and builder exit -lists. +lists. """ from dataclasses import dataclass From db3cfcd5237b7302c7b68e8578a6bd701c1e52ec Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 15 Jul 2026 13:14:31 -0300 Subject: [PATCH 241/265] feat: implement temporary trust path for external benchmark filling in stateless artifacts --- .../src/execution_testing/specs/blockchain.py | 23 +++++++++++++++++++ .../specs/blockchain_stateless.py | 18 +++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 60d33bbd597..f777f66910c 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -35,6 +35,7 @@ from execution_testing.client_clis import ( BlockExceptionWithMessage, ClientBackend, + ExecutionSpecsTransitionTool, FillerBackend, LazyAlloc, Result, @@ -1107,6 +1108,28 @@ def generate_block_data( parent_hash=header.parent_hash, execution_witness=t8n_witness, ) + missing_stateless_artifacts = ( + not stateless_options.skip_validation + and t8n_witness is not None + and bal is not None + and ( + transition_tool_output.result.stateless_input_bytes is None + or transition_tool_output.result.stateless_output_bytes is None + ) + ) + if missing_stateless_artifacts: + # Temporary trust path for external benchmark filling until Geth + # emits both stateless byte fields. + assert not isinstance(t8n, ExecutionSpecsTransitionTool), ( + "EELS must provide stateless input and output bytes" + ) + assert self.operation_mode == OpMode.BENCHMARKING, ( + "Missing stateless artifacts are only supported for external " + "benchmark fills" + ) + assert block.exception is None, ( + "Missing stateless artifacts require a valid benchmark block" + ) stateless_artifacts = stateless_artifacts_from_t8n( options=stateless_options, artifacts=stateless_artifacts, diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 1fdd999cd07..b26b6964b78 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -889,10 +889,15 @@ def build_amsterdam_stateless_artifacts_from_t8n( from ethereum.forks.amsterdam.execution_engine.requests import ( decode_execution_requests, ) + from ethereum.forks.amsterdam.stateless import ( + StatelessValidationResult, + compute_new_payload_request_root, + ) from ethereum.forks.amsterdam.stateless_guest import ( - run_stateless_guest, + serialize_stateless_output, ) from ethereum.forks.amsterdam.stateless_host import ( + build_chain_config, build_stateless_input, serialize_stateless_input, ) @@ -949,7 +954,16 @@ def build_amsterdam_stateless_artifacts_from_t8n( chain_id=U64(chain_id), ) stateless_input_bytes = serialize_stateless_input(stateless_input) - stateless_output_bytes = run_stateless_guest(stateless_input_bytes) + # Temporary trust path for external benchmark filling until Geth emits + # both stateless byte fields. + stateless_output = StatelessValidationResult( + new_payload_request_root=compute_new_payload_request_root( + stateless_input + ), + successful_validation=True, + chain_config=build_chain_config(U64(chain_id)), + ) + stateless_output_bytes = serialize_stateless_output(stateless_output) return ( Bytes(bytes(stateless_input_bytes)), Bytes(bytes(stateless_output_bytes)), From b6ba0194b5bda0df45551f33346ad86901dfdb4b Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 15 Jul 2026 13:35:47 -0300 Subject: [PATCH 242/265] fix: update default Geth branch in benchmark workflow to match current development branch --- .github/workflows/benchmark-fill-parity.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/benchmark-fill-parity.yaml b/.github/workflows/benchmark-fill-parity.yaml index 8b79b9c9fb6..bd4dcfdac75 100644 --- a/.github/workflows/benchmark-fill-parity.yaml +++ b/.github/workflows/benchmark-fill-parity.yaml @@ -13,7 +13,7 @@ on: description: "Geth branch to build" required: false type: string - default: "jsign-master-t8n-fill-executionwitness" + default: "jsign-glamsterdam-devnet-7-t8n-zkevm" benchmark_file: description: "Benchmark test file to fill" required: false @@ -36,7 +36,7 @@ concurrency: env: GETH_REPO: ${{ inputs.geth_repo || 'jsign/go-ethereum' }} - GETH_BRANCH: ${{ inputs.geth_branch || 'jsign-master-t8n-fill-executionwitness' }} + GETH_BRANCH: ${{ inputs.geth_branch || 'jsign-glamsterdam-devnet-7-t8n-zkevm' }} BENCHMARK_FILE: ${{ inputs.benchmark_file || 'tests/benchmark/compute/instruction/test_memory.py' }} FILL_FORK: ${{ inputs.fill_fork || 'Amsterdam' }} XDIST_WORKERS: ${{ inputs.xdist_workers || 'auto' }} From 8093f5818bd5ea0df3a2d07c10f9ac3829def192 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 15 Jul 2026 13:52:14 -0300 Subject: [PATCH 243/265] fix: update benchmark fork from Osaka to Amsterdam in feature.yaml --- .github/configs/feature.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 50c43078adf..2338dc856b9 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -11,12 +11,12 @@ tests: benchmark: evm-type: benchmark - fill-params: --fork=Osaka --generate-all-formats --gas-benchmark-values 1,5,10,30,60,100,150 ./tests/benchmark/compute --maxprocesses=30 --dist=worksteal + fill-params: --fork=Amsterdam --generate-all-formats --gas-benchmark-values 1,5,10,30,60,100,150 ./tests/benchmark/compute --maxprocesses=30 --dist=worksteal feature_only: true benchmark_fast: evm-type: benchmark - fill-params: --fork=Osaka --generate-all-formats --gas-benchmark-values 100 ./tests/benchmark/compute + fill-params: --fork=Amsterdam --generate-all-formats --gas-benchmark-values 60 ./tests/benchmark/compute feature_only: true # Shared entry for all `-devnet` releases; matched by `-devnet` suffix. From ace3eef44691bdf68383781f83829c5b312ae261 Mon Sep 17 00:00:00 2001 From: jsign Date: Sat, 25 Jul 2026 19:21:27 -0300 Subject: [PATCH 244/265] test: add stateless input blob versioned-hash validation tests --- .../test_stateless_input_versioned_hashes.py | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py new file mode 100644 index 00000000000..6edff2846c5 --- /dev/null +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_versioned_hashes.py @@ -0,0 +1,298 @@ +"""Stateless input blob versioned-hash validation tests.""" + +from dataclasses import replace +from typing import Any, Callable, Tuple + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytes, + Fork, + Transaction, + add_kzg_version, +) + +from .gas_helpers import empty_account_value_transfer_gas_limit + +pytestmark = pytest.mark.valid_from("Amsterdam") + +REFERENCE_SPEC_GIT_PATH = "N/A" +REFERENCE_SPEC_VERSION = "N/A" + +StatelessInputBytesModifier = Callable[[Bytes], Bytes] +VersionedHashesBuilder = Callable[[Tuple[Any, ...]], Tuple[Any, ...]] + +# Version byte prefixed to every blob versioned hash. +BLOB_COMMITMENT_VERSION_KZG = 1 + +# The canonical block below carries two blob transactions: the first +# declares two blobs, the second declares one. The declared versioned hash +# tuple is therefore three entries long, with the transaction boundary +# between index 1 and index 2. +FIRST_TRANSACTION_BLOB_HASHES = add_kzg_version( + [0, 1], BLOB_COMMITMENT_VERSION_KZG +) +SECOND_TRANSACTION_BLOB_HASHES = add_kzg_version( + [2], BLOB_COMMITMENT_VERSION_KZG +) +CANONICAL_BLOB_HASH_COUNT = len(FIRST_TRANSACTION_BLOB_HASHES) + len( + SECOND_TRANSACTION_BLOB_HASHES +) + +# A KZG-versioned hash no canonical blob transaction declares. +UNRELATED_BLOB_HASH = add_kzg_version([0xFF], BLOB_COMMITMENT_VERSION_KZG)[0] + + +def replace_versioned_hashes( + build_versioned_hashes: VersionedHashesBuilder, +) -> StatelessInputBytesModifier: + """ + Replace only the declared new payload request versioned hashes. + + Everything else the guest validates -- execution payload, block hash, + witness, chain config, and transaction public keys -- is preserved, and + the result is re-serialized as valid SSZ. A validation failure is + therefore attributable to the versioned hash cross-check rather than to + a decoding error or to some other validation step. + """ + + def modifier(input_bytes: Bytes) -> Bytes: + from ethereum_types.bytes import Bytes as AmsterdamBytes + + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum.forks.amsterdam.stateless_host import ( + serialize_stateless_input, + ) + + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(input_bytes)) + ) + new_payload_request = stateless_input.new_payload_request + modified_input = replace( + stateless_input, + new_payload_request=replace( + new_payload_request, + versioned_hashes=build_versioned_hashes( + tuple(new_payload_request.versioned_hashes) + ), + ), + ) + return Bytes(bytes(serialize_stateless_input(modified_input))) + + return modifier + + +def assert_hash_count( + versioned_hashes: Tuple[Any, ...], + expected_count: int, +) -> None: + """ + Assert the canonical declared hash count before mutating it. + + Without this, a change that stops deriving the hashes from the block + would turn every mutation below into a silent no-op. + """ + if len(versioned_hashes) != expected_count: + raise AssertionError( + f"expected {expected_count} canonical versioned hashes, " + f"got {len(versioned_hashes)}" + ) + + +def swapped( + versioned_hashes: Tuple[Any, ...], + first: int, + second: int, +) -> Tuple[Any, ...]: + """Return the hashes with two entries exchanged.""" + swapped_hashes = list(versioned_hashes) + swapped_hashes[first], swapped_hashes[second] = ( + swapped_hashes[second], + swapped_hashes[first], + ) + return tuple(swapped_hashes) + + +def unchanged_hashes( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Rebuild the canonical hashes unchanged.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return versioned_hashes + + +def replaced_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Overwrite the first declared hash with an unrelated one.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return (UNRELATED_BLOB_HASH,) + versioned_hashes[1:] + + +def removed_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Drop the first declared hash.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return versioned_hashes[1:] + + +def extra_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Append a hash no blob transaction declares.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return versioned_hashes + (UNRELATED_BLOB_HASH,) + + +def cleared_hashes( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Declare no hashes at all despite the payload carrying blobs.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return () + + +def reordered_within_transaction( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Exchange the two hashes belonging to the first blob transaction.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return swapped(versioned_hashes, 0, 1) + + +def reordered_across_transactions( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Exchange hashes across the blob transaction boundary.""" + assert_hash_count(versioned_hashes, CANONICAL_BLOB_HASH_COUNT) + return swapped(versioned_hashes, 1, 2) + + +def sole_unrelated_hash( + versioned_hashes: Tuple[Any, ...], +) -> Tuple[Any, ...]: + """Declare one hash for a payload that carries no blob transactions.""" + assert_hash_count(versioned_hashes, 0) + return (UNRELATED_BLOB_HASH,) + + +@pytest.mark.parametrize( + "build_versioned_hashes,expected_validation_success", + [ + pytest.param(unchanged_hashes, True, id="unchanged_hashes"), + pytest.param(replaced_hash, False, id="replaced_hash"), + pytest.param(removed_hash, False, id="removed_hash"), + pytest.param(extra_hash, False, id="extra_hash"), + pytest.param(cleared_hashes, False, id="cleared_hashes"), + pytest.param( + reordered_within_transaction, + False, + id="reordered_within_transaction", + ), + pytest.param( + reordered_across_transactions, + False, + id="reordered_across_transactions", + ), + ], +) +def test_stateless_input_versioned_hashes( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, + build_versioned_hashes: VersionedHashesBuilder, + expected_validation_success: bool, +) -> None: + """ + Declared versioned hashes must match the payload's blob transactions. + + The guest recomputes the hashes from the blob transactions in the + payload and compares the ordered sequence against the hashes the + consensus layer declared. Only the unmodified sequence validates: a + changed, missing, extra, or reordered entry must be rejected, and the + reordering cases fail even though the declared multiset is unchanged. + """ + recipient = pre.fund_eoa(amount=0) + first_sender = pre.fund_eoa() + second_sender = pre.fund_eoa() + first_tx = Transaction( + ty=3, + sender=first_sender, + to=recipient, + max_fee_per_blob_gas=fork.min_base_fee_per_blob_gas(), + blob_versioned_hashes=FIRST_TRANSACTION_BLOB_HASHES, + ) + second_tx = Transaction( + ty=3, + sender=second_sender, + to=recipient, + max_fee_per_blob_gas=fork.min_base_fee_per_blob_gas(), + blob_versioned_hashes=SECOND_TRANSACTION_BLOB_HASHES, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[first_tx, second_tx], + # The rerun always goes through a modifier, including the + # unchanged case: a bytes modifier is what forces the + # filler to rerun the guest instead of trusting the + # validation flag the artifact builder may hardcode. + stateless_input_bytes_modifier=replace_versioned_hashes( + build_versioned_hashes + ), + expected_stateless_validation_success=( + expected_validation_success + ), + ) + ], + post={ + first_sender: Account(nonce=1), + second_sender: Account(nonce=1), + }, + ) + + +def test_stateless_input_versioned_hashes_without_blob_transactions( + fork: Fork, + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + A payload carrying no blob transactions must declare no hashes. + + This catches guests that skip the cross-check when the payload has + nothing to recompute the hashes from. + """ + sender = pre.fund_eoa() + recipient = pre.fund_eoa(amount=0) + tx = Transaction( + sender=sender, + to=recipient, + value=1, + gas_limit=empty_account_value_transfer_gas_limit(fork), + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + stateless_input_bytes_modifier=replace_versioned_hashes( + sole_unrelated_hash + ), + expected_stateless_validation_success=False, + ) + ], + post={ + sender: Account(nonce=1), + recipient: Account(balance=1), + }, + ) From 19f7bdea3b4d86c4a00cd2936c42bbd5af417b1f Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 28 Jul 2026 10:17:32 -0300 Subject: [PATCH 245/265] feat: change StatelessInput SSZ serialization to be EIP-7688 aligned --- .../plugins/filler/tests/test_filler.py | 24 +++++ .../specs/blockchain_stateless.py | 59 ++++++++++++ src/ethereum/forks/amsterdam/stateless.py | 3 - src/ethereum/forks/amsterdam/stateless_ssz.py | 95 ++++++++----------- .../test_modified_builder_contract.py | 4 - 5 files changed, 121 insertions(+), 64 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 7bd40fc8d87..c90a2b2e920 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1357,16 +1357,31 @@ def test_execution_witness_expected_true_reuses_canonical_stateless_result( fixture = next(iter(fixture_data.values())) block = fixture["blocks"][-1] + from ethereum.crypto.hash import Hash32 + from ethereum.forks.amsterdam.stateless import ( + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes + stateless_input = deserialize_stateless_input( + EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) + ) stateless_output = deserialize_stateless_output( EthereumBytes(bytes.fromhex(block["statelessOutputBytes"][2:])) ) assert stateless_output.successful_validation is True + assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) + assert ( + stateless_output.new_payload_request_root + == compute_new_payload_request_root(stateless_input) + ) def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( @@ -1402,6 +1417,10 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( assert len(block["executionWitness"]["headers"]) == 1 + from ethereum.crypto.hash import Hash32 + from ethereum.forks.amsterdam.stateless import ( + compute_new_payload_request_root, + ) from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, ) @@ -1418,6 +1437,11 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( ) assert stateless_output.successful_validation is False + assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) + assert ( + stateless_output.new_payload_request_root + == compute_new_payload_request_root(stateless_input) + ) assert len(stateless_input.witness.headers) == 1 assert [ "0x" + bytes(header).hex() diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index b26b6964b78..2e5b350c841 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -443,6 +443,14 @@ def finalize_stateless_artifacts( f"want {options.expected_validation_success}" ) + assert_amsterdam_stateless_output_request_root( + fork=fork, + block_number=block_number, + timestamp=timestamp, + stateless_input_bytes=stateless_input_bytes, + stateless_output=stateless_output, + ) + skip_chain_config_assertion = ( options.has_stateless_input_bytes_modifier and stateless_output is not None @@ -1067,6 +1075,57 @@ def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: ) +def assert_amsterdam_stateless_output_request_root( + *, + fork: Fork, + block_number: int, + timestamp: int, + stateless_input_bytes: Bytes | None, + stateless_output: Any | None, +) -> None: + """ + Assert the output commits to the decoded Amsterdam payload request. + """ + if stateless_input_bytes is None or stateless_output is None: + return + + active_fork = fork.fork_at( + block_number=block_number, + timestamp=timestamp, + ) + if active_fork.name() != "Amsterdam": + return + + from ethereum.forks.amsterdam.stateless import ( + compute_new_payload_request_root, + ) + from ethereum.forks.amsterdam.stateless_guest import ( + deserialize_stateless_input, + ) + from ethereum_types.bytes import Bytes as AmsterdamBytes + + try: + stateless_input = deserialize_stateless_input( + AmsterdamBytes(bytes(stateless_input_bytes)) + ) + except Exception as exc: + if is_invalid_stateless_input_sentinel(stateless_output): + return + raise AssertionError( + "Stateless input decoding failed for block " + f"{block_number}, but its output is not the invalid-input sentinel" + ) from exc + + expected_root = compute_new_payload_request_root(stateless_input) + actual_root = stateless_output.new_payload_request_root + if actual_root != expected_root: + raise AssertionError( + "Stateless output new_payload_request_root mismatch for block " + f"{block_number}: got 0x{bytes(actual_root).hex()}, " + f"want 0x{bytes(expected_root).hex()}" + ) + + def _has_execution_witness_modifier( block: StatelessBlockProtocol, ) -> bool: diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index ed581afa150..a639e347c51 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -231,9 +231,6 @@ def compute_new_payload_request_root( ) -> Hash32: """ Compute the request root for a stateless input via SSZ hash tree root. - - TODO: For readability, convert to NewPayloadRequestHeader and - then compute the payload request root. """ from .stateless_ssz import _new_payload_request_to_ssz diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index f0f4db58fd0..2e1069d438d 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -14,6 +14,11 @@ from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector from remerkleable.complex import Container from remerkleable.complex import List as SszList +from remerkleable.progressive import ( + ProgressiveByteList, + ProgressiveContainer, + ProgressiveList, +) from ethereum.crypto.hash import Hash32 from ethereum.state import Address, Root @@ -41,19 +46,8 @@ # --- SSZ max-length constants --- -# Consensus-spec bounds mirrored by NewPayloadRequest and its nested -# containers. These should track the corresponding consensus-spec values. MAX_EXTRA_DATA_BYTES = 32 -MAX_BYTES_PER_TRANSACTION = 2**30 -MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 -MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 -MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 -MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**13 -MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = 2**4 -MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD = 2**1 -MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**6 -MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD = 2**4 # Stateless witness resource bounds. These are local limits chosen for this # schema, not consensus-spec SSZ constants. @@ -119,7 +113,9 @@ class SszWithdrawal(Container): amount: uint64 -class SszExecutionPayload(Container): +class SszExecutionPayload( + ProgressiveContainer(active_fields=[1] * 19) # type: ignore[misc] +): """SSZ container mirroring ``ExecutionPayload``.""" parent_hash: Bytes32 @@ -135,13 +131,11 @@ class SszExecutionPayload(Container): extra_data: ByteList[MAX_EXTRA_DATA_BYTES] base_fee_per_gas: uint256 block_hash: Bytes32 - transactions: SszList[ - ByteList[MAX_BYTES_PER_TRANSACTION], MAX_TRANSACTIONS_PER_PAYLOAD - ] # noqa: E501 - withdrawals: SszList[SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + transactions: ProgressiveList[ProgressiveByteList] + withdrawals: ProgressiveList[SszWithdrawal] blob_gas_used: uint64 excess_blob_gas: uint64 - block_access_list: ByteList[MAX_BYTES_PER_TRANSACTION] + block_access_list: ProgressiveByteList slot_number: uint64 @@ -187,29 +181,23 @@ class SszBuilderExitRequest(Container): pubkey: ByteVector[48] -class SszExecutionRequests(Container): +class SszExecutionRequests( + ProgressiveContainer(active_fields=[1] * 5) # type: ignore[misc] +): """SSZ container mirroring ``ExecutionRequests``.""" - deposits: SszList[SszDepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD] - withdrawals: SszList[ - SszWithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD - ] - consolidations: SszList[ - SszConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD - ] - builder_deposits: SszList[ - SszBuilderDepositRequest, MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD - ] - builder_exits: SszList[ - SszBuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD - ] + deposits: ProgressiveList[SszDepositRequest] + withdrawals: ProgressiveList[SszWithdrawalRequest] + consolidations: ProgressiveList[SszConsolidationRequest] + builder_deposits: ProgressiveList[SszBuilderDepositRequest] + builder_exits: ProgressiveList[SszBuilderExitRequest] class SszNewPayloadRequest(Container): """SSZ container mirroring ``NewPayloadRequest``.""" execution_payload: SszExecutionPayload - versioned_hashes: SszList[Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK] + versioned_hashes: ProgressiveList[Bytes32] parent_beacon_block_root: Bytes32 execution_requests: SszExecutionRequests @@ -300,21 +288,15 @@ def _payload_to_ssz( extra_data=ByteList[MAX_EXTRA_DATA_BYTES](bytes(p.extra_data)), base_fee_per_gas=uint256(int(p.base_fee_per_gas)), block_hash=Bytes32(bytes(p.block_hash)), - transactions=SszList[ - ByteList[MAX_BYTES_PER_TRANSACTION], - MAX_TRANSACTIONS_PER_PAYLOAD, - ]( - ByteList[MAX_BYTES_PER_TRANSACTION](bytes(tx)) - for tx in p.transactions + transactions=ProgressiveList[ProgressiveByteList]( + ProgressiveByteList(bytes(tx)) for tx in p.transactions ), - withdrawals=SszList[SszWithdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]( + withdrawals=ProgressiveList[SszWithdrawal]( _withdrawal_to_ssz(w) for w in p.withdrawals ), blob_gas_used=uint64(int(p.blob_gas_used)), excess_blob_gas=uint64(int(p.excess_blob_gas)), - block_access_list=ByteList[MAX_BYTES_PER_TRANSACTION]( - bytes(p.block_access_list) - ), + block_access_list=ProgressiveByteList(bytes(p.block_access_list)), slot_number=uint64(int(p.slot_number)), ) @@ -461,22 +443,21 @@ def _execution_requests_to_ssz( ) -> SszExecutionRequests: """Convert an ExecutionRequests to its SSZ form.""" return SszExecutionRequests( - deposits=SszList[SszDepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD]( + deposits=ProgressiveList[SszDepositRequest]( _deposit_request_to_ssz(d) for d in er.deposits ), - withdrawals=SszList[ - SszWithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD - ](_withdrawal_request_to_ssz(w) for w in er.withdrawals), - consolidations=SszList[ - SszConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD - ](_consolidation_request_to_ssz(c) for c in er.consolidations), - builder_deposits=SszList[ - SszBuilderDepositRequest, - MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD, - ](_builder_deposit_request_to_ssz(b) for b in er.builder_deposits), - builder_exits=SszList[ - SszBuilderExitRequest, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD - ](_builder_exit_request_to_ssz(b) for b in er.builder_exits), + withdrawals=ProgressiveList[SszWithdrawalRequest]( + _withdrawal_request_to_ssz(w) for w in er.withdrawals + ), + consolidations=ProgressiveList[SszConsolidationRequest]( + _consolidation_request_to_ssz(c) for c in er.consolidations + ), + builder_deposits=ProgressiveList[SszBuilderDepositRequest]( + _builder_deposit_request_to_ssz(b) for b in er.builder_deposits + ), + builder_exits=ProgressiveList[SszBuilderExitRequest]( + _builder_exit_request_to_ssz(b) for b in er.builder_exits + ), ) @@ -507,7 +488,7 @@ def _new_payload_request_to_ssz( """Convert a NewPayloadRequest to its SSZ form.""" return SszNewPayloadRequest( execution_payload=_payload_to_ssz(npr.execution_payload), - versioned_hashes=SszList[Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK]( + versioned_hashes=ProgressiveList[Bytes32]( Bytes32(bytes(vh)) for vh in npr.versioned_hashes ), parent_beacon_block_root=Bytes32(bytes(npr.parent_beacon_block_root)), diff --git a/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py b/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py index 3ad43135ac4..ead0b94e08b 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/test_modified_builder_contract.py @@ -121,14 +121,12 @@ def run_modified_requests_test( Spec.MAX_DEPOSIT_REQUESTS_PER_BLOCK + 1 ), id="max_plus_1_builder_deposit_requests", - marks=pytest.mark.skip_stateless_validation, ), pytest.param( builder_deposit_list_with_custom_fee( Spec.MAX_DEPOSIT_REQUESTS_PER_BLOCK + 2 ), id="max_plus_2_builder_deposit_requests", - marks=pytest.mark.skip_stateless_validation, ), ], ) @@ -174,14 +172,12 @@ def test_extra_builder_deposits( Spec.MAX_EXIT_REQUESTS_PER_BLOCK + 1 ), id="max_plus_1_builder_exit_requests", - marks=pytest.mark.skip_stateless_validation, ), pytest.param( builder_exit_list_with_custom_fee( Spec.MAX_EXIT_REQUESTS_PER_BLOCK + 2 ), id="max_plus_2_builder_exit_requests", - marks=pytest.mark.skip_stateless_validation, ), ], ) From 49754361a2c2c2fe965b0a90533692d8f060958d Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 31 Jul 2026 18:32:16 -0300 Subject: [PATCH 246/265] feat: update stateless validation to include schema fork index and refactor chain config handling --- .../plugins/filler/tests/test_filler.py | 7 +- .../specs/blockchain_stateless.py | 160 +++++++++--------- src/ethereum/forks/amsterdam/stateless.py | 46 ++--- .../forks/amsterdam/stateless_guest.py | 12 +- .../forks/amsterdam/stateless_host.py | 9 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 38 +---- .../test_witness_validation_chain_config.py | 10 +- tests/json_loader/test_stateless_guest.py | 39 +++-- 8 files changed, 157 insertions(+), 164 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index c90a2b2e920..83f24d8cd0f 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1163,11 +1163,12 @@ def test_execution_witness_in_blockchain_fixture( deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes - from ethereum_types.numeric import U64 + from ethereum_types.numeric import U8, U64 stateless_output = deserialize_stateless_output(EthereumBytes(bytes(sob))) assert stateless_output.successful_validation is True assert stateless_output.chain_config == build_chain_config(U64(1)) + assert stateless_output.schema_fork_index == U8(0x15) engine_fixture_path = Path( "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" @@ -1368,6 +1369,7 @@ def test_execution_witness_expected_true_reuses_canonical_stateless_result( deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes + from ethereum_types.numeric import U8 stateless_input = deserialize_stateless_input( EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) @@ -1377,6 +1379,7 @@ def test_execution_witness_expected_true_reuses_canonical_stateless_result( ) assert stateless_output.successful_validation is True + assert stateless_output.schema_fork_index == U8(0x15) assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) assert ( stateless_output.new_payload_request_root @@ -1428,6 +1431,7 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes + from ethereum_types.numeric import U8 stateless_input = deserialize_stateless_input( EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) @@ -1437,6 +1441,7 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( ) assert stateless_output.successful_validation is False + assert stateless_output.schema_fork_index == U8(0x15) assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) assert ( stateless_output.new_payload_request_root diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 2e5b350c841..0ae919f2b0e 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -443,40 +443,17 @@ def finalize_stateless_artifacts( f"want {options.expected_validation_success}" ) - assert_amsterdam_stateless_output_request_root( - fork=fork, - block_number=block_number, - timestamp=timestamp, - stateless_input_bytes=stateless_input_bytes, - stateless_output=stateless_output, - ) - - skip_chain_config_assertion = ( - options.has_stateless_input_bytes_modifier - and stateless_output is not None - and is_invalid_stateless_input_sentinel(stateless_output) - ) - if not skip_chain_config_assertion: - expected_chain_config = None - if options.has_stateless_input_bytes_modifier: - if stateless_input_bytes is None: - raise Exception( - "Stateless output chain_config assertion requires " - "stateless input bytes" - ) - expected_chain_config = ( - decode_amsterdam_stateless_input_chain_config( - fork=fork, - block_number=block_number, - timestamp=timestamp, - stateless_input_bytes=stateless_input_bytes, - ) + if stateless_output is not None: + if stateless_input_bytes is None: + raise Exception( + "Stateless output verification requires stateless input bytes" ) - assert_amsterdam_stateless_output_chain_config( + verify_amsterdam_stateless_output( block_number=block_number, chain_id=chain_id, + stateless_input_bytes=stateless_input_bytes, stateless_output=stateless_output, - expected_chain_config=expected_chain_config, + input_bytes_modified=options.has_stateless_input_bytes_modifier, ) return replace( @@ -910,7 +887,7 @@ def build_amsterdam_stateless_artifacts_from_t8n( serialize_stateless_input, ) from ethereum_types.bytes import Bytes as AmsterdamBytes - from ethereum_types.numeric import U64 + from ethereum_types.numeric import U8, U64 parent_number = ZeroPaddedHexNumber(block_number - 1) parent_header_rlp = previous_env.block_headers.get(parent_number) @@ -970,6 +947,7 @@ def build_amsterdam_stateless_artifacts_from_t8n( ), successful_validation=True, chain_config=build_chain_config(U64(chain_id)), + schema_fork_index=U8(0x15), ) stateless_output_bytes = serialize_stateless_output(stateless_output) return ( @@ -1005,31 +983,6 @@ def decode_amsterdam_stateless_output( ) -def decode_amsterdam_stateless_input_chain_config( - *, - fork: Fork, - block_number: int, - timestamp: int, - stateless_input_bytes: Bytes, -) -> Any | None: - """ - Decode the chain config from Amsterdam stateless input bytes. - """ - active_fork = fork.fork_at(block_number=block_number, timestamp=timestamp) - if active_fork.name() != "Amsterdam": - return None - - from ethereum.forks.amsterdam.stateless_guest import ( - deserialize_stateless_input, - ) - from ethereum_types.bytes import Bytes as AmsterdamBytes - - stateless_input = deserialize_stateless_input( - AmsterdamBytes(bytes(stateless_input_bytes)) - ) - return stateless_input.chain_config - - def assert_amsterdam_stateless_output_chain_config( *, block_number: int, @@ -1057,48 +1010,78 @@ def assert_amsterdam_stateless_output_chain_config( ) -def is_invalid_stateless_input_sentinel(stateless_output: Any) -> bool: +def is_invalid_input_stateless_output(stateless_output: Any) -> bool: """ Return whether output is the invalid stateless input sentinel. """ - from ethereum_types.numeric import U64 + from ethereum_types.numeric import U8, U64 chain_config = stateless_output.chain_config - active_fork = chain_config.active_fork - activation = active_fork.activation + activation = chain_config.fork_activation return ( not stateless_output.successful_validation and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 and chain_config.chain_id == U64(0) and activation.block_number is None and activation.timestamp is None + and stateless_output.schema_fork_index == U8(0) ) def assert_amsterdam_stateless_output_request_root( *, - fork: Fork, block_number: int, - timestamp: int, - stateless_input_bytes: Bytes | None, - stateless_output: Any | None, + stateless_input: Any, + stateless_output: Any, ) -> None: """ Assert the output commits to the decoded Amsterdam payload request. """ - if stateless_input_bytes is None or stateless_output is None: - return - - active_fork = fork.fork_at( - block_number=block_number, - timestamp=timestamp, - ) - if active_fork.name() != "Amsterdam": - return - from ethereum.forks.amsterdam.stateless import ( compute_new_payload_request_root, ) + + expected_root = compute_new_payload_request_root(stateless_input) + actual_root = stateless_output.new_payload_request_root + if actual_root != expected_root: + raise AssertionError( + "Stateless output new_payload_request_root mismatch for block " + f"{block_number}: got 0x{bytes(actual_root).hex()}, " + f"want 0x{bytes(expected_root).hex()}" + ) + + +def assert_amsterdam_stateless_output_schema_fork_index( + *, + block_number: int, + stateless_output: Any, +) -> None: + """ + Assert the output identifies the fork rules executed by the guest. + """ + from ethereum_types.numeric import U8 + + expected_fork_index = U8(0x15) + + if stateless_output.schema_fork_index != expected_fork_index: + raise AssertionError( + "Stateless output schema_fork_index mismatch for block " + f"{block_number}: got {stateless_output.schema_fork_index}, " + f"want {expected_fork_index}" + ) + + +def verify_amsterdam_stateless_output( + *, + block_number: int, + chain_id: int, + stateless_input_bytes: Bytes, + stateless_output: Any, + input_bytes_modified: bool, +) -> None: + """ + Verify the public values returned by the Amsterdam stateless guest. + """ from ethereum.forks.amsterdam.stateless_guest import ( deserialize_stateless_input, ) @@ -1109,21 +1092,32 @@ def assert_amsterdam_stateless_output_request_root( AmsterdamBytes(bytes(stateless_input_bytes)) ) except Exception as exc: - if is_invalid_stateless_input_sentinel(stateless_output): + if input_bytes_modified and is_invalid_input_stateless_output( + stateless_output + ): return raise AssertionError( "Stateless input decoding failed for block " f"{block_number}, but its output is not the invalid-input sentinel" ) from exc - expected_root = compute_new_payload_request_root(stateless_input) - actual_root = stateless_output.new_payload_request_root - if actual_root != expected_root: - raise AssertionError( - "Stateless output new_payload_request_root mismatch for block " - f"{block_number}: got 0x{bytes(actual_root).hex()}, " - f"want 0x{bytes(expected_root).hex()}" - ) + assert_amsterdam_stateless_output_request_root( + block_number=block_number, + stateless_input=stateless_input, + stateless_output=stateless_output, + ) + assert_amsterdam_stateless_output_schema_fork_index( + block_number=block_number, + stateless_output=stateless_output, + ) + assert_amsterdam_stateless_output_chain_config( + block_number=block_number, + chain_id=chain_id, + stateless_output=stateless_output, + expected_chain_config=( + stateless_input.chain_config if input_bytes_modified else None + ), + ) def _has_execution_witness_modifier( diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index a639e347c51..de46a3d3157 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -9,7 +9,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from ethereum_types.frozen import slotted_freezable -from ethereum_types.numeric import U64 +from ethereum_types.numeric import U8, U64 from ethereum.crypto.hash import Hash32, keccak256 from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader @@ -136,17 +136,6 @@ class ForkActivation: timestamp: U64 | None -@final -@slotted_freezable -@dataclass -class ForkConfig: - """ - Per-fork configuration needed to interpret stateless inputs. - """ - - activation: ForkActivation - - @final @slotted_freezable @dataclass @@ -156,7 +145,17 @@ class ChainConfig: """ chain_id: U64 - active_fork: ForkConfig + """ + Chain identifier used during payload validation and + execution. + """ + + fork_activation: ForkActivation + """ + Activation for the fork selected by the guest schema. + + Used for activation checks and retained for transition-block logic. + """ @final @@ -225,6 +224,12 @@ class StatelessValidationResult: when ``run_stateless_guest`` cannot decode the input bytes. """ + schema_fork_index: U8 + """ + Fork rules executed by the guest. This uses the invalid-input sentinel + when ``run_stateless_guest`` cannot decode the input bytes. + """ + def compute_new_payload_request_root( stateless_input: StatelessInput, @@ -300,20 +305,20 @@ def _is_activation_active( def validate_chain_config( chain_config: ChainConfig, new_payload_request: NewPayloadRequest, -) -> ForkConfig: +) -> None: """ - Validate and return the target payload's active fork config. + Validate the chain configuration for the target payload. """ - active_fork = chain_config.active_fork execution_payload = new_payload_request.execution_payload - if not _is_activation_active(active_fork.activation, execution_payload): + if not _is_activation_active( + chain_config.fork_activation, + execution_payload, + ): raise InactiveForkConfigError( - "ChainConfig active_fork is not active for the target payload" + "ChainConfig fork_activation is not active for the target payload" ) - return active_fork - def verify_stateless_new_payload( stateless_input: StatelessInput, @@ -363,4 +368,5 @@ def verify_stateless_new_payload( new_payload_request_root=new_payload_request_root, successful_validation=successful_validation, chain_config=stateless_input.chain_config, + schema_fork_index=U8(int(ProtocolFork.Amsterdam)), ) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 112f23b2bd5..173fe0ff377 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -3,14 +3,13 @@ """ from ethereum_types.bytes import Bytes -from ethereum_types.numeric import U64 +from ethereum_types.numeric import U8, U64 from ethereum.crypto.hash import Hash32 from .stateless import ( ChainConfig, ForkActivation, - ForkConfig, StatelessInput, StatelessValidationResult, verify_stateless_new_payload, @@ -59,13 +58,12 @@ def _default_failed_stateless_output() -> StatelessValidationResult: successful_validation=False, chain_config=ChainConfig( chain_id=U64(0), - active_fork=ForkConfig( - activation=ForkActivation( - block_number=None, - timestamp=None, - ), + fork_activation=ForkActivation( + block_number=None, + timestamp=None, ), ), + schema_fork_index=U8(0), ) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 108758bb865..2fe0ca383e0 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -20,7 +20,6 @@ ChainConfig, ExecutionWitness, ForkActivation, - ForkConfig, StatelessInput, StatelessValidationResult, ) @@ -63,11 +62,9 @@ def build_chain_config(chain_id: U64) -> ChainConfig: """ return ChainConfig( chain_id=chain_id, - active_fork=ForkConfig( - activation=ForkActivation( - block_number=None, - timestamp=U64(0), - ), + fork_activation=ForkActivation( + block_number=None, + timestamp=U64(0), ), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 2e1069d438d..c89794b75bd 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -9,8 +9,8 @@ from typing import TypeAlias from ethereum_types.bytes import Bytes, Bytes48, Bytes96 -from ethereum_types.numeric import U64, U256, Uint -from remerkleable.basic import boolean, uint64, uint256 +from ethereum_types.numeric import U8, U64, U256, Uint +from remerkleable.basic import boolean, uint8, uint64, uint256 from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector from remerkleable.complex import Container from remerkleable.complex import List as SszList @@ -38,7 +38,6 @@ ChainConfig, ExecutionWitness, ForkActivation, - ForkConfig, ProtocolFork, StatelessInput, StatelessValidationResult, @@ -217,17 +216,11 @@ class SszForkActivation(Container): timestamp: SszOptionalForkActivationValue -class SszForkConfig(Container): - """SSZ container mirroring ``ForkConfig``.""" - - activation: SszForkActivation - - class SszChainConfig(Container): """SSZ container mirroring ``ChainConfig``.""" chain_id: uint64 - active_fork: SszForkConfig + fork_activation: SszForkActivation class SszStatelessInput(Container): @@ -245,6 +238,7 @@ class SszStatelessValidationResult(Container): new_payload_request_root: Bytes32 successful_validation: boolean chain_config: SszChainConfig + schema_fork_index: uint8 # --- Conversion helpers --- @@ -576,31 +570,13 @@ def _ssz_to_fork_activation( ) -def _fork_config_to_ssz( - fork_config: ForkConfig, -) -> SszForkConfig: - """Convert a ForkConfig to its SSZ form.""" - return SszForkConfig( - activation=_fork_activation_to_ssz(fork_config.activation), - ) - - -def _ssz_to_fork_config( - ssz_fork_config: SszForkConfig, -) -> ForkConfig: - """Convert an SSZ fork config back.""" - return ForkConfig( - activation=_ssz_to_fork_activation(ssz_fork_config.activation), - ) - - def _chain_config_to_ssz( cc: ChainConfig, ) -> SszChainConfig: """Convert a ChainConfig to its SSZ form.""" return SszChainConfig( chain_id=uint64(int(cc.chain_id)), - active_fork=_fork_config_to_ssz(cc.active_fork), + fork_activation=_fork_activation_to_ssz(cc.fork_activation), ) @@ -610,7 +586,7 @@ def _ssz_to_chain_config( """Convert an SSZ chain config back.""" return ChainConfig( chain_id=U64(scc.chain_id), - active_fork=_ssz_to_fork_config(scc.active_fork), + fork_activation=_ssz_to_fork_activation(scc.fork_activation), ) @@ -658,6 +634,7 @@ def validation_result_to_ssz( new_payload_request_root=Bytes32(bytes(vr.new_payload_request_root)), successful_validation=boolean(vr.successful_validation), chain_config=_chain_config_to_ssz(vr.chain_config), + schema_fork_index=uint8(int(vr.schema_fork_index)), ) @@ -671,4 +648,5 @@ def ssz_to_validation_result( ), successful_validation=bool(ssz_vr.successful_validation), chain_config=_ssz_to_chain_config(ssz_vr.chain_config), + schema_fork_index=U8(ssz_vr.schema_fork_index), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py index 7397fe85e20..74a4241e1d9 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py @@ -60,15 +60,11 @@ def future_timestamp_activation_chain_config(stateless_input: Any) -> Any: payload = stateless_input.new_payload_request.execution_payload chain_config = stateless_input.chain_config - active_fork = chain_config.active_fork return replace( chain_config, - active_fork=replace( - active_fork, - activation=ForkActivation( - block_number=None, - timestamp=U64(int(payload.timestamp) + 1), - ), + fork_activation=ForkActivation( + block_number=None, + timestamp=U64(int(payload.timestamp) + 1), ), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 0f5d39ab6e0..8cb3172f3ea 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -6,7 +6,7 @@ import pytest from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U8, U64, U256, Uint from ethereum.crypto.hash import Hash32 from ethereum.forks.amsterdam.block_access_lists import BlockAccessList @@ -26,7 +26,6 @@ ChainConfig, ExecutionWitness, ForkActivation, - ForkConfig, ProtocolFork, StatelessInput, StatelessValidationResult, @@ -135,11 +134,9 @@ def _make_block() -> Block: def _expected_amsterdam_chain_config(chain_id: U64) -> ChainConfig: return ChainConfig( chain_id=chain_id, - active_fork=ForkConfig( - activation=ForkActivation( - block_number=None, - timestamp=U64(0), - ), + fork_activation=ForkActivation( + block_number=None, + timestamp=U64(0), ), ) @@ -203,6 +200,7 @@ def _make_stateless_output() -> StatelessValidationResult: new_payload_request_root=Hash32(_rb(32)), successful_validation=True, chain_config=build_chain_config(U64(1)), + schema_fork_index=U8(0x15), ) @@ -414,17 +412,21 @@ def test_roundtrip(self) -> None: encoded = serialize_stateless_output(original) recovered = deserialize_stateless_output(encoded) assert recovered == original + assert recovered.chain_config == build_chain_config(U64(1)) + assert recovered.schema_fork_index == U8(0x15) def test_failed_validation(self) -> None: - """Serializes a failed validation result correctly.""" + """Preserve the executed fork when later validation fails.""" original = StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=False, chain_config=build_chain_config(U64(1)), + schema_fork_index=U8(0x15), ) encoded = serialize_stateless_output(original) recovered = deserialize_stateless_output(encoded) assert recovered == original + assert recovered.schema_fork_index == U8(0x15) class TestRunStatelessGuest: @@ -438,8 +440,23 @@ def test_invalid_input_bytes_return_failed_validation(self) -> None: assert result.new_payload_request_root == Hash32(b"\0" * 32) assert not result.successful_validation assert result.chain_config.chain_id == U64(0) - assert result.chain_config.active_fork.activation.block_number is None - assert result.chain_config.active_fork.activation.timestamp is None + assert result.chain_config.fork_activation.block_number is None + assert result.chain_config.fork_activation.timestamp is None + assert result.schema_fork_index == U8(0) + + def test_decodable_input_reports_amsterdam_on_validation_failure( + self, + ) -> None: + """Decoded Amsterdam input reports its fork after execution failure.""" + stateless_input = _make_stateless_input() + encoded = run_stateless_guest( + serialize_stateless_input(stateless_input) + ) + result = deserialize_stateless_output(encoded) + + assert not result.successful_validation + assert result.chain_config == stateless_input.chain_config + assert result.schema_fork_index == U8(0x15) class TestComputeNewPayloadRequestRoot: @@ -474,6 +491,7 @@ def test_too_few_public_keys_fail_validation(self) -> None: result = verify_stateless_new_payload(invalid) assert not result.successful_validation + assert result.schema_fork_index == U8(0x15) def test_too_many_public_keys_fail_validation(self) -> None: """Stateless validation should fail with too many public keys.""" @@ -491,3 +509,4 @@ def test_too_many_public_keys_fail_validation(self) -> None: result = verify_stateless_new_payload(invalid) assert not result.successful_validation + assert result.schema_fork_index == U8(0x15) From 52ecc583067952ac6e575528fd217ec8a1c6589d Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 31 Jul 2026 19:27:00 -0300 Subject: [PATCH 247/265] feat: remove ForkActivation and indirectly ChainConfig leaving only chain_id flattened --- .../plugins/filler/tests/test_filler.py | 3 +- .../specs/blockchain_stateless.py | 36 +++--- src/ethereum/forks/amsterdam/stateless.py | 118 ++---------------- .../forks/amsterdam/stateless_guest.py | 10 +- .../forks/amsterdam/stateless_host.py | 19 +-- src/ethereum/forks/amsterdam/stateless_ssz.py | 94 +------------- ...py => test_witness_validation_chain_id.py} | 64 ++-------- tests/json_loader/test_stateless_guest.py | 53 +++----- 8 files changed, 62 insertions(+), 335 deletions(-) rename tests/amsterdam/eip8025_optional_proofs/{test_witness_validation_chain_config.py => test_witness_validation_chain_id.py} (51%) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 83f24d8cd0f..4fdca444bcf 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1159,7 +1159,6 @@ def test_execution_witness_in_blockchain_fixture( assert sob is not None and len(sob) > 0 from ethereum.forks.amsterdam.stateless_host import ( - build_chain_config, deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes @@ -1167,7 +1166,7 @@ def test_execution_witness_in_blockchain_fixture( stateless_output = deserialize_stateless_output(EthereumBytes(bytes(sob))) assert stateless_output.successful_validation is True - assert stateless_output.chain_config == build_chain_config(U64(1)) + assert stateless_output.chain_id == U64(1) assert stateless_output.schema_fork_index == U8(0x15) engine_fixture_path = Path( diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 0ae919f2b0e..c44883319c7 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -583,7 +583,7 @@ def rebuild_amsterdam_stateless_input_with_overrides( rebuilt_input = AmsterdamStatelessInput( new_payload_request=original_input.new_payload_request, witness=rebuilt_witness, - chain_config=original_input.chain_config, + chain_id=original_input.chain_id, public_keys=( tuple(AmsterdamBytes(bytes(key)) for key in public_keys) if public_keys is not None @@ -882,7 +882,6 @@ def build_amsterdam_stateless_artifacts_from_t8n( serialize_stateless_output, ) from ethereum.forks.amsterdam.stateless_host import ( - build_chain_config, build_stateless_input, serialize_stateless_input, ) @@ -946,7 +945,7 @@ def build_amsterdam_stateless_artifacts_from_t8n( stateless_input ), successful_validation=True, - chain_config=build_chain_config(U64(chain_id)), + chain_id=U64(chain_id), schema_fork_index=U8(0x15), ) stateless_output_bytes = serialize_stateless_output(stateless_output) @@ -983,30 +982,29 @@ def decode_amsterdam_stateless_output( ) -def assert_amsterdam_stateless_output_chain_config( +def assert_amsterdam_stateless_output_chain_id( *, block_number: int, chain_id: int, stateless_output: Any | None, - expected_chain_config: Any | None = None, + expected_chain_id: Any | None = None, ) -> None: """ - Assert the stateless output reports the expected Amsterdam chain config. + Assert the stateless output reports the expected chain identifier. """ if stateless_output is None: return - if expected_chain_config is None: - from ethereum.forks.amsterdam.stateless_host import build_chain_config + if expected_chain_id is None: from ethereum_types.numeric import U64 - expected_chain_config = build_chain_config(U64(chain_id)) + expected_chain_id = U64(chain_id) - if stateless_output.chain_config != expected_chain_config: + if stateless_output.chain_id != expected_chain_id: raise AssertionError( - "Stateless output chain_config mismatch for block " - f"{block_number}: got {stateless_output.chain_config}, " - f"want {expected_chain_config}" + "Stateless output chain_id mismatch for block " + f"{block_number}: got {stateless_output.chain_id}, " + f"want {expected_chain_id}" ) @@ -1016,14 +1014,10 @@ def is_invalid_input_stateless_output(stateless_output: Any) -> bool: """ from ethereum_types.numeric import U8, U64 - chain_config = stateless_output.chain_config - activation = chain_config.fork_activation return ( not stateless_output.successful_validation and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 - and chain_config.chain_id == U64(0) - and activation.block_number is None - and activation.timestamp is None + and stateless_output.chain_id == U64(0) and stateless_output.schema_fork_index == U8(0) ) @@ -1110,12 +1104,12 @@ def verify_amsterdam_stateless_output( block_number=block_number, stateless_output=stateless_output, ) - assert_amsterdam_stateless_output_chain_config( + assert_amsterdam_stateless_output_chain_id( block_number=block_number, chain_id=chain_id, stateless_output=stateless_output, - expected_chain_config=( - stateless_input.chain_config if input_bytes_modified else None + expected_chain_id=( + stateless_input.chain_id if input_bytes_modified else None ), ) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index de46a3d3157..274992e23fd 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -18,7 +18,7 @@ from .blocks import Header from .execution_engine.new_payload import execute_new_payload_request from .execution_engine.requests import ExecutionRequests -from .execution_engine.types import ExecutionPayload, NewPayloadRequest +from .execution_engine.types import NewPayloadRequest from .fork import ChainContext from .fork_types import VersionedHash from .witness_state import WitnessState, build_code_db, build_node_db @@ -106,58 +106,6 @@ class ProtocolFork(IntEnum): Amsterdam = 0x15 -class ChainConfigValidationError(Exception): - """ - Raised when a chain config cannot be used by this stateless guest. - """ - - -class InactiveForkConfigError(ChainConfigValidationError): - """ - Raised when the configured active fork is not active for the payload. - """ - - -class InvalidForkActivationError(ChainConfigValidationError): - """ - Raised when a fork entry has a malformed activation point. - """ - - -@final -@slotted_freezable -@dataclass -class ForkActivation: - """ - Activation point for a protocol fork. - """ - - block_number: U64 | None - timestamp: U64 | None - - -@final -@slotted_freezable -@dataclass -class ChainConfig: - """ - Chain configuration needed for stateless validation. - """ - - chain_id: U64 - """ - Chain identifier used during payload validation and - execution. - """ - - fork_activation: ForkActivation - """ - Activation for the fork selected by the guest schema. - - Used for activation checks and retained for transition-block logic. - """ - - @final @slotted_freezable @dataclass @@ -179,9 +127,9 @@ class StatelessInput: state transition function statelessly. """ - chain_config: ChainConfig + chain_id: U64 """ - Chain configuration values needed during stateless validation. + Chain identifier used during payload validation and execution. """ public_keys: Tuple[Bytes, ...] @@ -218,10 +166,10 @@ class StatelessValidationResult: means validation failed or the guest input bytes could not be decoded. """ - chain_config: ChainConfig + chain_id: U64 """ - Chain configuration decoded from the input. This is a sentinel default - when ``run_stateless_guest`` cannot decode the input bytes. + Chain identifier decoded from the input. This is zero when + ``run_stateless_guest`` cannot decode the input bytes. """ schema_fork_index: U8 @@ -277,49 +225,6 @@ def validate_headers( return headers, block_hashes -def _is_activation_active( - activation: ForkActivation, - execution_payload: ExecutionPayload, -) -> bool: - """ - Return whether an activation point is active for the payload. - """ - if activation.block_number is None and activation.timestamp is None: - raise InvalidForkActivationError( - "Fork activation must set block_number or timestamp" - ) - - if activation.block_number is not None and int( - execution_payload.block_number - ) < int(activation.block_number): - return False - - if activation.timestamp is not None and int( - execution_payload.timestamp - ) < int(activation.timestamp): - return False - - return True - - -def validate_chain_config( - chain_config: ChainConfig, - new_payload_request: NewPayloadRequest, -) -> None: - """ - Validate the chain configuration for the target payload. - """ - execution_payload = new_payload_request.execution_payload - - if not _is_activation_active( - chain_config.fork_activation, - execution_payload, - ): - raise InactiveForkConfigError( - "ChainConfig fork_activation is not active for the target payload" - ) - - def verify_stateless_new_payload( stateless_input: StatelessInput, ) -> StatelessValidationResult: @@ -332,10 +237,9 @@ def verify_stateless_new_payload( witness = stateless_input.witness try: - validate_chain_config( - stateless_input.chain_config, - stateless_input.new_payload_request, - ) + # Note that in EEST we have one implementation per fork, so we don't need + # to check execution payload timestamp against the current fork activation + # information. A real implementation MUST do these checks! # Validate the headers are contiguous and compute their # blockhashes. @@ -343,7 +247,7 @@ def verify_stateless_new_payload( parent_header = decoded_headers[-1] chain_context = ChainContext( - chain_id=stateless_input.chain_config.chain_id, + chain_id=stateless_input.chain_id, block_hashes=block_hashes, parent_header=parent_header, ) @@ -367,6 +271,6 @@ def verify_stateless_new_payload( return StatelessValidationResult( new_payload_request_root=new_payload_request_root, successful_validation=successful_validation, - chain_config=stateless_input.chain_config, + chain_id=stateless_input.chain_id, schema_fork_index=U8(int(ProtocolFork.Amsterdam)), ) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 173fe0ff377..9fe6cbe465d 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -8,8 +8,6 @@ from ethereum.crypto.hash import Hash32 from .stateless import ( - ChainConfig, - ForkActivation, StatelessInput, StatelessValidationResult, verify_stateless_new_payload, @@ -56,13 +54,7 @@ def _default_failed_stateless_output() -> StatelessValidationResult: return StatelessValidationResult( new_payload_request_root=Hash32(b"\0" * 32), successful_validation=False, - chain_config=ChainConfig( - chain_id=U64(0), - fork_activation=ForkActivation( - block_number=None, - timestamp=None, - ), - ), + chain_id=U64(0), schema_fork_index=U8(0), ) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 2fe0ca383e0..6bed33433e3 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -17,9 +17,7 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash from .stateless import ( - ChainConfig, ExecutionWitness, - ForkActivation, StatelessInput, StatelessValidationResult, ) @@ -54,21 +52,6 @@ def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: return ssz_to_validation_result(ssz_obj) -def build_chain_config(chain_id: U64) -> ChainConfig: - """ - Build the chain configuration supported by this host. - - For now the Amsterdam stateless host only describes the Amsterdam fork. - """ - return ChainConfig( - chain_id=chain_id, - fork_activation=ForkActivation( - block_number=None, - timestamp=U64(0), - ), - ) - - def build_stateless_input( block: Block, *, @@ -152,6 +135,6 @@ def build_stateless_input( return StatelessInput( new_payload_request=new_payload, witness=execution_witness, - chain_config=build_chain_config(chain_id), + chain_id=chain_id, public_keys=tuple(public_keys), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index c89794b75bd..cbf40e2caa7 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -6,8 +6,6 @@ functions between the two representations. """ -from typing import TypeAlias - from ethereum_types.bytes import Bytes, Bytes48, Bytes96 from ethereum_types.numeric import U8, U64, U256, Uint from remerkleable.basic import boolean, uint8, uint64, uint256 @@ -35,9 +33,7 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import Bloom, VersionedHash from .stateless import ( - ChainConfig, ExecutionWitness, - ForkActivation, ProtocolFork, StatelessInput, StatelessValidationResult, @@ -72,7 +68,6 @@ # 2**10 is the next power of two, with almost twice the needed capacity. MAX_BYTES_PER_WITNESS_NODE = 2**10 -MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 # One public key is supplied per transaction. Every valid transaction consumes # at least 21_000 gas, so a 500M gas block can contain at most # 500_000_000 // 21_000 = 23_809 transactions, rounded up to next power of 2. @@ -98,11 +93,6 @@ # --- SSZ Container types --- -SszOptionalForkActivationValue: TypeAlias = SszList[ - uint64, MAX_OPTIONAL_FORK_ACTIVATION_VALUES -] - - class SszWithdrawal(Container): """SSZ container mirroring ``Withdrawal``.""" @@ -209,26 +199,12 @@ class SszExecutionWitness(Container): headers: SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] -class SszForkActivation(Container): - """SSZ container mirroring ``ForkActivation``.""" - - block_number: SszOptionalForkActivationValue - timestamp: SszOptionalForkActivationValue - - -class SszChainConfig(Container): - """SSZ container mirroring ``ChainConfig``.""" - - chain_id: uint64 - fork_activation: SszForkActivation - - class SszStatelessInput(Container): """SSZ container mirroring ``StatelessInput``.""" new_payload_request: SszNewPayloadRequest witness: SszExecutionWitness - chain_config: SszChainConfig + chain_id: uint64 public_keys: SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS] @@ -237,7 +213,7 @@ class SszStatelessValidationResult(Container): new_payload_request_root: Bytes32 successful_validation: boolean - chain_config: SszChainConfig + chain_id: uint64 schema_fork_index: uint8 @@ -532,64 +508,6 @@ def _ssz_to_witness( ) -def _optional_u64_to_ssz( - value: U64 | None, -) -> SszOptionalForkActivationValue: - """Convert an optional U64 to a zero-or-one SSZ list.""" - if value is None: - return SszOptionalForkActivationValue() - return SszOptionalForkActivationValue(uint64(int(value))) - - -def _ssz_to_optional_u64( - ssz_value: SszOptionalForkActivationValue, -) -> U64 | None: - """Convert a zero-or-one SSZ list back to an optional U64.""" - if ssz_value.length() == 0: - return None - return U64(ssz_value.get(0)) - - -def _fork_activation_to_ssz( - activation: ForkActivation, -) -> SszForkActivation: - """Convert a ForkActivation to its SSZ form.""" - return SszForkActivation( - block_number=_optional_u64_to_ssz(activation.block_number), - timestamp=_optional_u64_to_ssz(activation.timestamp), - ) - - -def _ssz_to_fork_activation( - ssz_activation: SszForkActivation, -) -> ForkActivation: - """Convert an SSZ fork activation back.""" - return ForkActivation( - block_number=_ssz_to_optional_u64(ssz_activation.block_number), - timestamp=_ssz_to_optional_u64(ssz_activation.timestamp), - ) - - -def _chain_config_to_ssz( - cc: ChainConfig, -) -> SszChainConfig: - """Convert a ChainConfig to its SSZ form.""" - return SszChainConfig( - chain_id=uint64(int(cc.chain_id)), - fork_activation=_fork_activation_to_ssz(cc.fork_activation), - ) - - -def _ssz_to_chain_config( - scc: SszChainConfig, -) -> ChainConfig: - """Convert an SSZ chain config back.""" - return ChainConfig( - chain_id=U64(scc.chain_id), - fork_activation=_ssz_to_fork_activation(scc.fork_activation), - ) - - def stateless_input_to_ssz( si: StatelessInput, ) -> SszStatelessInput: @@ -605,7 +523,7 @@ def stateless_input_to_ssz( si.new_payload_request ), witness=_witness_to_ssz(si.witness), - chain_config=_chain_config_to_ssz(si.chain_config), + chain_id=uint64(int(si.chain_id)), public_keys=SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS]( ByteVector[PUBLIC_KEY_BYTES](bytes(pk)) for pk in si.public_keys ), @@ -621,7 +539,7 @@ def ssz_to_stateless_input( ssz_si.new_payload_request ), witness=_ssz_to_witness(ssz_si.witness), - chain_config=_ssz_to_chain_config(ssz_si.chain_config), + chain_id=U64(ssz_si.chain_id), public_keys=tuple(Bytes(bytes(pk)) for pk in ssz_si.public_keys), ) @@ -633,7 +551,7 @@ def validation_result_to_ssz( return SszStatelessValidationResult( new_payload_request_root=Bytes32(bytes(vr.new_payload_request_root)), successful_validation=boolean(vr.successful_validation), - chain_config=_chain_config_to_ssz(vr.chain_config), + chain_id=uint64(int(vr.chain_id)), schema_fork_index=uint8(int(vr.schema_fork_index)), ) @@ -647,6 +565,6 @@ def ssz_to_validation_result( bytes(ssz_vr.new_payload_request_root) ), successful_validation=bool(ssz_vr.successful_validation), - chain_config=_ssz_to_chain_config(ssz_vr.chain_config), + chain_id=U64(ssz_vr.chain_id), schema_fork_index=U8(ssz_vr.schema_fork_index), ) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_id.py similarity index 51% rename from tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py rename to tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_id.py index 74a4241e1d9..df38b076f33 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_config.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_validation_chain_id.py @@ -1,4 +1,4 @@ -"""Stateless chain-config validation tests.""" +"""Stateless chain-ID validation tests.""" from dataclasses import replace from typing import Any, Callable @@ -22,13 +22,13 @@ REFERENCE_SPEC_VERSION = "N/A" StatelessInputBytesModifier = Callable[[Bytes], Bytes] -ChainConfigBuilder = Callable[[Any], Any] +ChainIdBuilder = Callable[[Any], Any] -def replace_chain_config( - build_chain_config: ChainConfigBuilder, +def replace_chain_id( + build_chain_id: ChainIdBuilder, ) -> StatelessInputBytesModifier: - """Replace only the decoded stateless input chain_config.""" + """Replace only the decoded stateless input chain ID.""" def modifier(input_bytes: Bytes) -> Bytes: from ethereum_types.bytes import Bytes as AmsterdamBytes @@ -45,63 +45,25 @@ def modifier(input_bytes: Bytes) -> Bytes: ) modified_input = replace( stateless_input, - chain_config=build_chain_config(stateless_input), + chain_id=build_chain_id(stateless_input), ) return Bytes(bytes(serialize_stateless_input(modified_input))) return modifier -def future_timestamp_activation_chain_config(stateless_input: Any) -> Any: - """Move Amsterdam activation one second after the payload timestamp.""" - from ethereum_types.numeric import U64 - - from ethereum.forks.amsterdam.stateless import ForkActivation - - payload = stateless_input.new_payload_request.execution_payload - chain_config = stateless_input.chain_config - return replace( - chain_config, - fork_activation=ForkActivation( - block_number=None, - timestamp=U64(int(payload.timestamp) + 1), - ), - ) - - -def wrong_chain_id_chain_config(stateless_input: Any) -> Any: +def wrong_chain_id(stateless_input: Any) -> Any: """Change chain_id from 1 to 2.""" from ethereum_types.numeric import U64 - chain_config = stateless_input.chain_config - if int(chain_config.chain_id) != 1: + if int(stateless_input.chain_id) != 1: raise AssertionError( - f"expected canonical chain_id 1, got {chain_config.chain_id}" + f"expected canonical chain_id 1, got {stateless_input.chain_id}" ) - return replace(chain_config, chain_id=U64(2)) - - -def test_validation_chain_config_future_timestamp_activation( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """A future Amsterdam timestamp activation fails validation.""" - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[], - stateless_input_bytes_modifier=replace_chain_config( - future_timestamp_activation_chain_config - ), - expected_stateless_validation_success=False, - ) - ], - post={}, - ) + return U64(2) -def test_validation_chain_config_wrong_chain_id_legacy_signature( +def test_validation_wrong_chain_id_legacy_signature( fork: Fork, pre: Alloc, blockchain_test: BlockchainTestFiller, @@ -122,8 +84,8 @@ def test_validation_chain_config_wrong_chain_id_legacy_signature( blocks=[ Block( txs=[tx], - stateless_input_bytes_modifier=replace_chain_config( - wrong_chain_id_chain_config + stateless_input_bytes_modifier=replace_chain_id( + wrong_chain_id ), expected_stateless_validation_success=False, ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 8cb3172f3ea..819b910c737 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -23,9 +23,7 @@ ) from ethereum.forks.amsterdam.fork_types import Bloom from ethereum.forks.amsterdam.stateless import ( - ChainConfig, ExecutionWitness, - ForkActivation, ProtocolFork, StatelessInput, StatelessValidationResult, @@ -38,7 +36,6 @@ serialize_stateless_output, ) from ethereum.forks.amsterdam.stateless_host import ( - build_chain_config, build_stateless_input, deserialize_stateless_output, serialize_stateless_input, @@ -131,16 +128,6 @@ def _make_block() -> Block: ) -def _expected_amsterdam_chain_config(chain_id: U64) -> ChainConfig: - return ChainConfig( - chain_id=chain_id, - fork_activation=ForkActivation( - block_number=None, - timestamp=U64(0), - ), - ) - - def _make_deposit_request() -> DepositRequest: return DepositRequest( pubkey=Bytes48(_rb(48)), @@ -190,7 +177,7 @@ def _make_stateless_input() -> StatelessInput: codes=(Bytes(_rb(48)), Bytes(_rb(96))), headers=(Bytes(_rb(512)), Bytes(_rb(512))), ), - chain_config=build_chain_config(U64(1)), + chain_id=U64(1), public_keys=(Bytes(_rb(65)), Bytes(_rb(65))), ) @@ -199,26 +186,16 @@ def _make_stateless_output() -> StatelessValidationResult: return StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=True, - chain_config=build_chain_config(U64(1)), + chain_id=U64(1), schema_fork_index=U8(0x15), ) -class TestBuildChainConfig: - """Test host-side ChainConfig construction.""" - - def test_amsterdam_only(self) -> None: - """Builds a single Amsterdam fork entry.""" - chain_config = build_chain_config(U64(123)) - assert chain_config == _expected_amsterdam_chain_config(U64(123)) - - class TestBuildStatelessInput: """Test host-side StatelessInput construction.""" - def test_includes_amsterdam_chain_config(self) -> None: - """Includes the Amsterdam-only chain config.""" - chain_config = build_chain_config(U64(123)) + def test_includes_chain_id(self) -> None: + """Include the configured chain identifier.""" block_access_list: BlockAccessList = [] stateless_input = build_stateless_input( _make_block(), @@ -237,7 +214,7 @@ def test_includes_amsterdam_chain_config(self) -> None: block_access_list=block_access_list, chain_id=U64(123), ) - assert stateless_input.chain_config == chain_config + assert stateless_input.chain_id == U64(123) @pytest.mark.parametrize( ("v", "r", "s"), @@ -317,7 +294,7 @@ def test_empty_witness(self) -> None: ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), - chain_config=build_chain_config(U64(1)), + chain_id=U64(1), public_keys=(), ) encoded = serialize_stateless_input(original) @@ -331,7 +308,7 @@ def test_rejects_non_65_byte_public_key(self) -> None: invalid = StatelessInput( new_payload_request=original.new_payload_request, witness=original.witness, - chain_config=original.chain_config, + chain_id=original.chain_id, public_keys=(Bytes(_rb(64)), Bytes(_rb(65))), ) @@ -365,7 +342,7 @@ def test_empty_witness(self) -> None: ), ), witness=ExecutionWitness(state=(), codes=(), headers=()), - chain_config=build_chain_config(U64(1)), + chain_id=U64(1), public_keys=(), ) encoded = serialize_stateless_input(original) @@ -412,7 +389,7 @@ def test_roundtrip(self) -> None: encoded = serialize_stateless_output(original) recovered = deserialize_stateless_output(encoded) assert recovered == original - assert recovered.chain_config == build_chain_config(U64(1)) + assert recovered.chain_id == U64(1) assert recovered.schema_fork_index == U8(0x15) def test_failed_validation(self) -> None: @@ -420,7 +397,7 @@ def test_failed_validation(self) -> None: original = StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=False, - chain_config=build_chain_config(U64(1)), + chain_id=U64(1), schema_fork_index=U8(0x15), ) encoded = serialize_stateless_output(original) @@ -439,9 +416,7 @@ def test_invalid_input_bytes_return_failed_validation(self) -> None: assert result.new_payload_request_root == Hash32(b"\0" * 32) assert not result.successful_validation - assert result.chain_config.chain_id == U64(0) - assert result.chain_config.fork_activation.block_number is None - assert result.chain_config.fork_activation.timestamp is None + assert result.chain_id == U64(0) assert result.schema_fork_index == U8(0) def test_decodable_input_reports_amsterdam_on_validation_failure( @@ -455,7 +430,7 @@ def test_decodable_input_reports_amsterdam_on_validation_failure( result = deserialize_stateless_output(encoded) assert not result.successful_validation - assert result.chain_config == stateless_input.chain_config + assert result.chain_id == stateless_input.chain_id assert result.schema_fork_index == U8(0x15) @@ -485,7 +460,7 @@ def test_too_few_public_keys_fail_validation(self) -> None: invalid = StatelessInput( new_payload_request=original.new_payload_request, witness=original.witness, - chain_config=original.chain_config, + chain_id=original.chain_id, public_keys=(original.public_keys[0],), ) @@ -499,7 +474,7 @@ def test_too_many_public_keys_fail_validation(self) -> None: invalid = StatelessInput( new_payload_request=original.new_payload_request, witness=original.witness, - chain_config=original.chain_config, + chain_id=original.chain_id, public_keys=( original.public_keys[0], original.public_keys[1], From 8668e3148188119b17fe62e1e7bcb6fe3864f1c2 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 31 Jul 2026 19:56:07 -0300 Subject: [PATCH 248/265] lints --- src/ethereum/forks/amsterdam/stateless.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 274992e23fd..69140636f30 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -237,8 +237,8 @@ def verify_stateless_new_payload( witness = stateless_input.witness try: - # Note that in EEST we have one implementation per fork, so we don't need - # to check execution payload timestamp against the current fork activation + # EEST has one implementation per fork, so it does not need to check + # the execution payload timestamp against the current fork activation # information. A real implementation MUST do these checks! # Validate the headers are contiguous and compute their From 5d3c30e78e63b541d900271ef3b54ec86f077762 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 3 Aug 2026 09:45:40 -0300 Subject: [PATCH 249/265] feat: update stateless validation to use schema_id instead of schema_fork_index --- .../plugins/filler/tests/test_filler.py | 19 +++++++---- .../specs/blockchain_stateless.py | 32 +++++++++++-------- src/ethereum/forks/amsterdam/stateless.py | 13 +++++--- .../forks/amsterdam/stateless_guest.py | 4 +-- src/ethereum/forks/amsterdam/stateless_ssz.py | 10 +++--- tests/json_loader/test_stateless_guest.py | 25 ++++++++------- 6 files changed, 60 insertions(+), 43 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 4fdca444bcf..23325b230ad 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1150,6 +1150,7 @@ def test_execution_witness_in_blockchain_fixture( sib = block.stateless_input_bytes assert sib is not None and len(sib) > 0 from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_BYTES, ) @@ -1162,12 +1163,12 @@ def test_execution_witness_in_blockchain_fixture( deserialize_stateless_output, ) from ethereum_types.bytes import Bytes as EthereumBytes - from ethereum_types.numeric import U8, U64 + from ethereum_types.numeric import U16, U64 stateless_output = deserialize_stateless_output(EthereumBytes(bytes(sob))) assert stateless_output.successful_validation is True assert stateless_output.chain_id == U64(1) - assert stateless_output.schema_fork_index == U8(0x15) + assert stateless_output.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) engine_fixture_path = Path( "fixtures/blockchain_tests_engine/for_amsterdam/amsterdam/" @@ -1367,8 +1368,11 @@ def test_execution_witness_expected_true_reuses_canonical_stateless_result( from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, ) + from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, + ) from ethereum_types.bytes import Bytes as EthereumBytes - from ethereum_types.numeric import U8 + from ethereum_types.numeric import U16 stateless_input = deserialize_stateless_input( EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) @@ -1378,7 +1382,7 @@ def test_execution_witness_expected_true_reuses_canonical_stateless_result( ) assert stateless_output.successful_validation is True - assert stateless_output.schema_fork_index == U8(0x15) + assert stateless_output.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) assert ( stateless_output.new_payload_request_root @@ -1429,8 +1433,11 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, ) + from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, + ) from ethereum_types.bytes import Bytes as EthereumBytes - from ethereum_types.numeric import U8 + from ethereum_types.numeric import U16 stateless_input = deserialize_stateless_input( EthereumBytes(bytes.fromhex(block["statelessInputBytes"][2:])) @@ -1440,7 +1447,7 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( ) assert stateless_output.successful_validation is False - assert stateless_output.schema_fork_index == U8(0x15) + assert stateless_output.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) assert stateless_output.new_payload_request_root != Hash32(b"\0" * 32) assert ( stateless_output.new_payload_request_root diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index c44883319c7..a4c27538185 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -885,8 +885,11 @@ def build_amsterdam_stateless_artifacts_from_t8n( build_stateless_input, serialize_stateless_input, ) + from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, + ) from ethereum_types.bytes import Bytes as AmsterdamBytes - from ethereum_types.numeric import U8, U64 + from ethereum_types.numeric import U16, U64 parent_number = ZeroPaddedHexNumber(block_number - 1) parent_header_rlp = previous_env.block_headers.get(parent_number) @@ -946,7 +949,7 @@ def build_amsterdam_stateless_artifacts_from_t8n( ), successful_validation=True, chain_id=U64(chain_id), - schema_fork_index=U8(0x15), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), ) stateless_output_bytes = serialize_stateless_output(stateless_output) return ( @@ -1012,13 +1015,13 @@ def is_invalid_input_stateless_output(stateless_output: Any) -> bool: """ Return whether output is the invalid stateless input sentinel. """ - from ethereum_types.numeric import U8, U64 + from ethereum_types.numeric import U16, U64 return ( not stateless_output.successful_validation and bytes(stateless_output.new_payload_request_root) == b"\0" * 32 and stateless_output.chain_id == U64(0) - and stateless_output.schema_fork_index == U8(0) + and stateless_output.schema_id == U16(0) ) @@ -1045,23 +1048,26 @@ def assert_amsterdam_stateless_output_request_root( ) -def assert_amsterdam_stateless_output_schema_fork_index( +def assert_amsterdam_stateless_output_schema_id( *, block_number: int, stateless_output: Any, ) -> None: """ - Assert the output identifies the fork rules executed by the guest. + Assert the output identifies the input schema executed by the guest. """ - from ethereum_types.numeric import U8 + from ethereum.forks.amsterdam.stateless_ssz import ( + STATELESS_INPUT_SCHEMA_ID, + ) + from ethereum_types.numeric import U16 - expected_fork_index = U8(0x15) + expected_schema_id = U16(STATELESS_INPUT_SCHEMA_ID) - if stateless_output.schema_fork_index != expected_fork_index: + if stateless_output.schema_id != expected_schema_id: raise AssertionError( - "Stateless output schema_fork_index mismatch for block " - f"{block_number}: got {stateless_output.schema_fork_index}, " - f"want {expected_fork_index}" + "Stateless output schema_id mismatch for block " + f"{block_number}: got {stateless_output.schema_id}, " + f"want {expected_schema_id}" ) @@ -1100,7 +1106,7 @@ def verify_amsterdam_stateless_output( stateless_input=stateless_input, stateless_output=stateless_output, ) - assert_amsterdam_stateless_output_schema_fork_index( + assert_amsterdam_stateless_output_schema_id( block_number=block_number, stateless_output=stateless_output, ) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 69140636f30..1d89f72de47 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -9,7 +9,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes from ethereum_types.frozen import slotted_freezable -from ethereum_types.numeric import U8, U64 +from ethereum_types.numeric import U16, U64 from ethereum.crypto.hash import Hash32, keccak256 from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader @@ -172,10 +172,11 @@ class StatelessValidationResult: ``run_stateless_guest`` cannot decode the input bytes. """ - schema_fork_index: U8 + schema_id: U16 """ - Fork rules executed by the guest. This uses the invalid-input sentinel - when ``run_stateless_guest`` cannot decode the input bytes. + Exact input schema decoded and executed by the guest. This uses the + invalid-input sentinel when ``run_stateless_guest`` cannot decode the + input bytes. """ @@ -231,6 +232,8 @@ def verify_stateless_new_payload( """ Statelessly validate the execution payload. """ + from .stateless_ssz import STATELESS_INPUT_SCHEMA_ID + new_payload_request_root = compute_new_payload_request_root( stateless_input ) @@ -272,5 +275,5 @@ def verify_stateless_new_payload( new_payload_request_root=new_payload_request_root, successful_validation=successful_validation, chain_id=stateless_input.chain_id, - schema_fork_index=U8(int(ProtocolFork.Amsterdam)), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), ) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 9fe6cbe465d..519c620e9a8 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -3,7 +3,7 @@ """ from ethereum_types.bytes import Bytes -from ethereum_types.numeric import U8, U64 +from ethereum_types.numeric import U16, U64 from ethereum.crypto.hash import Hash32 @@ -55,7 +55,7 @@ def _default_failed_stateless_output() -> StatelessValidationResult: new_payload_request_root=Hash32(b"\0" * 32), successful_validation=False, chain_id=U64(0), - schema_fork_index=U8(0), + schema_id=U16(0), ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index cbf40e2caa7..ac88dbf4cda 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -7,8 +7,8 @@ """ from ethereum_types.bytes import Bytes, Bytes48, Bytes96 -from ethereum_types.numeric import U8, U64, U256, Uint -from remerkleable.basic import boolean, uint8, uint64, uint256 +from ethereum_types.numeric import U16, U64, U256, Uint +from remerkleable.basic import boolean, uint16, uint64, uint256 from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector from remerkleable.complex import Container from remerkleable.complex import List as SszList @@ -214,7 +214,7 @@ class SszStatelessValidationResult(Container): new_payload_request_root: Bytes32 successful_validation: boolean chain_id: uint64 - schema_fork_index: uint8 + schema_id: uint16 # --- Conversion helpers --- @@ -552,7 +552,7 @@ def validation_result_to_ssz( new_payload_request_root=Bytes32(bytes(vr.new_payload_request_root)), successful_validation=boolean(vr.successful_validation), chain_id=uint64(int(vr.chain_id)), - schema_fork_index=uint8(int(vr.schema_fork_index)), + schema_id=uint16(int(vr.schema_id)), ) @@ -566,5 +566,5 @@ def ssz_to_validation_result( ), successful_validation=bool(ssz_vr.successful_validation), chain_id=U64(ssz_vr.chain_id), - schema_fork_index=U8(ssz_vr.schema_fork_index), + schema_id=U16(ssz_vr.schema_id), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 819b910c737..33ac387ac0b 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -6,7 +6,7 @@ import pytest from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 -from ethereum_types.numeric import U8, U64, U256, Uint +from ethereum_types.numeric import U16, U64, U256, Uint from ethereum.crypto.hash import Hash32 from ethereum.forks.amsterdam.block_access_lists import BlockAccessList @@ -187,7 +187,7 @@ def _make_stateless_output() -> StatelessValidationResult: new_payload_request_root=Hash32(_rb(32)), successful_validation=True, chain_id=U64(1), - schema_fork_index=U8(0x15), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), ) @@ -387,23 +387,24 @@ def test_roundtrip(self) -> None: """Encoding then decoding recovers the original result.""" original = _make_stateless_output() encoded = serialize_stateless_output(original) + assert encoded[-2:] == STATELESS_INPUT_SCHEMA_ID.to_bytes(2, "little") recovered = deserialize_stateless_output(encoded) assert recovered == original assert recovered.chain_id == U64(1) - assert recovered.schema_fork_index == U8(0x15) + assert recovered.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) def test_failed_validation(self) -> None: - """Preserve the executed fork when later validation fails.""" + """Preserve the input schema when later validation fails.""" original = StatelessValidationResult( new_payload_request_root=Hash32(_rb(32)), successful_validation=False, chain_id=U64(1), - schema_fork_index=U8(0x15), + schema_id=U16(STATELESS_INPUT_SCHEMA_ID), ) encoded = serialize_stateless_output(original) recovered = deserialize_stateless_output(encoded) assert recovered == original - assert recovered.schema_fork_index == U8(0x15) + assert recovered.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) class TestRunStatelessGuest: @@ -417,12 +418,12 @@ def test_invalid_input_bytes_return_failed_validation(self) -> None: assert result.new_payload_request_root == Hash32(b"\0" * 32) assert not result.successful_validation assert result.chain_id == U64(0) - assert result.schema_fork_index == U8(0) + assert result.schema_id == U16(0) - def test_decodable_input_reports_amsterdam_on_validation_failure( + def test_decodable_input_reports_schema_on_validation_failure( self, ) -> None: - """Decoded Amsterdam input reports its fork after execution failure.""" + """Decoded input reports its schema after execution failure.""" stateless_input = _make_stateless_input() encoded = run_stateless_guest( serialize_stateless_input(stateless_input) @@ -431,7 +432,7 @@ def test_decodable_input_reports_amsterdam_on_validation_failure( assert not result.successful_validation assert result.chain_id == stateless_input.chain_id - assert result.schema_fork_index == U8(0x15) + assert result.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) class TestComputeNewPayloadRequestRoot: @@ -466,7 +467,7 @@ def test_too_few_public_keys_fail_validation(self) -> None: result = verify_stateless_new_payload(invalid) assert not result.successful_validation - assert result.schema_fork_index == U8(0x15) + assert result.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) def test_too_many_public_keys_fail_validation(self) -> None: """Stateless validation should fail with too many public keys.""" @@ -484,4 +485,4 @@ def test_too_many_public_keys_fail_validation(self) -> None: result = verify_stateless_new_payload(invalid) assert not result.successful_validation - assert result.schema_fork_index == U8(0x15) + assert result.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) From 8efd3acab967d610274c6d7b7b823c5df81fb490 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 3 Aug 2026 21:05:08 -0300 Subject: [PATCH 250/265] feat: preserve extra data in environment and update block artifact construction --- .../src/execution_testing/specs/blockchain.py | 7 ++++--- .../test_types/block_types.py | 2 +- .../test_types/tests/test_types.py | 10 ++++++++++ .../evm_tools/loaders/fork_loader.py | 6 ++++++ .../evm_tools/t8n/result.py | 19 +++++++++++++++++-- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index e2da2fe6c9d..c07f1b55148 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -457,6 +457,9 @@ def set_environment(self, env: Environment) -> Environment: new_env_values["gas_limit"] = ( self.gas_limit or env.parent_gas_limit or Environment().gas_limit ) + new_env_values["extra_data"] = ( + self.extra_data if self.extra_data is not None else Bytes(b"") + ) if not isinstance(self.base_fee_per_gas, Removable): new_env_values["base_fee_per_gas"] = self.base_fee_per_gas new_env_values["withdrawals"] = self.withdrawals @@ -1027,9 +1030,7 @@ def generate_block_data( ), blob_gas_used=blob_gas_used, transactions_trie=Transaction.list_root(txs), - extra_data=( - block.extra_data if block.extra_data is not None else b"" - ), + extra_data=env.extra_data, slot_number=slot_number_value, fork=fork, ) diff --git a/packages/testing/src/execution_testing/test_types/block_types.py b/packages/testing/src/execution_testing/test_types/block_types.py index 2ffffab7011..51783c89132 100644 --- a/packages/testing/src/execution_testing/test_types/block_types.py +++ b/packages/testing/src/execution_testing/test_types/block_types.py @@ -207,7 +207,7 @@ def set_fork_requirements(self, fork: Fork) -> "Environment": if fork.header_slot_number_required() and self.slot_number is None: updated_values["slot_number"] = 0 - return self.copy(**updated_values) + return self.copy(extra_data=self.extra_data, **updated_values) def __hash__(self) -> int: """Hashes the environment object.""" diff --git a/packages/testing/src/execution_testing/test_types/tests/test_types.py b/packages/testing/src/execution_testing/test_types/tests/test_types.py index 3a3e65aee46..f97a29b1cb3 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_types.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_types.py @@ -15,6 +15,7 @@ to_json, ) from execution_testing.base_types.pydantic import CopyValidateModel +from execution_testing.forks import Amsterdam from ..account_types import EOA, Alloc from ..block_types import ( @@ -953,6 +954,15 @@ def test_model_copy(model: CopyValidateModel) -> None: assert model.copy().model_fields_set == model.model_fields_set +def test_environment_fork_requirements_preserve_extra_data() -> None: + """Preserve extra data while applying fork requirements.""" + env = Environment(extra_data=b"current block") + + assert ( + env.set_fork_requirements(Amsterdam).extra_data == env.extra_data + ) + + @pytest.mark.parametrize( "value, expected", [ diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index fa0f67962e3..b99a01af449 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -248,6 +248,12 @@ def Block(self) -> Any: """Block class of the fork.""" return self._module("blocks").Block + @property + def block_rlp_size_limit(self) -> int | None: + """Return the maximum RLP-encoded block size, if defined.""" + limit = getattr(self._module("fork"), "MAX_RLP_BLOCK_SIZE", None) + return int(limit) if limit is not None else None + @property def decode_receipt(self) -> Any: """decode_receipt function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/result.py b/src/ethereum_spec_tools/evm_tools/t8n/result.py index 4184c17b71c..872ca7f9964 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/result.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/result.py @@ -166,7 +166,11 @@ def _build_stateless_artifacts( gas_limit=block_env.block_gas_limit, gas_used=Uint(result_arguments["gas_used"]), timestamp=block_env.time, - extra_data=Bytes(b""), + extra_data=Bytes( + t8n.env.extra_data + if "extra_data" in t8n.env.model_fields_set + else b"" + ), prev_randao=block_env.prev_randao, nonce=Bytes8(b"\x00" * 8), base_fee_per_gas=block_env.base_fee_per_gas, @@ -209,7 +213,18 @@ def _build_stateless_artifacts( stateless_output_bytes ) - if t8n.rejected_transactions or block_exception is not None: + # The transition phase executes the block body before the finalized block + # exists, so block-level RLP validation is first observable here. + block_rlp_size_limit = t8n.fork.block_rlp_size_limit + block_rlp_limit_exceeded = ( + block_rlp_size_limit is not None + and len(rlp.encode(block)) > block_rlp_size_limit + ) + if ( + t8n.rejected_transactions + or block_exception is not None + or block_rlp_limit_exceeded + ): assert not stateless_output.successful_validation else: assert stateless_output.successful_validation, ( From 82d56301d7bb1b048cb49ecd11166220bcf398a4 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 3 Aug 2026 21:19:09 -0300 Subject: [PATCH 251/265] lints --- .../specs/blockchain_stateless.py | 4 ++- .../test_types/tests/test_types.py | 4 +-- .../test_witness_public_keys.py | 34 ++++++++++++------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index a4c27538185..0caf63d769f 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -484,7 +484,9 @@ def execution_witness_implicit_codes_for_block( if not addresses: return [] - effective_alloc = alloc.get() if isinstance(alloc, LazyAlloc) else alloc + effective_alloc = ( + alloc.materialize() if isinstance(alloc, LazyAlloc) else alloc + ) codes: List[Bytes] = [] seen: set[Bytes] = set() diff --git a/packages/testing/src/execution_testing/test_types/tests/test_types.py b/packages/testing/src/execution_testing/test_types/tests/test_types.py index f97a29b1cb3..fbb276a85b9 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_types.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_types.py @@ -958,9 +958,7 @@ def test_environment_fork_requirements_preserve_extra_data() -> None: """Preserve extra data while applying fork requirements.""" env = Environment(extra_data=b"current block") - assert ( - env.set_fork_requirements(Amsterdam).extra_data == env.extra_data - ) + assert env.set_fork_requirements(Amsterdam).extra_data == env.extra_data @pytest.mark.parametrize( diff --git a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py index d39346e76dd..901a0d9447c 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_witness_public_keys.py @@ -1,8 +1,13 @@ """Stateless input transaction public-key tests.""" import pytest -from coincurve import ecdsa -from coincurve.keys import PublicKey +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import ( + Prehashed, + encode_dss_signature, +) from execution_testing import ( Account, Address, @@ -15,6 +20,7 @@ from execution_testing.test_types.execution_witness.modifiers import ( replace_public_key_at, ) +from spec256k1 import PublicKey pytestmark = pytest.mark.valid_from("Amsterdam") @@ -146,7 +152,6 @@ def _recover_public_key( public_key = PublicKey.from_signature_and_message( signature, signing_hash, - hasher=None, ) return Bytes(public_key.format(compressed=False)) @@ -194,14 +199,17 @@ def _signature_verifies( signing_hash: bytes, ) -> bool: """Return whether ``public_key`` verifies the transaction signature.""" - compact_signature = int(tx.r).to_bytes(32, byteorder="big") + int( - tx.s - ).to_bytes(32, byteorder="big") - der_signature = ecdsa.cdata_to_der( - ecdsa.deserialize_compact(compact_signature) - ) - return PublicKey(bytes(public_key)).verify( - der_signature, - signing_hash, - hasher=None, + der_signature = encode_dss_signature(int(tx.r), int(tx.s)) + verifying_key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256K1(), + bytes(public_key), ) + try: + verifying_key.verify( + der_signature, + signing_hash, + ec.ECDSA(Prehashed(hashes.SHA256())), + ) + except InvalidSignature: + return False + return True From ecd8e61e0b23cb45bff74fa27ae096937cf00c62 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 3 Aug 2026 21:35:29 -0300 Subject: [PATCH 252/265] fix old stateless unit tests --- .../cli/pytest_commands/plugins/filler/tests/test_filler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 23325b230ad..6ec99a07d4a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -843,7 +843,7 @@ def test_execution_witness(state_test, pre) -> None: contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) state_test(env=Environment(), pre=pre, post={contract: Account(storage={0: 1})}, - tx=Transaction(to=contract, gas_limit=100_000, sender=pre.fund_eoa())) + tx=Transaction(to=contract, sender=pre.fund_eoa())) """ ) @@ -868,7 +868,7 @@ def test_skip_stateless_validation( ) -> None: contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) sender = pre.fund_eoa() - tx = Transaction(to=contract, gas_limit=100_000, sender=sender) + tx = Transaction(to=contract, sender=sender) blockchain_test( pre=pre, @@ -1049,7 +1049,7 @@ def test_execution_witness_rlp_modifier( ) -> None: contract = pre.deploy_contract(code=Op.SSTORE(0, 1) + Op.STOP) sender = pre.fund_eoa() - tx = Transaction(to=contract, gas_limit=100_000, sender=sender) + tx = Transaction(to=contract, sender=sender) blockchain_test( pre=pre, From fed8cc58db5e5700a3b4af6cec9a7161f71598f5 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 3 Aug 2026 22:09:05 -0300 Subject: [PATCH 253/265] fix --- .../execution_engine/validation_helpers.py | 18 ++++++- tests/json_loader/test_stateless_guest.py | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py index 06c3735fa3b..f76af3d1ffa 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py +++ b/src/ethereum/forks/amsterdam/execution_engine/validation_helpers.py @@ -15,6 +15,7 @@ from ..blocks import Block, Header from ..fork import EMPTY_OMMER_HASH from ..requests import compute_requests_hash +from ..transactions import LegacyTransaction, decode_transaction from .requests import ExecutionRequests, encode_execution_requests from .types import ExecutionPayload @@ -84,6 +85,18 @@ def _payload_header( ) +def _payload_transaction_to_block_transaction( + encoded_transaction: Bytes, +) -> LegacyTransaction | Bytes: + """Return the canonical block representation of a payload transaction.""" + if not encoded_transaction or encoded_transaction[0] < 0xC0: + return encoded_transaction + + transaction = decode_transaction(encoded_transaction) + assert isinstance(transaction, LegacyTransaction) + return transaction + + def _payload_block( execution_payload: ExecutionPayload, parent_beacon_block_root: Root, @@ -100,7 +113,10 @@ def _payload_block( return Block( header=header, - transactions=execution_payload.transactions, + transactions=tuple( + _payload_transaction_to_block_transaction(encoded_transaction) + for encoded_transaction in execution_payload.transactions + ), ommers=(), withdrawals=execution_payload.withdrawals, ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 33ac387ac0b..9dd9c2438a9 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -21,6 +21,9 @@ ExecutionPayload, NewPayloadRequest, ) +from ethereum.forks.amsterdam.execution_engine.validation_helpers import ( + _payload_block, +) from ethereum.forks.amsterdam.fork_types import Bloom from ethereum.forks.amsterdam.stateless import ( ExecutionWitness, @@ -266,6 +269,55 @@ def test_rejected_transaction_omits_public_key( assert payload.transactions == (Bytes(rlp.encode(tx)),) assert stateless_input.public_keys == () + def test_payload_round_trip_preserves_legacy_transaction_rlp(self) -> None: + """Preserve canonical legacy transaction RLP through the payload.""" + tx = LegacyTransaction( + nonce=U256(0), + gas_price=Uint(1), + gas=Uint(21_000), + to=Address(b"\x00" * 20), + value=U256(0), + data=Bytes(b"\x00" * 200_000), + v=U256(27), + r=U256(0), + s=U256(1), + ) + block = Block( + header=_make_header(), + transactions=(tx,), + ommers=(), + withdrawals=(), + ) + execution_requests = ExecutionRequests( + deposits=(), + withdrawals=(), + consolidations=(), + builder_deposits=(), + builder_exits=(), + ) + stateless_input = build_stateless_input( + block, + execution_witness=ExecutionWitness( + state=(), + codes=(), + headers=(), + ), + execution_requests=execution_requests, + block_access_list=[], + chain_id=U64(1), + ) + + rebuilt_block = _payload_block( + stateless_input.new_payload_request.execution_payload, + block.header.parent_beacon_block_root, + execution_requests, + ) + + assert rebuilt_block.transactions == (tx,) + assert rlp.encode(rebuilt_block.transactions) == rlp.encode( + block.transactions + ) + class TestSerializeStatelessInput: """Test serialize_stateless_input.""" From 4e7a7177242c3ab3dbc3525c3395933e907d7416 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 5 Aug 2026 19:09:26 -0300 Subject: [PATCH 254/265] fix lints --- .../src/execution_testing/specs/blockchain_stateless.py | 4 ++-- src/ethereum/forks/amsterdam/stateless_ssz.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 0caf63d769f..46526addff7 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -754,7 +754,7 @@ def _convert_amsterdam_withdrawals( Withdrawal as AmsterdamWithdrawal, ) from ethereum.state import Address as AmsterdamAddress - from ethereum_types.numeric import U64, U256 + from ethereum_types.numeric import U64 if withdrawals is None: return () @@ -763,7 +763,7 @@ def _convert_amsterdam_withdrawals( index=U64(int(withdrawal.index)), validator_index=U64(int(withdrawal.validator_index)), address=AmsterdamAddress(bytes(withdrawal.address)), - amount=U256(int(withdrawal.amount)), + amount=U64(int(withdrawal.amount)), ) for withdrawal in withdrawals ) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index ac88dbf4cda..7e33cd19e98 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -236,7 +236,7 @@ def _ssz_to_withdrawal(sw: SszWithdrawal) -> Withdrawal: index=U64(sw.index), validator_index=U64(sw.validator_index), address=Address(bytes(sw.address)), - amount=U256(sw.amount), + amount=U64(sw.amount), ) From c053640fe0695f86f82533a77fde2d53bb6bdd2e Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 11 Aug 2026 10:17:03 -0300 Subject: [PATCH 255/265] refactor(stateless_ssz): replace SszList with ProgressiveList for state and codes in SszExecutionWitness and SszStatelessInput --- src/ethereum/forks/amsterdam/stateless_ssz.py | 30 ++------ tests/json_loader/test_stateless_guest.py | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index 7e33cd19e98..a5d4229878a 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -44,20 +44,6 @@ MAX_EXTRA_DATA_BYTES = 32 -# Stateless witness resource bounds. These are local limits chosen for this -# schema, not consensus-spec SSZ constants. - -# BAL permits block_gas_limit // 2_000 items. At 500M gas, that is -# 250_000 account accesses; budgeting 16 witness nodes per account gives -# 4_000_000 nodes, rounded up to 2**22. This targets an account-heavy -# witness, since deep storage tries are harder to construct. Depth of -# 16 is around double the depth of mainnet account trie. -MAX_WITNESS_NODES = 2**22 - -# Enough room for one pre-state bytecode read per BAL item at 500M gas: -# 500_000_000 // 2_000 = 250_000, rounded up to 2**18. -MAX_WITNESS_CODES = 2**18 - # Execution only exposes the previous 256 block hashes. MAX_WITNESS_HEADERS = 256 # As defined in EIP-7954. @@ -68,10 +54,6 @@ # 2**10 is the next power of two, with almost twice the needed capacity. MAX_BYTES_PER_WITNESS_NODE = 2**10 -# One public key is supplied per transaction. Every valid transaction consumes -# at least 21_000 gas, so a 500M gas block can contain at most -# 500_000_000 // 21_000 = 23_809 transactions, rounded up to next power of 2. -MAX_PUBLIC_KEYS = 2**15 PUBLIC_KEY_BYTES = 65 # Stateless guest input bytes are schema-prefixed: @@ -194,8 +176,8 @@ class SszNewPayloadRequest(Container): class SszExecutionWitness(Container): """SSZ container mirroring ``ExecutionWitness``.""" - state: SszList[ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES] - codes: SszList[ByteList[MAX_BYTES_PER_CODE], MAX_WITNESS_CODES] + state: ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]] + codes: ProgressiveList[ByteList[MAX_BYTES_PER_CODE]] headers: SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] @@ -205,7 +187,7 @@ class SszStatelessInput(Container): new_payload_request: SszNewPayloadRequest witness: SszExecutionWitness chain_id: uint64 - public_keys: SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS] + public_keys: ProgressiveList[ByteVector[PUBLIC_KEY_BYTES]] class SszStatelessValidationResult(Container): @@ -485,10 +467,10 @@ def _witness_to_ssz( ) -> SszExecutionWitness: """Convert an ExecutionWitness to its SSZ form.""" return SszExecutionWitness( - state=SszList[ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES]( + state=ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]]( ByteList[MAX_BYTES_PER_WITNESS_NODE](bytes(s)) for s in w.state ), - codes=SszList[ByteList[MAX_BYTES_PER_CODE], MAX_WITNESS_CODES]( + codes=ProgressiveList[ByteList[MAX_BYTES_PER_CODE]]( ByteList[MAX_BYTES_PER_CODE](bytes(c)) for c in w.codes ), headers=SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS]( @@ -524,7 +506,7 @@ def stateless_input_to_ssz( ), witness=_witness_to_ssz(si.witness), chain_id=uint64(int(si.chain_id)), - public_keys=SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS]( + public_keys=ProgressiveList[ByteVector[PUBLIC_KEY_BYTES]]( ByteVector[PUBLIC_KEY_BYTES](bytes(pk)) for pk in si.public_keys ), ) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 9dd9c2438a9..59d30822d4a 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -7,6 +7,8 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U16, U64, U256, Uint +from remerkleable.complex import List as SszList +from remerkleable.progressive import ProgressiveList from ethereum.crypto.hash import Hash32 from ethereum.forks.amsterdam.block_access_lists import BlockAccessList @@ -44,10 +46,17 @@ serialize_stateless_input, ) from ethereum.forks.amsterdam.stateless_ssz import ( + MAX_BYTES_PER_CODE, + MAX_BYTES_PER_HEADER, + MAX_BYTES_PER_WITNESS_NODE, + MAX_WITNESS_HEADERS, + PUBLIC_KEY_BYTES, STATELESS_INPUT_SCHEMA_FORK_INDEX, STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_BYTES, STATELESS_INPUT_SCHEMA_REVISION, + SszExecutionWitness, + SszStatelessInput, stateless_input_to_ssz, ) from ethereum.forks.amsterdam.transactions import LegacyTransaction @@ -367,6 +376,68 @@ def test_rejects_non_65_byte_public_key(self) -> None: with pytest.raises(ValueError): serialize_stateless_input(invalid) + @pytest.mark.parametrize( + "witness", + [ + pytest.param( + ExecutionWitness( + state=(Bytes(b"\0" * (MAX_BYTES_PER_WITNESS_NODE + 1)),), + codes=(), + headers=(), + ), + id="state-node", + ), + pytest.param( + ExecutionWitness( + state=(), + codes=(Bytes(b"\0" * (MAX_BYTES_PER_CODE + 1)),), + headers=(), + ), + id="code", + ), + pytest.param( + ExecutionWitness( + state=(), + codes=(), + headers=(Bytes(b"\0" * (MAX_BYTES_PER_HEADER + 1)),), + ), + id="header", + ), + ], + ) + def test_rejects_oversized_witness_item( + self, + witness: ExecutionWitness, + ) -> None: + """Retain the structural byte limit for each witness item.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=witness, + chain_id=original.chain_id, + public_keys=original.public_keys, + ) + + with pytest.raises(Exception, match="cannot be more than limit"): + serialize_stateless_input(invalid) + + def test_rejects_more_than_256_headers(self) -> None: + """Retain the protocol-backed witness header count limit.""" + original = _make_stateless_input() + invalid = StatelessInput( + new_payload_request=original.new_payload_request, + witness=ExecutionWitness( + state=(), + codes=(), + headers=tuple(Bytes() for _ in range(MAX_WITNESS_HEADERS + 1)), + ), + chain_id=original.chain_id, + public_keys=original.public_keys, + ) + + with pytest.raises(Exception, match="too many list inputs: 257"): + serialize_stateless_input(invalid) + class TestDeserializeStatelessInput: """Test deserialize_stateless_input.""" From becc6920663e30f6d14d23fe923ebc8f9f4f58fc Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 11 Aug 2026 10:24:33 -0300 Subject: [PATCH 256/265] lints --- tests/json_loader/test_stateless_guest.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 59d30822d4a..0659719851f 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -7,8 +7,6 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes, Bytes8, Bytes32, Bytes48, Bytes96 from ethereum_types.numeric import U16, U64, U256, Uint -from remerkleable.complex import List as SszList -from remerkleable.progressive import ProgressiveList from ethereum.crypto.hash import Hash32 from ethereum.forks.amsterdam.block_access_lists import BlockAccessList @@ -50,13 +48,10 @@ MAX_BYTES_PER_HEADER, MAX_BYTES_PER_WITNESS_NODE, MAX_WITNESS_HEADERS, - PUBLIC_KEY_BYTES, STATELESS_INPUT_SCHEMA_FORK_INDEX, STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_BYTES, STATELESS_INPUT_SCHEMA_REVISION, - SszExecutionWitness, - SszStatelessInput, stateless_input_to_ssz, ) from ethereum.forks.amsterdam.transactions import LegacyTransaction From 6485ec53f72ac96f2cf29bcd70cfc9f9158628bb Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 12 Aug 2026 16:46:12 -0300 Subject: [PATCH 257/265] update geth branch for bench filling comparison Signed-off-by: jsign --- .github/workflows/benchmark-fill-parity.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/benchmark-fill-parity.yaml b/.github/workflows/benchmark-fill-parity.yaml index bd4dcfdac75..afab41d6ec6 100644 --- a/.github/workflows/benchmark-fill-parity.yaml +++ b/.github/workflows/benchmark-fill-parity.yaml @@ -13,7 +13,7 @@ on: description: "Geth branch to build" required: false type: string - default: "jsign-glamsterdam-devnet-7-t8n-zkevm" + default: "glamsterdam-devnet-8-t8n-zkevm" benchmark_file: description: "Benchmark test file to fill" required: false @@ -36,7 +36,7 @@ concurrency: env: GETH_REPO: ${{ inputs.geth_repo || 'jsign/go-ethereum' }} - GETH_BRANCH: ${{ inputs.geth_branch || 'jsign-glamsterdam-devnet-7-t8n-zkevm' }} + GETH_BRANCH: ${{ inputs.geth_branch || 'glamsterdam-devnet-8-t8n-zkevm' }} BENCHMARK_FILE: ${{ inputs.benchmark_file || 'tests/benchmark/compute/instruction/test_memory.py' }} FILL_FORK: ${{ inputs.fill_fork || 'Amsterdam' }} XDIST_WORKERS: ${{ inputs.xdist_workers || 'auto' }} From f0413642f5e6ceacf6861c3316f7bea34adc2e90 Mon Sep 17 00:00:00 2001 From: jsign Date: Thu, 13 Aug 2026 17:46:26 -0300 Subject: [PATCH 258/265] capitalize SSZ --- .../simulators/engine_witness/conftest.py | 8 +- .../test_via_engine_witness.py | 6 +- .../src/execution_testing/rpc/__init__.py | 4 +- .../testing/src/execution_testing/rpc/rpc.py | 2 +- .../src/execution_testing/rpc/rpc_types.py | 22 +-- .../tests/test_new_payload_with_witness.py | 10 +- .../forks/amsterdam/stateless_guest.py | 4 +- .../forks/amsterdam/stateless_host.py | 4 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 136 +++++++++--------- .../test_stateless_input_bytes.py | 2 +- 10 files changed, 99 insertions(+), 99 deletions(-) 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 index 81dfbc9e41f..c2d65cec1ca 100644 --- 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 @@ -16,7 +16,7 @@ from execution_testing.exceptions import ExceptionMapper from execution_testing.fixtures import BlockchainEngineFixture from execution_testing.fixtures.blockchain import FixtureHeader -from execution_testing.rpc import EngineSszRPC +from execution_testing.rpc import EngineSSZRPC pytest_plugins = ( "execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive", @@ -92,13 +92,13 @@ def genesis_header(fixture: BlockchainEngineFixture) -> "FixtureHeader": @pytest.fixture(scope="function") def engine_ssz_rpc( client: Client, client_exception_mapper: ExceptionMapper | None -) -> EngineSszRPC: +) -> EngineSSZRPC: """Provide the REST client used by the `--ssz` witness transport.""" if client_exception_mapper: - return EngineSszRPC( + return EngineSSZRPC( f"http://{client.ip}:8551", response_validation_context={ "exception_mapper": client_exception_mapper, }, ) - return EngineSszRPC(f"http://{client.ip}:8551") + return EngineSSZRPC(f"http://{client.ip}:8551") 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 index c4209f6ae1d..071f6047ae0 100644 --- 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 @@ -10,7 +10,7 @@ from execution_testing.logging import get_logger from execution_testing.rpc import ( EngineRPC, - EngineSszRPC, + EngineSSZRPC, EngineWitnessEndpointNotImplementedError, EthRPC, ForkchoiceUpdateTimeoutError, @@ -52,7 +52,7 @@ def _send_payload_with_witness( *, use_ssz_transport: bool, engine_rpc: EngineRPC, - engine_ssz_rpc: EngineSszRPC, + engine_ssz_rpc: EngineSSZRPC, payload: FixtureEngineNewPayload, ) -> NewPayloadWithWitnessResponse | JSONRPCError: """ @@ -182,7 +182,7 @@ def test_blockchain_via_engine_witness( timing_data: TimingData, eth_rpc: EthRPC, engine_rpc: EngineRPC, - engine_ssz_rpc: EngineSszRPC, + engine_ssz_rpc: EngineSSZRPC, fixture: BlockchainEngineFixture, genesis_header: FixtureHeader, use_ssz_transport: bool, diff --git a/packages/testing/src/execution_testing/rpc/__init__.py b/packages/testing/src/execution_testing/rpc/__init__.py index 21e72507b6e..950f6598696 100644 --- a/packages/testing/src/execution_testing/rpc/__init__.py +++ b/packages/testing/src/execution_testing/rpc/__init__.py @@ -9,7 +9,7 @@ BlockNumberType, DebugRPC, EngineRPC, - EngineSszRPC, + EngineSSZRPC, EngineWitnessEndpointNotImplementedError, EthRPC, ForkchoiceUpdateTimeoutError, @@ -45,7 +45,7 @@ "DebugRPC", "DEFAULT_REQUEST_TIMEOUT", "EngineRPC", - "EngineSszRPC", + "EngineSSZRPC", "EngineWitnessEndpointNotImplementedError", "EthConfigResponse", "EthRPC", diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 68b1166e21f..35ffaa6d4ff 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -1638,7 +1638,7 @@ def _do_forkchoice_update() -> ForkchoiceUpdateResponse: return _do_forkchoice_update() -class EngineSszRPC(BaseJwtRPC): +class EngineSSZRPC(BaseJwtRPC): """ REST client for `POST /new-payload-with-witness`. diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index f10917f5443..af86fe58cc2 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -12,8 +12,8 @@ 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 remerkleable.complex import List as SSZList +from remerkleable.union import Union as SSZUnion from execution_testing.base_types import ( Address, @@ -436,16 +436,16 @@ class EthConfigResponse(CamelModel): 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 _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): +class _SSZNewPayloadWithWitnessResponse(Container): status: uint8 - latest_valid_hash: SszUnion[None, ByteVector[32]] - validation_error: SszUnion[None, ByteList[VALIDATION_ERROR_MAX]] + latest_valid_hash: SSZUnion[None, ByteVector[32]] + validation_error: SSZUnion[None, ByteList[VALIDATION_ERROR_MAX]] witness: ByteList[MAX_WITNESS_BYTES] @@ -574,7 +574,7 @@ class NewPayloadWithWitnessResponse: @classmethod def from_ssz_bytes(cls, data: bytes) -> Self: """Decode an SSZ-encoded NewPayloadWithWitnessResponseV1 body.""" - resp = _SszNewPayloadWithWitnessResponse.decode_bytes(data) + resp = _SSZNewPayloadWithWitnessResponse.decode_bytes(data) status_int = int(resp.status) try: @@ -598,7 +598,7 @@ def from_ssz_bytes(cls, data: bytes) -> Self: raise ValueError( f"{status.value} SSZ response must not contain a witness" ) - inner = _SszExecutionWitness.decode_bytes(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], 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 index 72154b0c061..c9cfbacf897 100644 --- 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 @@ -12,15 +12,15 @@ VALIDATION_ERROR_MAX, NewPayloadWithWitnessResponse, PayloadStatusEnum, - _SszExecutionWitness, - _SszNewPayloadWithWitnessResponse, + _SSZExecutionWitness, + _SSZNewPayloadWithWitnessResponse, ) def _build_inner_witness( state: list[bytes], codes: list[bytes], headers: list[bytes] ) -> bytes: - inner = _SszExecutionWitness( + 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], @@ -34,7 +34,7 @@ def _build_response( validation_error: str | None, witness_bytes: bytes, ) -> bytes: - fields = _SszNewPayloadWithWitnessResponse.fields() + fields = _SSZNewPayloadWithWitnessResponse.fields() lvh_type = fields["latest_valid_hash"] ve_type = fields["validation_error"] @@ -53,7 +53,7 @@ def _build_response( ), ) - resp = _SszNewPayloadWithWitnessResponse( + resp = _SSZNewPayloadWithWitnessResponse( status=uint8(status), latest_valid_hash=lvh, validation_error=ve, diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 519c620e9a8..20c6e190f7c 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -15,7 +15,7 @@ from .stateless_ssz import ( STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_SIZE, - SszStatelessInput, + SSZStatelessInput, ssz_to_stateless_input, validation_result_to_ssz, ) @@ -41,7 +41,7 @@ def deserialize_stateless_input(data: Bytes) -> StatelessInput: raise ValueError( f"Unsupported stateless input schema id: 0x{schema_id:04x}" ) - ssz_obj = SszStatelessInput.decode_bytes( + ssz_obj = SSZStatelessInput.decode_bytes( data[STATELESS_INPUT_SCHEMA_ID_SIZE:] ) return ssz_to_stateless_input(ssz_obj) diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 6bed33433e3..9785df5542b 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -23,7 +23,7 @@ ) from .stateless_ssz import ( STATELESS_INPUT_SCHEMA_ID_BYTES, - SszStatelessValidationResult, + SSZStatelessValidationResult, ssz_to_validation_result, stateless_input_to_ssz, ) @@ -48,7 +48,7 @@ def serialize_stateless_input( def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: """Deserialize a StatelessValidationResult from SSZ bytes.""" - ssz_obj = SszStatelessValidationResult.decode_bytes(data) + ssz_obj = SSZStatelessValidationResult.decode_bytes(data) return ssz_to_validation_result(ssz_obj) diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py index a5d4229878a..1802b70cecd 100644 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ b/src/ethereum/forks/amsterdam/stateless_ssz.py @@ -11,7 +11,7 @@ from remerkleable.basic import boolean, uint16, uint64, uint256 from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector from remerkleable.complex import Container -from remerkleable.complex import List as SszList +from remerkleable.complex import List as SSZList from remerkleable.progressive import ( ProgressiveByteList, ProgressiveContainer, @@ -59,7 +59,7 @@ # Stateless guest input bytes are schema-prefixed: # schema_id || encoded_payload # schema_id is fork_index || schema_revision. Amsterdam is fork 0x15, and -# revision 0x01 uses SSZ encode(SszStatelessInput) for the payload. +# revision 0x01 uses SSZ encode(SSZStatelessInput) for the payload. STATELESS_INPUT_SCHEMA_FORK_INDEX = ProtocolFork.Amsterdam STATELESS_INPUT_SCHEMA_REVISION = 0x01 STATELESS_INPUT_SCHEMA_ID = ( @@ -75,7 +75,7 @@ # --- SSZ Container types --- -class SszWithdrawal(Container): +class SSZWithdrawal(Container): """SSZ container mirroring ``Withdrawal``.""" index: uint64 @@ -84,7 +84,7 @@ class SszWithdrawal(Container): amount: uint64 -class SszExecutionPayload( +class SSZExecutionPayload( ProgressiveContainer(active_fields=[1] * 19) # type: ignore[misc] ): """SSZ container mirroring ``ExecutionPayload``.""" @@ -103,14 +103,14 @@ class SszExecutionPayload( base_fee_per_gas: uint256 block_hash: Bytes32 transactions: ProgressiveList[ProgressiveByteList] - withdrawals: ProgressiveList[SszWithdrawal] + withdrawals: ProgressiveList[SSZWithdrawal] blob_gas_used: uint64 excess_blob_gas: uint64 block_access_list: ProgressiveByteList slot_number: uint64 -class SszDepositRequest(Container): +class SSZDepositRequest(Container): """SSZ container mirroring ``DepositRequest``.""" pubkey: ByteVector[48] @@ -120,7 +120,7 @@ class SszDepositRequest(Container): index: uint64 -class SszWithdrawalRequest(Container): +class SSZWithdrawalRequest(Container): """SSZ container mirroring ``WithdrawalRequest``.""" source_address: ByteVector[20] @@ -128,7 +128,7 @@ class SszWithdrawalRequest(Container): amount: uint64 -class SszConsolidationRequest(Container): +class SSZConsolidationRequest(Container): """SSZ container mirroring ``ConsolidationRequest``.""" source_address: ByteVector[20] @@ -136,7 +136,7 @@ class SszConsolidationRequest(Container): target_pubkey: ByteVector[48] -class SszBuilderDepositRequest(Container): +class SSZBuilderDepositRequest(Container): """SSZ container mirroring ``BuilderDepositRequest``.""" pubkey: ByteVector[48] @@ -145,52 +145,52 @@ class SszBuilderDepositRequest(Container): signature: ByteVector[96] -class SszBuilderExitRequest(Container): +class SSZBuilderExitRequest(Container): """SSZ container mirroring ``BuilderExitRequest``.""" source_address: ByteVector[20] pubkey: ByteVector[48] -class SszExecutionRequests( +class SSZExecutionRequests( ProgressiveContainer(active_fields=[1] * 5) # type: ignore[misc] ): """SSZ container mirroring ``ExecutionRequests``.""" - deposits: ProgressiveList[SszDepositRequest] - withdrawals: ProgressiveList[SszWithdrawalRequest] - consolidations: ProgressiveList[SszConsolidationRequest] - builder_deposits: ProgressiveList[SszBuilderDepositRequest] - builder_exits: ProgressiveList[SszBuilderExitRequest] + deposits: ProgressiveList[SSZDepositRequest] + withdrawals: ProgressiveList[SSZWithdrawalRequest] + consolidations: ProgressiveList[SSZConsolidationRequest] + builder_deposits: ProgressiveList[SSZBuilderDepositRequest] + builder_exits: ProgressiveList[SSZBuilderExitRequest] -class SszNewPayloadRequest(Container): +class SSZNewPayloadRequest(Container): """SSZ container mirroring ``NewPayloadRequest``.""" - execution_payload: SszExecutionPayload + execution_payload: SSZExecutionPayload versioned_hashes: ProgressiveList[Bytes32] parent_beacon_block_root: Bytes32 - execution_requests: SszExecutionRequests + execution_requests: SSZExecutionRequests -class SszExecutionWitness(Container): +class SSZExecutionWitness(Container): """SSZ container mirroring ``ExecutionWitness``.""" state: ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]] codes: ProgressiveList[ByteList[MAX_BYTES_PER_CODE]] - headers: SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] + headers: SSZList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] -class SszStatelessInput(Container): +class SSZStatelessInput(Container): """SSZ container mirroring ``StatelessInput``.""" - new_payload_request: SszNewPayloadRequest - witness: SszExecutionWitness + new_payload_request: SSZNewPayloadRequest + witness: SSZExecutionWitness chain_id: uint64 public_keys: ProgressiveList[ByteVector[PUBLIC_KEY_BYTES]] -class SszStatelessValidationResult(Container): +class SSZStatelessValidationResult(Container): """SSZ container mirroring ``StatelessValidationResult``.""" new_payload_request_root: Bytes32 @@ -202,9 +202,9 @@ class SszStatelessValidationResult(Container): # --- Conversion helpers --- -def _withdrawal_to_ssz(w: Withdrawal) -> SszWithdrawal: +def _withdrawal_to_ssz(w: Withdrawal) -> SSZWithdrawal: """Convert a Withdrawal to its SSZ form.""" - return SszWithdrawal( + return SSZWithdrawal( index=uint64(int(w.index)), validator_index=uint64(int(w.validator_index)), address=ByteVector[20](bytes(w.address)), @@ -212,7 +212,7 @@ def _withdrawal_to_ssz(w: Withdrawal) -> SszWithdrawal: ) -def _ssz_to_withdrawal(sw: SszWithdrawal) -> Withdrawal: +def _ssz_to_withdrawal(sw: SSZWithdrawal) -> Withdrawal: """Convert an SSZ withdrawal back to a Withdrawal.""" return Withdrawal( index=U64(sw.index), @@ -224,9 +224,9 @@ def _ssz_to_withdrawal(sw: SszWithdrawal) -> Withdrawal: def _payload_to_ssz( p: ExecutionPayload, -) -> SszExecutionPayload: +) -> SSZExecutionPayload: """Convert an ExecutionPayload to its SSZ form.""" - return SszExecutionPayload( + return SSZExecutionPayload( parent_hash=Bytes32(bytes(p.parent_hash)), fee_recipient=ByteVector[20](bytes(p.fee_recipient)), state_root=Bytes32(bytes(p.state_root)), @@ -243,7 +243,7 @@ def _payload_to_ssz( transactions=ProgressiveList[ProgressiveByteList]( ProgressiveByteList(bytes(tx)) for tx in p.transactions ), - withdrawals=ProgressiveList[SszWithdrawal]( + withdrawals=ProgressiveList[SSZWithdrawal]( _withdrawal_to_ssz(w) for w in p.withdrawals ), blob_gas_used=uint64(int(p.blob_gas_used)), @@ -254,7 +254,7 @@ def _payload_to_ssz( def _ssz_to_payload( - sp: SszExecutionPayload, + sp: SSZExecutionPayload, ) -> ExecutionPayload: """Convert an SSZ execution payload back to ExecutionPayload.""" return ExecutionPayload( @@ -280,9 +280,9 @@ def _ssz_to_payload( ) -def _deposit_request_to_ssz(d: DepositRequest) -> SszDepositRequest: +def _deposit_request_to_ssz(d: DepositRequest) -> SSZDepositRequest: """Convert a DepositRequest to its SSZ form.""" - return SszDepositRequest( + return SSZDepositRequest( pubkey=ByteVector[48](bytes(d.pubkey)), withdrawal_credentials=Bytes32(bytes(d.withdrawal_credentials)), amount=uint64(int(d.amount)), @@ -291,7 +291,7 @@ def _deposit_request_to_ssz(d: DepositRequest) -> SszDepositRequest: ) -def _ssz_to_deposit_request(sd: SszDepositRequest) -> DepositRequest: +def _ssz_to_deposit_request(sd: SSZDepositRequest) -> DepositRequest: """Convert an SSZ deposit request back.""" return DepositRequest( pubkey=Bytes48(bytes(sd.pubkey)), @@ -304,9 +304,9 @@ def _ssz_to_deposit_request(sd: SszDepositRequest) -> DepositRequest: def _withdrawal_request_to_ssz( w: WithdrawalRequest, -) -> SszWithdrawalRequest: +) -> SSZWithdrawalRequest: """Convert a WithdrawalRequest to its SSZ form.""" - return SszWithdrawalRequest( + return SSZWithdrawalRequest( source_address=ByteVector[20](bytes(w.source_address)), validator_pubkey=ByteVector[48](bytes(w.validator_pubkey)), amount=uint64(int(w.amount)), @@ -314,7 +314,7 @@ def _withdrawal_request_to_ssz( def _ssz_to_withdrawal_request( - sw: SszWithdrawalRequest, + sw: SSZWithdrawalRequest, ) -> WithdrawalRequest: """Convert an SSZ withdrawal request back.""" return WithdrawalRequest( @@ -326,9 +326,9 @@ def _ssz_to_withdrawal_request( def _consolidation_request_to_ssz( c: ConsolidationRequest, -) -> SszConsolidationRequest: +) -> SSZConsolidationRequest: """Convert a ConsolidationRequest to its SSZ form.""" - return SszConsolidationRequest( + return SSZConsolidationRequest( source_address=ByteVector[20](bytes(c.source_address)), source_pubkey=ByteVector[48](bytes(c.source_pubkey)), target_pubkey=ByteVector[48](bytes(c.target_pubkey)), @@ -336,7 +336,7 @@ def _consolidation_request_to_ssz( def _ssz_to_consolidation_request( - sc: SszConsolidationRequest, + sc: SSZConsolidationRequest, ) -> ConsolidationRequest: """Convert an SSZ consolidation request back.""" return ConsolidationRequest( @@ -348,9 +348,9 @@ def _ssz_to_consolidation_request( def _builder_deposit_request_to_ssz( b: BuilderDepositRequest, -) -> SszBuilderDepositRequest: +) -> SSZBuilderDepositRequest: """Convert a BuilderDepositRequest to its SSZ form.""" - return SszBuilderDepositRequest( + return SSZBuilderDepositRequest( pubkey=ByteVector[48](bytes(b.pubkey)), withdrawal_credentials=Bytes32(bytes(b.withdrawal_credentials)), amount=uint64(int(b.amount)), @@ -359,7 +359,7 @@ def _builder_deposit_request_to_ssz( def _ssz_to_builder_deposit_request( - sb: SszBuilderDepositRequest, + sb: SSZBuilderDepositRequest, ) -> BuilderDepositRequest: """Convert an SSZ builder deposit request back.""" return BuilderDepositRequest( @@ -372,16 +372,16 @@ def _ssz_to_builder_deposit_request( def _builder_exit_request_to_ssz( b: BuilderExitRequest, -) -> SszBuilderExitRequest: +) -> SSZBuilderExitRequest: """Convert a BuilderExitRequest to its SSZ form.""" - return SszBuilderExitRequest( + return SSZBuilderExitRequest( source_address=ByteVector[20](bytes(b.source_address)), pubkey=ByteVector[48](bytes(b.pubkey)), ) def _ssz_to_builder_exit_request( - sb: SszBuilderExitRequest, + sb: SSZBuilderExitRequest, ) -> BuilderExitRequest: """Convert an SSZ builder exit request back.""" return BuilderExitRequest( @@ -392,29 +392,29 @@ def _ssz_to_builder_exit_request( def _execution_requests_to_ssz( er: ExecutionRequests, -) -> SszExecutionRequests: +) -> SSZExecutionRequests: """Convert an ExecutionRequests to its SSZ form.""" - return SszExecutionRequests( - deposits=ProgressiveList[SszDepositRequest]( + return SSZExecutionRequests( + deposits=ProgressiveList[SSZDepositRequest]( _deposit_request_to_ssz(d) for d in er.deposits ), - withdrawals=ProgressiveList[SszWithdrawalRequest]( + withdrawals=ProgressiveList[SSZWithdrawalRequest]( _withdrawal_request_to_ssz(w) for w in er.withdrawals ), - consolidations=ProgressiveList[SszConsolidationRequest]( + consolidations=ProgressiveList[SSZConsolidationRequest]( _consolidation_request_to_ssz(c) for c in er.consolidations ), - builder_deposits=ProgressiveList[SszBuilderDepositRequest]( + builder_deposits=ProgressiveList[SSZBuilderDepositRequest]( _builder_deposit_request_to_ssz(b) for b in er.builder_deposits ), - builder_exits=ProgressiveList[SszBuilderExitRequest]( + builder_exits=ProgressiveList[SSZBuilderExitRequest]( _builder_exit_request_to_ssz(b) for b in er.builder_exits ), ) def _ssz_to_execution_requests( - ser: SszExecutionRequests, + ser: SSZExecutionRequests, ) -> ExecutionRequests: """Convert an SSZ execution requests back.""" return ExecutionRequests( @@ -436,9 +436,9 @@ def _ssz_to_execution_requests( def _new_payload_request_to_ssz( npr: NewPayloadRequest, -) -> SszNewPayloadRequest: +) -> SSZNewPayloadRequest: """Convert a NewPayloadRequest to its SSZ form.""" - return SszNewPayloadRequest( + return SSZNewPayloadRequest( execution_payload=_payload_to_ssz(npr.execution_payload), versioned_hashes=ProgressiveList[Bytes32]( Bytes32(bytes(vh)) for vh in npr.versioned_hashes @@ -449,7 +449,7 @@ def _new_payload_request_to_ssz( def _ssz_to_new_payload_request( - snpr: SszNewPayloadRequest, + snpr: SSZNewPayloadRequest, ) -> NewPayloadRequest: """Convert an SSZ new payload request back.""" return NewPayloadRequest( @@ -464,23 +464,23 @@ def _ssz_to_new_payload_request( def _witness_to_ssz( w: ExecutionWitness, -) -> SszExecutionWitness: +) -> SSZExecutionWitness: """Convert an ExecutionWitness to its SSZ form.""" - return SszExecutionWitness( + return SSZExecutionWitness( state=ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]]( ByteList[MAX_BYTES_PER_WITNESS_NODE](bytes(s)) for s in w.state ), codes=ProgressiveList[ByteList[MAX_BYTES_PER_CODE]]( ByteList[MAX_BYTES_PER_CODE](bytes(c)) for c in w.codes ), - headers=SszList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS]( + headers=SSZList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS]( ByteList[MAX_BYTES_PER_HEADER](bytes(h)) for h in w.headers ), ) def _ssz_to_witness( - sw: SszExecutionWitness, + sw: SSZExecutionWitness, ) -> ExecutionWitness: """Convert an SSZ execution witness back.""" return ExecutionWitness( @@ -492,7 +492,7 @@ def _ssz_to_witness( def stateless_input_to_ssz( si: StatelessInput, -) -> SszStatelessInput: +) -> SSZStatelessInput: """Convert a StatelessInput to its SSZ form.""" for public_key in si.public_keys: if len(public_key) != PUBLIC_KEY_BYTES: @@ -500,7 +500,7 @@ def stateless_input_to_ssz( f"Transaction public key must be {PUBLIC_KEY_BYTES} bytes" ) - return SszStatelessInput( + return SSZStatelessInput( new_payload_request=_new_payload_request_to_ssz( si.new_payload_request ), @@ -513,7 +513,7 @@ def stateless_input_to_ssz( def ssz_to_stateless_input( - ssz_si: SszStatelessInput, + ssz_si: SSZStatelessInput, ) -> StatelessInput: """Convert an SSZ stateless input back.""" return StatelessInput( @@ -528,9 +528,9 @@ def ssz_to_stateless_input( def validation_result_to_ssz( vr: StatelessValidationResult, -) -> SszStatelessValidationResult: +) -> SSZStatelessValidationResult: """Convert a StatelessValidationResult to its SSZ form.""" - return SszStatelessValidationResult( + return SSZStatelessValidationResult( new_payload_request_root=Bytes32(bytes(vr.new_payload_request_root)), successful_validation=boolean(vr.successful_validation), chain_id=uint64(int(vr.chain_id)), @@ -539,7 +539,7 @@ def validation_result_to_ssz( def ssz_to_validation_result( - ssz_vr: SszStatelessValidationResult, + ssz_vr: SSZStatelessValidationResult, ) -> StatelessValidationResult: """Convert an SSZ validation result back.""" return StatelessValidationResult( diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py index 550aaa6f271..842a1a2cc30 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -65,7 +65,7 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: The stateless input starts with a 2-byte schema id, followed by the encoded payload selected by that schema. For Amsterdam schema 0x1501, - the payload is an SSZ-encoded ``SszStatelessInput`` container. Its + the payload is an SSZ-encoded ``SSZStatelessInput`` container. Its first four SSZ bytes encode the offset to the first variable-size field. Setting that offset to 1 makes it point inside the fixed-size section, so the SSZ decoder must reject the input before stateless validation From ca2ccf49f41f2539efb9c859c173fabe32f46afa Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 17 Aug 2026 17:01:02 -0300 Subject: [PATCH 259/265] automate zkevm benchmark filling releases --- .../actions/build-evm-client/geth/action.yaml | 13 +- .github/actions/build-fixtures/action.yaml | 6 + .github/configs/evm.yaml | 6 + .github/configs/feature.yaml | 5 + .../scripts/check_zkevm_benchmark_release.py | 98 ++++++ .github/scripts/resolve_git_ref.py | 68 ++++ .github/scripts/tests/test_release_scripts.py | 298 +++++++++++++++++- .../validate_zkevm_benchmark_fixtures.py | 151 +++++++++ .github/workflows/release_fixtures.yaml | 12 +- docs/dev/releasing_tests.md | 23 +- docs/running_tests/releases.md | 11 +- 11 files changed, 679 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/check_zkevm_benchmark_release.py create mode 100644 .github/scripts/resolve_git_ref.py create mode 100644 .github/scripts/validate_zkevm_benchmark_fixtures.py diff --git a/.github/actions/build-evm-client/geth/action.yaml b/.github/actions/build-evm-client/geth/action.yaml index 7770f0192c1..0f8d832a631 100644 --- a/.github/actions/build-evm-client/geth/action.yaml +++ b/.github/actions/build-evm-client/geth/action.yaml @@ -6,7 +6,7 @@ inputs: required: true default: 'ethereum/go-ethereum' ref: - description: 'Reference to branch, commit, or tag to use to build the EVM binary' + description: 'Branch or full commit SHA to use to build the EVM binary' required: true default: 'master' golang: @@ -16,13 +16,16 @@ inputs: runs: using: "composite" steps: - - name: Get latest geth commit + - name: Resolve geth commit id: geth-sha shell: bash + env: + GETH_REPOSITORY: ${{ inputs.repo }} + GETH_REF: ${{ inputs.ref }} run: | - SHA=$(git ls-remote https://github.com/${{ inputs.repo }}.git refs/heads/${{ inputs.ref }} | cut -f1) - echo "sha=$SHA" >> $GITHUB_OUTPUT - echo "Latest geth commit: $SHA" + SHA=$(python3 .github/scripts/resolve_git_ref.py "$GETH_REPOSITORY" "$GETH_REF") + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Resolved geth commit: $SHA" - name: Prepare cache target dir shell: bash run: mkdir -p "$GITHUB_WORKSPACE/go-ethereum/cmd/evm" diff --git a/.github/actions/build-fixtures/action.yaml b/.github/actions/build-fixtures/action.yaml index 61d255c22ab..0b08e634f85 100644 --- a/.github/actions/build-fixtures/action.yaml +++ b/.github/actions/build-fixtures/action.yaml @@ -76,6 +76,12 @@ runs: if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 5 ]; then exit "$EXIT_CODE" fi + - name: Validate zkEVM Benchmark Fixtures + if: inputs.split_label == '' && inputs.release_name == 'zkevm-benchmark' + shell: bash + run: | + uv run -q .github/scripts/validate_zkevm_benchmark_fixtures.py \ + fixtures_${{ inputs.release_name }}.tar.gz - name: Generate Benchmark Genesis Files if: inputs.split_label == '' && contains(inputs.release_name, 'benchmark') uses: ./.github/actions/build-benchmark-genesis diff --git a/.github/configs/evm.yaml b/.github/configs/evm.yaml index 981615796f2..51afcb8e654 100644 --- a/.github/configs/evm.yaml +++ b/.github/configs/evm.yaml @@ -4,6 +4,12 @@ benchmark: ref: glamsterdam-devnet-7 evm-bin: evm xdist: auto +zkevm-benchmark: + impl: geth + repo: jsign/go-ethereum + ref: df17b59f9dfd731f0b7da0a85c30548f3db45b0b + evm-bin: evm + xdist: auto eels: impl: eels repo: null diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 76064296fc8..78f71254908 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -22,6 +22,11 @@ benchmark_fast: fill-params: --fork=Amsterdam --generate-all-formats --gas-benchmark-values 100 ./tests/benchmark/compute feature_only: true +zkevm-benchmark: + evm-type: zkevm-benchmark + fill-params: --fork=Amsterdam --gas-benchmark-values=10,30,60 -m blockchain_test ./tests/benchmark/compute --maxprocesses=30 --dist=worksteal + feature_only: true + # Shared entry for all `-devnet` releases; matched by `-devnet` suffix. devnet: evm-type: eels diff --git a/.github/scripts/check_zkevm_benchmark_release.py b/.github/scripts/check_zkevm_benchmark_release.py new file mode 100644 index 00000000000..2ed272e8624 --- /dev/null +++ b/.github/scripts/check_zkevm_benchmark_release.py @@ -0,0 +1,98 @@ +#!/usr/bin/env -S uv run --script +"""Check a zkEVM benchmark release request against GitHub state.""" + +import json +import os +import subprocess +import sys +from typing import Any, NoReturn + + +def fail(message: str) -> NoReturn: + """Print an error and stop the request.""" + print(f"Error: {message}", file=sys.stderr) + sys.exit(1) + + +def github_api(path: str) -> Any: + """Return all pages from a GitHub API request.""" + result = subprocess.run( + ["gh", "api", "--paginate", "--slurp", path], + capture_output=True, + text=True, + ) + if result.returncode != 0: + fail(f"gh api failed for {path}: {result.stderr.strip()}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + fail(f"gh api returned invalid JSON for {path}: {error}") + + +def flatten_pages(response: Any) -> list[dict[str, Any]]: + """Flatten the page list returned by ``gh api --slurp``.""" + if not isinstance(response, list): + fail("gh api returned an invalid paginated response") + items: list[dict[str, Any]] = [] + for page in response: + if not isinstance(page, list): + fail("gh api returned an invalid page") + for item in page: + if not isinstance(item, dict): + fail("gh api returned an invalid item") + items.append(item) + return items + + +def matching_refs(repository: str, tag: str) -> list[dict[str, Any]]: + """Return refs that match *tag*.""" + path = f"repos/{repository}/git/matching-refs/tags/{tag}" + return flatten_pages(github_api(path)) + + +def check_release(version: str, source_ref: str) -> None: + """Check the source input and destination release state.""" + repository = os.environ.get("GITHUB_REPOSITORY", "") + if not repository: + fail("GITHUB_REPOSITORY is empty") + + source_tag = f"tests-zkevm@{version}" + destination_tag = f"tests-zkevm-benchmark@{version}" + if source_ref != source_tag: + fail( + f"source ref must be '{source_tag}' for version '{version}', " + f"got '{source_ref}'" + ) + + destination_refs = matching_refs(repository, destination_tag) + if any( + ref.get("ref") == f"refs/tags/{destination_tag}" + for ref in destination_refs + ): + fail(f"destination tag '{destination_tag}' already exists") + + releases = flatten_pages( + github_api(f"repos/{repository}/releases?per_page=100") + ) + if any(release.get("tag_name") == destination_tag for release in releases): + fail( + f"destination release or draft '{destination_tag}' already exists" + ) + + print(f"Source tag: {source_tag}") + print(f"Destination release: {destination_tag}") + + +def main() -> None: + """Check the command-line release request.""" + if len(sys.argv) != 3: + print( + "Usage: check_zkevm_benchmark_release.py ", + file=sys.stderr, + ) + sys.exit(1) + check_release(sys.argv[1], sys.argv[2]) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/resolve_git_ref.py b/.github/scripts/resolve_git_ref.py new file mode 100644 index 00000000000..04d0f7e851f --- /dev/null +++ b/.github/scripts/resolve_git_ref.py @@ -0,0 +1,68 @@ +#!/usr/bin/env -S uv run --script +"""Resolve a GitHub repository branch or full commit SHA.""" + +import re +import subprocess +import sys + +FULL_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def resolve_git_ref(repository: str, ref: str) -> str: + """Return the commit SHA for a branch name or full commit SHA.""" + if not repository: + raise ValueError("repository is empty") + if not ref: + raise ValueError("ref is empty") + if FULL_COMMIT_RE.fullmatch(ref): + return ref.lower() + + branch_ref = ref if ref.startswith("refs/heads/") else f"refs/heads/{ref}" + result = subprocess.run( + [ + "git", + "ls-remote", + "--exit-code", + f"https://github.com/{repository}.git", + branch_ref, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise ValueError( + f"could not resolve branch '{ref}' in repository '{repository}'" + ) + + lines = [line for line in result.stdout.splitlines() if line.strip()] + if len(lines) != 1: + raise ValueError( + f"branch '{ref}' in repository '{repository}' resolved to " + f"{len(lines)} commits" + ) + sha = lines[0].split(maxsplit=1)[0] + if not FULL_COMMIT_RE.fullmatch(sha): + raise ValueError( + f"branch '{ref}' in repository '{repository}' returned an " + "invalid commit SHA" + ) + return sha.lower() + + +def main() -> None: + """Resolve the command-line repository and ref.""" + if len(sys.argv) != 3: + print( + "Usage: resolve_git_ref.py ", + file=sys.stderr, + ) + sys.exit(1) + try: + print(resolve_git_ref(sys.argv[1], sys.argv[2])) + except ValueError as error: + print(f"Error: {error}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/tests/test_release_scripts.py b/.github/scripts/tests/test_release_scripts.py index 44e6dbcab77..fe625729314 100644 --- a/.github/scripts/tests/test_release_scripts.py +++ b/.github/scripts/tests/test_release_scripts.py @@ -5,6 +5,7 @@ interface, matching how GitHub Actions calls them. """ +import io import json import os import subprocess @@ -20,15 +21,23 @@ MERGE_INDEX_SCRIPT = SCRIPTS_DIR / "merge_index_files.py" CHECK_COMMITS_SCRIPT = SCRIPTS_DIR / "check_new_commits.py" RESOLVE_CACHED_SCRIPT = SCRIPTS_DIR / "resolve_cached_release.py" +CHECK_ZKEVM_RELEASE_SCRIPT = SCRIPTS_DIR / "check_zkevm_benchmark_release.py" +RESOLVE_GIT_REF_SCRIPT = SCRIPTS_DIR / "resolve_git_ref.py" +VALIDATE_ZKEVM_FIXTURES_SCRIPT = ( + SCRIPTS_DIR / "validate_zkevm_benchmark_fixtures.py" +) -def run_script(script: Path, *args: str) -> subprocess.CompletedProcess: +def run_script( + script: Path, *args: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess: """Run a uv inline-deps script and return the result.""" return subprocess.run( ["uv", "run", "-q", str(script), *args], capture_output=True, text=True, cwd=REPO_ROOT, + env=env, ) @@ -72,6 +81,25 @@ def test_unsplit_feature_produces_single_entry(self): assert matrix[0]["from_fork"] == "" assert matrix[0]["until_fork"] == "" + def test_zkevm_benchmark_produces_single_entry(self): + """Verify the zkEVM benchmark feature is an unsplit build.""" + result = run_script( + BUILD_MATRIX_SCRIPT, + "zkevm-benchmark", + "v0.9.0", + ) + assert result.returncode == 0 + out = parse_matrix_output(result.stdout) + matrix = json.loads(out["build_matrix"]) + assert matrix == [ + { + "feature": "zkevm-benchmark", + "label": "", + "from_fork": "", + "until_fork": "", + } + ] + def test_devnet_name_resolves_to_shared_feature(self): """Verify a -devnet name resolves to the devnet feature.""" result = run_script( @@ -200,6 +228,9 @@ def test_known_evm_passes(self): path="${@: -1}" case "$path" in *actions/workflows*) response="$FAKE_GH_RUNS" ;; + *matching-refs/tags/tests-zkevm-benchmark*) + response="${FAKE_GH_DESTINATION_TAGS:-$FAKE_GH_TAGS}" + ;; */artifacts) run_id="${path##*/runs/}" run_id="${run_id%%/*}" @@ -207,6 +238,7 @@ def test_known_evm_passes(self): response="${!var:-$FAKE_GH_ARTIFACTS}" ;; *matching-refs*) response="$FAKE_GH_TAGS" ;; + *releases*) response="$FAKE_GH_RELEASES" ;; *compare/tests@*) response="$FAKE_GH_COMPARE_TAG" ;; *compare*) response="$FAKE_GH_COMPARE" ;; *) response="" ;; @@ -224,6 +256,270 @@ def test_known_evm_passes(self): NO_ARTIFACTS = '{"artifacts": []}' +class TestResolveGitRef: + """Test resolve_git_ref.py.""" + + def run_with_fake_git( + self, tmp_path: Path, ref: str, output: str, exit_code: int + ) -> subprocess.CompletedProcess: + """Run the resolver with a fake git command.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_git = bin_dir / "git" + fake_git.write_text( + f"#!/usr/bin/env bash\nprintf '%s' '{output}'\nexit {exit_code}\n" + ) + fake_git.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env['PATH']}" + return run_script( + RESOLVE_GIT_REF_SCRIPT, + "example/geth", + ref, + env=env, + ) + + def test_full_commit_does_not_query_remote(self): + """Verify a full commit SHA resolves without a git command.""" + commit = "a" * 40 + result = run_script(RESOLVE_GIT_REF_SCRIPT, "example/geth", commit) + assert result.returncode == 0 + assert result.stdout.strip() == commit + + def test_branch_resolves_to_full_commit(self, tmp_path): + """Verify a branch uses the commit returned by git ls-remote.""" + commit = "b" * 40 + result = self.run_with_fake_git( + tmp_path, + "devnet", + f"{commit}\trefs/heads/devnet\n", + 0, + ) + assert result.returncode == 0 + assert result.stdout.strip() == commit + + def test_unresolved_branch_fails(self, tmp_path): + """Verify an unresolved branch stops the build.""" + result = self.run_with_fake_git(tmp_path, "missing", "", 2) + assert result.returncode == 1 + assert "could not resolve branch 'missing'" in result.stderr + + def test_empty_ref_fails(self): + """Verify an empty ref stops the build.""" + result = run_script(RESOLVE_GIT_REF_SCRIPT, "example/geth", "") + assert result.returncode == 1 + assert "ref is empty" in result.stderr + + +class TestCheckZkevmBenchmarkRelease: + """Test check_zkevm_benchmark_release.py.""" + + @staticmethod + def run_check( + tmp_path: Path, + destination_tags: str = "[[]]", + releases: str = "[[]]", + version: str = "v0.9.0", + source_ref: str = "tests-zkevm@v0.9.0", + ) -> subprocess.CompletedProcess: + """Run the release check with canned GitHub API responses.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_gh = bin_dir / "gh" + fake_gh.write_text(FAKE_GH) + fake_gh.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env['PATH']}" + env["GITHUB_REPOSITORY"] = "ethereum/execution-specs" + env["FAKE_GH_DESTINATION_TAGS"] = destination_tags + env["FAKE_GH_RELEASES"] = releases + return run_script( + CHECK_ZKEVM_RELEASE_SCRIPT, + version, + source_ref, + env=env, + ) + + def test_new_release_request_passes(self, tmp_path): + """Verify a matching source input and unused destination pass.""" + result = self.run_check(tmp_path) + assert result.returncode == 0 + assert "Destination release: tests-zkevm-benchmark@v0.9.0" in ( + result.stdout + ) + + def test_existing_destination_tag_fails(self, tmp_path): + """Verify an existing destination tag stops the request.""" + destination_tags = json.dumps( + [[{"ref": "refs/tags/tests-zkevm-benchmark@v0.9.0"}]] + ) + result = self.run_check(tmp_path, destination_tags=destination_tags) + assert result.returncode == 1 + assert "destination tag" in result.stderr + + def test_existing_destination_draft_fails(self, tmp_path): + """Verify an existing destination draft stops the request.""" + releases = json.dumps( + [[{"tag_name": "tests-zkevm-benchmark@v0.9.0", "draft": True}]] + ) + result = self.run_check(tmp_path, releases=releases) + assert result.returncode == 1 + assert "release or draft" in result.stderr + + def test_mismatched_source_ref_fails_without_api_call(self, tmp_path): + """Verify the source ref and release version must match.""" + result = self.run_check( + tmp_path, + source_ref="tests-zkevm@v0.8.0", + ) + assert result.returncode == 1 + assert "source ref must be 'tests-zkevm@v0.9.0'" in result.stderr + + +class TestValidateZkevmBenchmarkFixtures: + """Test validate_zkevm_benchmark_fixtures.py.""" + + fixture_path = ( + "fixtures/blockchain_tests/for_amsterdam_at_0060M/" + "benchmark/compute/test_example.json" + ) + + @staticmethod + def valid_fixture() -> dict: + """Return one valid zkEVM benchmark fixture file.""" + return { + "test_example": { + "network": "Amsterdam", + "blocks": [ + { + "statelessInputBytes": "0x1234", + "statelessOutputBytes": "0xabcd", + } + ], + "_info": { + "metadata": { + "opcode_count_per_block": [{"ADD": 1}], + } + }, + } + } + + @staticmethod + def make_archive(tmp_path: Path, files: dict[str, dict]) -> Path: + """Create a fixture archive from JSON file mappings.""" + archive_path = tmp_path / "fixtures_zkevm-benchmark.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + for name, contents in files.items(): + data = json.dumps(contents).encode() + member = tarfile.TarInfo(name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + return archive_path + + def run_validator( + self, tmp_path: Path, files: dict[str, dict] + ) -> subprocess.CompletedProcess: + """Create and validate one fixture archive.""" + archive_path = self.make_archive(tmp_path, files) + return run_script(VALIDATE_ZKEVM_FIXTURES_SCRIPT, str(archive_path)) + + def test_valid_archive_passes(self, tmp_path): + """Verify an archive with all configured gas limits passes.""" + files = { + self.fixture_path.replace("0060M", gas_limit): self.valid_fixture() + for gas_limit in ("0010M", "0030M", "0060M") + } + result = self.run_validator(tmp_path, files) + assert result.returncode == 0 + assert "Validated 3 fixture cases in 3 fixture files" in result.stdout + + def test_empty_archive_fails(self, tmp_path): + """Verify an empty archive fails.""" + result = self.run_validator(tmp_path, {}) + assert result.returncode == 1 + assert "contains no zkEVM benchmark fixtures" in result.stderr + + def test_wrong_target_directory_fails(self, tmp_path): + """Verify fixtures must use a configured gas limit.""" + path = self.fixture_path.replace("0060M", "0050M") + result = self.run_validator(tmp_path, {path: self.valid_fixture()}) + assert result.returncode == 1 + assert "target must be one of" in result.stderr + + def test_wrong_fork_directory_fails(self, tmp_path): + """Verify fixtures cannot target another fork.""" + path = self.fixture_path.replace("amsterdam", "prague") + result = self.run_validator(tmp_path, {path: self.valid_fixture()}) + assert result.returncode == 1 + assert "target must be one of" in result.stderr + + def test_extra_fixture_format_fails(self, tmp_path): + """Verify the archive cannot contain another fixture format.""" + engine_path = ( + "fixtures/blockchain_tests_engine/for_amsterdam_at_0060M/" + "benchmark/compute/test_example.json" + ) + result = self.run_validator( + tmp_path, + { + self.fixture_path: self.valid_fixture(), + engine_path: self.valid_fixture(), + }, + ) + assert result.returncode == 1 + assert "unexpected fixture formats" in result.stderr + + def test_missing_stateless_input_fails(self, tmp_path): + """Verify the final block must contain stateless input bytes.""" + fixture = self.valid_fixture() + del fixture["test_example"]["blocks"][-1]["statelessInputBytes"] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "statelessInputBytes must be" in result.stderr + + def test_malformed_stateless_output_fails(self, tmp_path): + """Verify the final block output must contain complete bytes.""" + fixture = self.valid_fixture() + fixture["test_example"]["blocks"][-1]["statelessOutputBytes"] = "0x1" + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "statelessOutputBytes must be" in result.stderr + + def test_empty_blocks_fails(self, tmp_path): + """Verify each fixture case must contain a block.""" + fixture = self.valid_fixture() + fixture["test_example"]["blocks"] = [] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "blocks must be a non-empty list" in result.stderr + + def test_opcode_count_length_must_match_blocks(self, tmp_path): + """Verify opcode count metadata has one entry for each block.""" + fixture = self.valid_fixture() + metadata = fixture["test_example"]["_info"]["metadata"] + metadata["opcode_count_per_block"] = [] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "has 0 entries for 1 blocks" in result.stderr + + def test_opcode_count_metadata_is_required(self, tmp_path): + """Verify each fixture case contains opcode count metadata.""" + fixture = self.valid_fixture() + del fixture["test_example"]["_info"]["metadata"] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "opcode_count_per_block must be a list" in result.stderr + + def test_final_opcode_count_must_not_be_empty(self, tmp_path): + """Verify the final opcode count contains at least one opcode.""" + fixture = self.valid_fixture() + metadata = fixture["test_example"]["_info"]["metadata"] + metadata["opcode_count_per_block"] = [{}] + result = self.run_validator(tmp_path, {self.fixture_path: fixture}) + assert result.returncode == 1 + assert "final opcode count must be a non-empty object" in result.stderr + + class TestCheckNewCommits: """Test check_new_commits.py.""" diff --git a/.github/scripts/validate_zkevm_benchmark_fixtures.py b/.github/scripts/validate_zkevm_benchmark_fixtures.py new file mode 100644 index 00000000000..2ecdaba7c96 --- /dev/null +++ b/.github/scripts/validate_zkevm_benchmark_fixtures.py @@ -0,0 +1,151 @@ +#!/usr/bin/env -S uv run --script +"""Validate a zkEVM benchmark fixture archive before release.""" + +import json +import re +import sys +import tarfile +from pathlib import Path, PurePosixPath +from typing import Any, NoReturn + +EXPECTED_FORMAT = "blockchain_tests" +EXPECTED_TARGETS = { + "for_amsterdam_at_0010M", + "for_amsterdam_at_0030M", + "for_amsterdam_at_0060M", +} +FIXTURE_FORMATS = { + "state_tests", + "blockchain_tests", + "blockchain_tests_engine", + "blockchain_tests_engine_x", + "blockchain_tests_sync", +} +HEX_BYTES_RE = re.compile(r"^0x(?:[0-9a-fA-F]{2})+$") + + +def fail(message: str) -> NoReturn: + """Raise a release validation error.""" + raise ValueError(message) + + +def validate_hex_bytes(value: Any, field: str) -> None: + """Validate one non-empty hexadecimal byte string.""" + if not isinstance(value, str) or not HEX_BYTES_RE.fullmatch(value): + fail(f"{field} must be a non-empty, even-length 0x byte string") + + +def validate_case(case_name: str, fixture: Any) -> None: + """Validate one fixture case.""" + if not isinstance(fixture, dict): + fail(f"{case_name}: fixture must be an object") + if fixture.get("network") != "Amsterdam": + fail(f"{case_name}: network must be Amsterdam") + + blocks = fixture.get("blocks") + if not isinstance(blocks, list) or not blocks: + fail(f"{case_name}: blocks must be a non-empty list") + final_block = blocks[-1] + if not isinstance(final_block, dict): + fail(f"{case_name}: final block must be an object") + validate_hex_bytes( + final_block.get("statelessInputBytes"), + f"{case_name}: final block statelessInputBytes", + ) + validate_hex_bytes( + final_block.get("statelessOutputBytes"), + f"{case_name}: final block statelessOutputBytes", + ) + + info = fixture.get("_info") + metadata = info.get("metadata") if isinstance(info, dict) else None + opcode_counts = ( + metadata.get("opcode_count_per_block") + if isinstance(metadata, dict) + else None + ) + if not isinstance(opcode_counts, list): + fail(f"{case_name}: opcode_count_per_block must be a list") + if len(opcode_counts) != len(blocks): + fail( + f"{case_name}: opcode_count_per_block has {len(opcode_counts)} " + f"entries for {len(blocks)} blocks" + ) + if not isinstance(opcode_counts[-1], dict) or not opcode_counts[-1]: + fail(f"{case_name}: final opcode count must be a non-empty object") + + +def validate_archive(archive_path: Path) -> tuple[int, int]: + """Validate *archive_path* and return its file and fixture counts.""" + fixture_files = 0 + fixture_cases = 0 + seen_formats: set[str] = set() + + with tarfile.open(archive_path, mode="r:gz") as archive: + for member in archive: + path = PurePosixPath(member.name) + member_formats = FIXTURE_FORMATS.intersection(path.parts) + seen_formats.update(member_formats) + if not member.isfile() or path.suffix != ".json": + continue + if EXPECTED_FORMAT not in path.parts or ".meta" in path.parts: + continue + + format_index = path.parts.index(EXPECTED_FORMAT) + if len(path.parts) <= format_index + 1: + fail(f"{member.name}: missing benchmark target directory") + target = path.parts[format_index + 1] + if target not in EXPECTED_TARGETS: + fail( + f"{member.name}: target must be one of " + f"{', '.join(sorted(EXPECTED_TARGETS))}. Got {target}" + ) + + extracted = archive.extractfile(member) + if extracted is None: + fail(f"{member.name}: could not read fixture file") + try: + contents = json.load(extracted) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + fail(f"{member.name}: invalid JSON: {error}") + if not isinstance(contents, dict) or not contents: + fail(f"{member.name}: fixture file must be a non-empty object") + + fixture_files += 1 + for case_name, fixture in contents.items(): + validate_case(f"{member.name}:{case_name}", fixture) + fixture_cases += 1 + + unexpected_formats = seen_formats - {EXPECTED_FORMAT} + if unexpected_formats: + fail( + "archive contains unexpected fixture formats: " + + ", ".join(sorted(unexpected_formats)) + ) + if fixture_files == 0 or fixture_cases == 0: + fail("archive contains no zkEVM benchmark fixtures") + return fixture_files, fixture_cases + + +def main() -> None: + """Validate the command-line fixture archive.""" + if len(sys.argv) != 2: + print( + "Usage: validate_zkevm_benchmark_fixtures.py ", + file=sys.stderr, + ) + sys.exit(1) + archive_path = Path(sys.argv[1]) + try: + fixture_files, fixture_cases = validate_archive(archive_path) + except (OSError, tarfile.TarError, ValueError) as error: + print(f"Error: {error}", file=sys.stderr) + sys.exit(1) + print( + f"Validated {fixture_cases} fixture cases in " + f"{fixture_files} fixture files." + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/release_fixtures.yaml b/.github/workflows/release_fixtures.yaml index d56a9dae918..0607d27e4cb 100644 --- a/.github/workflows/release_fixtures.yaml +++ b/.github/workflows/release_fixtures.yaml @@ -27,7 +27,7 @@ on: required: true type: string branch: - description: "Branch to release from, e.g. devnets/bal/7 (required for *-devnet features)" + description: "Branch or source tag to release from, e.g. devnets/bal/7" required: false type: string evm: @@ -95,6 +95,16 @@ jobs: echo "short_sha=${sha:0:7}" >> "$GITHUB_OUTPUT" - uses: ./.github/actions/setup-uv + - name: Check zkEVM benchmark release request + if: inputs.feature == 'zkevm-benchmark' + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_SOURCE_REF: ${{ inputs.branch }} + run: | + uv run -q .github/scripts/check_zkevm_benchmark_release.py \ + "$INPUT_VERSION" "$INPUT_SOURCE_REF" + - name: Check for new commits (scheduled runs) id: check if: github.event_name == 'schedule' diff --git a/docs/dev/releasing_tests.md b/docs/dev/releasing_tests.md index ffbfe1cf504..ad5409718d5 100644 --- a/docs/dev/releasing_tests.md +++ b/docs/dev/releasing_tests.md @@ -12,9 +12,9 @@ gh workflow run release_fixtures.yaml -f feature= -f version=vX.Y.Z [-f | Input | Required | Description | | ---------- | ----------------- | ---------------------------------------------------------------------------------------------------- | -| `feature` | yes | Feature name, e.g. `tests`, `benchmark`, or a `-devnet` name. | +| `feature` | yes | Feature name, for example `tests`, `benchmark`, `zkevm-benchmark`, or a `-devnet` name. | | `version` | yes | Release version `vX.Y.Z` (validated against `^v[0-9]+\.[0-9]+\.[0-9]+$`). Tagged as `tests-@` (the `tests` feature tags as `tests@`). | -| `branch` | devnet only | Branch to build and release from. Optional for non-devnet features; **required** for devnet releases. | +| `branch` | feature-dependent | Branch or source tag to release from. Devnet and zkEVM benchmark releases require this input. | | `evm` | no | Override the evm impl (e.g. `geth`, `evmone`). Defaults to the feature's `evm-type` in `feature.yaml`. | | `evm_repo` | no | Override the t8n tool repo (e.g. `ethereum/go-ethereum`). | | `evm_ref` | no | Override the t8n tool branch / tag / commit. | @@ -39,6 +39,24 @@ Devnet releases must use a `-devnet` feature name (e.g. `feature=bal-devne gh workflow run release_fixtures.yaml -f feature=bal-devnet -f version=v7.0.0 -f branch=devnets/bal/7 ``` +## zkEVM benchmark releases + +Publish the source `tests-zkevm@vX.Y.Z` release before you make its benchmark release. The benchmark version must match the source version. + +```bash +gh workflow run release_fixtures.yaml \ + --ref 'tests-zkevm@vX.Y.Z' \ + -f feature=zkevm-benchmark \ + -f version=vX.Y.Z \ + -f branch='tests-zkevm@vX.Y.Z' +``` + +The workflow uses the Geth repository and commit in `evm.yaml` by default. A releaser can use the existing `evm`, `evm_repo`, and `evm_ref` inputs to override that configuration. + +The workflow fills Amsterdam compute benchmarks at 10M, 30M, and 60M gas. It produces only `blockchain_test` fixtures. + +Before upload, the workflow checks the stateless data in each fixture. It also checks the source version and the destination release. + ## What the workflow produces On success the workflow: @@ -51,6 +69,7 @@ On success the workflow: | ---------------- | ------- | ------------- | -------- | | `feature=tests version=v24.0.0` | `tests@v24.0.0` | `tests@v24.0.0` | `fixtures.tar.gz` | | `feature=bal-devnet version=v7.0.0 branch=devnets/bal/7` | `tests-bal-devnet@v7.0.0` | `tests-bal-devnet@v7.0.0` | `fixtures_bal-devnet.tar.gz` | +| `feature=zkevm-benchmark version=v0.9.0 branch=tests-zkevm@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `fixtures_zkevm-benchmark.tar.gz`, `benchmark_genesis.tar.gz` | The release is created as a draft; review and publish it from the GitHub releases page. diff --git a/docs/running_tests/releases.md b/docs/running_tests/releases.md index 9adb54ee2a0..23ea42ed30b 100644 --- a/docs/running_tests/releases.md +++ b/docs/running_tests/releases.md @@ -2,7 +2,8 @@ Test fixtures are published as feature-scoped releases on the [`ethereum/execution-specs`](https://github.com/ethereum/execution-specs/releases) -repository: `tests@vX.Y.Z`, `-devnet@vX.Y.Z`, and `benchmark@vX.Y.Z`. Each release is +repository: `tests@vX.Y.Z`, `-devnet@vX.Y.Z`, `benchmark@vX.Y.Z`, and +`zkevm-benchmark@vX.Y.Z`. Each release is a self-contained `.tar.gz` of JSON fixtures that execution clients consume in CI. This page describes the release types, their versioning, the fixture formats they contain, @@ -28,6 +29,7 @@ and cadence. | Tests | `tests@vX.Y.Z` | `fixtures.tar.gz` | All forks, all tests (eventually including `ethereum/tests` state tests) | latest `forks/*` branch | | Devnet | `-devnet@vX.Y.Z` | `fixtures_-devnet.tar.gz` | All forks, all tests, for an upcoming-fork feature under active devnet testing | the devnet branch | | Benchmark | `benchmark@vX.Y.Z` | `fixtures_benchmark.tar.gz` | EVM benchmarking tests | latest `forks/*` branch | +| zkEVM benchmark | `zkevm-benchmark@vX.Y.Z` | `fixtures_zkevm-benchmark.tar.gz` | Amsterdam compute benchmarks with stateless input and output bytes | matching `tests-zkevm@vX.Y.Z` tag | - "Tests" releases track clients' production branches and are tagged frequently (roughly once or twice a week). They are the "must pass" release for mainnet CI, and supersede the @@ -35,8 +37,8 @@ and cadence. - "Devnet" releases target a specific feature under active development (e.g. `bal-devnet`). They are advisory/non-blocking and may not yet cover every EIP; see the corresponding release notes for the coverage provided. -- "Benchmark" (and, in future, zkEVM) releases are produced separately for their - specialized consumers. +- "Benchmark" releases are produced separately for their specialized consumers. +- "zkEVM benchmark" releases contain 10M, 30M, and 60M `blockchain_test` fixtures. Their versions match their source `tests-zkevm` releases. ## Versioning Scheme @@ -64,6 +66,8 @@ spec change in your target, so read the release notes before adopting it. This also lets two devnets of the same feature be maintained in parallel (e.g. `v3.0.1` alongside `v7.0.0`) without ambiguity, the same way `2.x` and `3.x` coexist under semver. +A `zkevm-benchmark` version must match its source `tests-zkevm` version. A benchmark-only correction requires a new `tests-zkevm` source release. + ## Fixture Formats Fixture releases contain JSON test fixtures in various formats. Note that transaction type @@ -171,6 +175,7 @@ to release URLs and downloads them. For example: uv run consume cache --input=latest # shorthand for tests@latest uv run consume cache --input=tests@latest uv run consume cache --input=bal-devnet@v7.0.0 +uv run consume cache --input=zkevm-benchmark@v0.9.0 ``` Raw tarballs can also be fetched directly with the GitHub CLI: From 80c19ba8fc7ab7b3e7d70d4987cbb29ec8386385 Mon Sep 17 00:00:00 2001 From: jsign Date: Mon, 17 Aug 2026 20:19:11 -0300 Subject: [PATCH 260/265] refine zkevm benchmark handling in action.yaml and update releasing_tests.md --- .github/actions/build-fixtures/action.yaml | 4 ++-- docs/dev/releasing_tests.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/build-fixtures/action.yaml b/.github/actions/build-fixtures/action.yaml index 0b08e634f85..9b31aeade5a 100644 --- a/.github/actions/build-fixtures/action.yaml +++ b/.github/actions/build-fixtures/action.yaml @@ -83,12 +83,12 @@ runs: uv run -q .github/scripts/validate_zkevm_benchmark_fixtures.py \ fixtures_${{ inputs.release_name }}.tar.gz - name: Generate Benchmark Genesis Files - if: inputs.split_label == '' && contains(inputs.release_name, 'benchmark') + if: inputs.split_label == '' && inputs.release_name != 'zkevm-benchmark' && contains(inputs.release_name, 'benchmark') uses: ./.github/actions/build-benchmark-genesis with: fixtures_path: fixtures_${{ inputs.release_name }}.tar.gz - name: Upload Benchmark Genesis Artifact - if: inputs.split_label == '' && contains(inputs.release_name, 'benchmark') + if: inputs.split_label == '' && inputs.release_name != 'zkevm-benchmark' && contains(inputs.release_name, 'benchmark') uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: benchmark_genesis_${{ inputs.release_name }} diff --git a/docs/dev/releasing_tests.md b/docs/dev/releasing_tests.md index ad5409718d5..c1972d71c18 100644 --- a/docs/dev/releasing_tests.md +++ b/docs/dev/releasing_tests.md @@ -69,7 +69,7 @@ On success the workflow: | ---------------- | ------- | ------------- | -------- | | `feature=tests version=v24.0.0` | `tests@v24.0.0` | `tests@v24.0.0` | `fixtures.tar.gz` | | `feature=bal-devnet version=v7.0.0 branch=devnets/bal/7` | `tests-bal-devnet@v7.0.0` | `tests-bal-devnet@v7.0.0` | `fixtures_bal-devnet.tar.gz` | -| `feature=zkevm-benchmark version=v0.9.0 branch=tests-zkevm@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `fixtures_zkevm-benchmark.tar.gz`, `benchmark_genesis.tar.gz` | +| `feature=zkevm-benchmark version=v0.9.0 branch=tests-zkevm@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `tests-zkevm-benchmark@v0.9.0` | `fixtures_zkevm-benchmark.tar.gz` | The release is created as a draft; review and publish it from the GitHub releases page. From c120088355647754dfc164c880fb3213df13a9b2 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 26 Aug 2026 13:37:37 -0300 Subject: [PATCH 261/265] use annotated ssz to avoid type duplication --- .../plugins/filler/tests/test_filler.py | 6 +- .../specs/blockchain_stateless.py | 6 +- src/ethereum/forks/amsterdam/blocks.py | 3 +- .../amsterdam/execution_engine/requests.py | 35 +- .../forks/amsterdam/execution_engine/types.py | 38 +- src/ethereum/forks/amsterdam/stateless.py | 60 +- .../forks/amsterdam/stateless_guest.py | 17 +- .../forks/amsterdam/stateless_host.py | 13 +- src/ethereum/forks/amsterdam/stateless_ssz.py | 552 ------------------ src/ethereum/utils/ssz.py | 312 ++++++++++ .../test_stateless_input_bytes.py | 2 +- tests/json_loader/test_ssz.py | 86 +++ tests/json_loader/test_stateless_guest.py | 64 +- 13 files changed, 559 insertions(+), 635 deletions(-) delete mode 100644 src/ethereum/forks/amsterdam/stateless_ssz.py create mode 100644 src/ethereum/utils/ssz.py create mode 100644 tests/json_loader/test_ssz.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py index 6ec99a07d4a..ca8e379d4d5 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_filler.py @@ -1149,7 +1149,7 @@ def test_execution_witness_in_blockchain_fixture( # statelessInputBytes is schema-prefixed guest input bytes. sib = block.stateless_input_bytes assert sib is not None and len(sib) > 0 - from ethereum.forks.amsterdam.stateless_ssz import ( + from ethereum.forks.amsterdam.stateless import ( STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_BYTES, ) @@ -1368,7 +1368,7 @@ def test_execution_witness_expected_true_reuses_canonical_stateless_result( from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, ) - from ethereum.forks.amsterdam.stateless_ssz import ( + from ethereum.forks.amsterdam.stateless import ( STATELESS_INPUT_SCHEMA_ID, ) from ethereum_types.bytes import Bytes as EthereumBytes @@ -1433,7 +1433,7 @@ def test_execution_witness_soundness_rewrites_stateless_fixture_bytes( from ethereum.forks.amsterdam.stateless_host import ( deserialize_stateless_output, ) - from ethereum.forks.amsterdam.stateless_ssz import ( + from ethereum.forks.amsterdam.stateless import ( STATELESS_INPUT_SCHEMA_ID, ) from ethereum_types.bytes import Bytes as EthereumBytes diff --git a/packages/testing/src/execution_testing/specs/blockchain_stateless.py b/packages/testing/src/execution_testing/specs/blockchain_stateless.py index 46526addff7..8746f38909b 100644 --- a/packages/testing/src/execution_testing/specs/blockchain_stateless.py +++ b/packages/testing/src/execution_testing/specs/blockchain_stateless.py @@ -877,6 +877,7 @@ def build_amsterdam_stateless_artifacts_from_t8n( decode_execution_requests, ) from ethereum.forks.amsterdam.stateless import ( + STATELESS_INPUT_SCHEMA_ID, StatelessValidationResult, compute_new_payload_request_root, ) @@ -887,9 +888,6 @@ def build_amsterdam_stateless_artifacts_from_t8n( build_stateless_input, serialize_stateless_input, ) - from ethereum.forks.amsterdam.stateless_ssz import ( - STATELESS_INPUT_SCHEMA_ID, - ) from ethereum_types.bytes import Bytes as AmsterdamBytes from ethereum_types.numeric import U16, U64 @@ -1058,7 +1056,7 @@ def assert_amsterdam_stateless_output_schema_id( """ Assert the output identifies the input schema executed by the guest. """ - from ethereum.forks.amsterdam.stateless_ssz import ( + from ethereum.forks.amsterdam.stateless import ( STATELESS_INPUT_SCHEMA_ID, ) from ethereum_types.numeric import U16 diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index 17fb23253a8..d27bf3f302c 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -19,6 +19,7 @@ from ethereum.crypto.hash import Hash32 from ethereum.state import Address, Root +from ethereum.utils.ssz import SszContainer from .fork_types import Bloom from .transactions import ( @@ -34,7 +35,7 @@ @final @slotted_freezable @dataclass -class Withdrawal: +class Withdrawal(SszContainer): """ Withdrawals represent a transfer of ETH from the consensus layer (beacon chain) to the execution layer, as validated by the consensus layer. Each diff --git a/src/ethereum/forks/amsterdam/execution_engine/requests.py b/src/ethereum/forks/amsterdam/execution_engine/requests.py index 77c2f749014..77beaf66226 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/requests.py +++ b/src/ethereum/forks/amsterdam/execution_engine/requests.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass -from typing import Sequence, Tuple, final +from typing import Annotated, Sequence, Tuple, final from ethereum_types.bytes import Bytes, Bytes32, Bytes48, Bytes96 from ethereum_types.frozen import slotted_freezable @@ -15,6 +15,11 @@ from ethereum.exceptions import InvalidBlock from ethereum.state import Address +from ethereum.utils.ssz import ( + ProgressiveSszContainer, + SszContainer, + progressive_list, +) from ..requests import ( BUILDER_DEPOSIT_REQUEST_TYPE, @@ -34,7 +39,7 @@ @final @slotted_freezable @dataclass -class DepositRequest: +class DepositRequest(SszContainer): """A single EIP-6110 deposit request.""" pubkey: Bytes48 @@ -47,7 +52,7 @@ class DepositRequest: @final @slotted_freezable @dataclass -class WithdrawalRequest: +class WithdrawalRequest(SszContainer): """A single EIP-7002 withdrawal request.""" source_address: Address @@ -58,7 +63,7 @@ class WithdrawalRequest: @final @slotted_freezable @dataclass -class ConsolidationRequest: +class ConsolidationRequest(SszContainer): """A single EIP-7251 consolidation request.""" source_address: Address @@ -69,7 +74,7 @@ class ConsolidationRequest: @final @slotted_freezable @dataclass -class BuilderDepositRequest: +class BuilderDepositRequest(SszContainer): """A single EIP-8282 builder deposit request.""" pubkey: Bytes48 @@ -81,7 +86,7 @@ class BuilderDepositRequest: @final @slotted_freezable @dataclass -class BuilderExitRequest: +class BuilderExitRequest(SszContainer): """A single EIP-8282 builder exit request.""" source_address: Address @@ -91,18 +96,24 @@ class BuilderExitRequest: @final @slotted_freezable @dataclass -class ExecutionRequests: +class ExecutionRequests(ProgressiveSszContainer): """ Typed engine-API container of execution-layer triggered requests. Mirrors the consensus-layer ``ExecutionRequests`` Container. """ - deposits: Tuple[DepositRequest, ...] - withdrawals: Tuple[WithdrawalRequest, ...] - consolidations: Tuple[ConsolidationRequest, ...] - builder_deposits: Tuple[BuilderDepositRequest, ...] - builder_exits: Tuple[BuilderExitRequest, ...] + deposits: Annotated[Tuple[DepositRequest, ...], progressive_list()] + withdrawals: Annotated[Tuple[WithdrawalRequest, ...], progressive_list()] + consolidations: Annotated[ + Tuple[ConsolidationRequest, ...], progressive_list() + ] + builder_deposits: Annotated[ + Tuple[BuilderDepositRequest, ...], progressive_list() + ] + builder_exits: Annotated[ + Tuple[BuilderExitRequest, ...], progressive_list() + ] def _encode_deposit(d: DepositRequest) -> Bytes: diff --git a/src/ethereum/forks/amsterdam/execution_engine/types.py b/src/ethereum/forks/amsterdam/execution_engine/types.py index 4e782227c9f..02c96772f21 100644 --- a/src/ethereum/forks/amsterdam/execution_engine/types.py +++ b/src/ethereum/forks/amsterdam/execution_engine/types.py @@ -3,7 +3,7 @@ """ from dataclasses import dataclass -from typing import Tuple, final +from typing import Annotated, Tuple, final from ethereum_types.bytes import Bytes, Bytes8, Bytes32 from ethereum_types.frozen import slotted_freezable @@ -11,6 +11,14 @@ from ethereum.crypto.hash import Hash32 from ethereum.state import Address, Root +from ethereum.utils.ssz import ( + ProgressiveSszContainer, + SszContainer, + byte_list, + progressive_byte_list, + progressive_list, + uint, +) from ..blocks import Withdrawal from ..fork import BlockChain @@ -22,12 +30,13 @@ ExecutionEngine = BlockChain PayloadId = Bytes8 _ZERO_HASH32 = Hash32(b"\x00" * 32) +MAX_EXTRA_DATA_BYTES = 32 @final @slotted_freezable @dataclass -class ExecutionPayload: +class ExecutionPayload(ProgressiveSszContainer): """ Represent a new block to be processed by the execution layer. @@ -45,25 +54,28 @@ class ExecutionPayload: receipts_root: Root logs_bloom: Bloom prev_randao: Bytes32 - block_number: Uint - gas_limit: Uint - gas_used: Uint - timestamp: U256 - extra_data: Bytes - base_fee_per_gas: Uint + block_number: Annotated[Uint, uint(64)] + gas_limit: Annotated[Uint, uint(64)] + gas_used: Annotated[Uint, uint(64)] + timestamp: Annotated[U256, uint(64)] + extra_data: Annotated[Bytes, byte_list(MAX_EXTRA_DATA_BYTES)] + base_fee_per_gas: Annotated[Uint, uint(256)] block_hash: Hash32 - transactions: Tuple[Bytes, ...] - withdrawals: Tuple[Withdrawal, ...] + transactions: Annotated[ + Tuple[Annotated[Bytes, progressive_byte_list()], ...], + progressive_list(), + ] + withdrawals: Annotated[Tuple[Withdrawal, ...], progressive_list()] blob_gas_used: U64 excess_blob_gas: U64 - block_access_list: Bytes + block_access_list: Annotated[Bytes, progressive_byte_list()] slot_number: U64 @final @slotted_freezable @dataclass -class NewPayloadRequest: +class NewPayloadRequest(SszContainer): """ Contains an execution payload along with versioned hashes, the parent beacon block root, and execution requests for the @@ -79,7 +91,7 @@ class NewPayloadRequest: """ execution_payload: ExecutionPayload - versioned_hashes: Tuple[VersionedHash, ...] + versioned_hashes: Annotated[Tuple[VersionedHash, ...], progressive_list()] parent_beacon_block_root: Root execution_requests: ExecutionRequests diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index 1d89f72de47..e111ee6eea4 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from enum import IntEnum -from typing import List, Sequence, Tuple, final +from typing import Annotated, List, Sequence, Tuple, final from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -14,6 +14,13 @@ from ethereum.crypto.hash import Hash32, keccak256 from ethereum.forks.bpo5.blocks import Header as PreviousForkHeader from ethereum.state import Root +from ethereum.utils.ssz import ( + SszContainer, + byte_list, + byte_vector, + progressive_list, + ssz_list, +) from .blocks import Header from .execution_engine.new_payload import execute_new_payload_request @@ -23,27 +30,42 @@ from .fork_types import VersionedHash from .witness_state import WitnessState, build_code_db, build_node_db +MAX_WITNESS_HEADERS = 256 +MAX_BYTES_PER_CODE = 2**16 +MAX_BYTES_PER_HEADER = 2**10 +MAX_BYTES_PER_WITNESS_NODE = 2**10 +PUBLIC_KEY_BYTES = 65 + @final @slotted_freezable @dataclass -class ExecutionWitness: +class ExecutionWitness(SszContainer): """ Execution witness data for stateless validation. """ - state: Tuple[Bytes, ...] + state: Annotated[ + Tuple[Annotated[Bytes, byte_list(MAX_BYTES_PER_WITNESS_NODE)], ...], + progressive_list(), + ] """ Hashed trie-node preimages needed during execution and state-root recomputation. """ - codes: Tuple[Bytes, ...] + codes: Annotated[ + Tuple[Annotated[Bytes, byte_list(MAX_BYTES_PER_CODE)], ...], + progressive_list(), + ] """ Contract-code preimages (created or accessed) needed during execution. """ - headers: Tuple[Bytes, ...] + headers: Annotated[ + Tuple[Annotated[Bytes, byte_list(MAX_BYTES_PER_HEADER)], ...], + ssz_list(MAX_WITNESS_HEADERS), + ] """ RLP-encoded block headers used for pre-state and ``BLOCKHASH`` correctness proofs. This may trend toward empty EIP-7709. @@ -106,10 +128,22 @@ class ProtocolFork(IntEnum): Amsterdam = 0x15 +STATELESS_INPUT_SCHEMA_FORK_INDEX = ProtocolFork.Amsterdam +STATELESS_INPUT_SCHEMA_REVISION = 0x01 +STATELESS_INPUT_SCHEMA_ID = ( + STATELESS_INPUT_SCHEMA_FORK_INDEX << 8 +) | STATELESS_INPUT_SCHEMA_REVISION +STATELESS_INPUT_SCHEMA_ID_SIZE = 2 +STATELESS_INPUT_SCHEMA_ID_BYTES = STATELESS_INPUT_SCHEMA_ID.to_bytes( + STATELESS_INPUT_SCHEMA_ID_SIZE, + "big", +) + + @final @slotted_freezable @dataclass -class StatelessInput: +class StatelessInput(SszContainer): """ Input to stateless validation. """ @@ -132,7 +166,10 @@ class StatelessInput: Chain identifier used during payload validation and execution. """ - public_keys: Tuple[Bytes, ...] + public_keys: Annotated[ + Tuple[Annotated[Bytes, byte_vector(PUBLIC_KEY_BYTES)], ...], + progressive_list(), + ] """ 65-byte uncompressed transaction public keys, in payload order. """ @@ -141,7 +178,7 @@ class StatelessInput: @final @slotted_freezable @dataclass -class StatelessValidationResult: +class StatelessValidationResult(SszContainer): """ Result returned by stateless validation. @@ -186,10 +223,7 @@ def compute_new_payload_request_root( """ Compute the request root for a stateless input via SSZ hash tree root. """ - from .stateless_ssz import _new_payload_request_to_ssz - - ssz_npr = _new_payload_request_to_ssz(stateless_input.new_payload_request) - return Hash32(ssz_npr.hash_tree_root()) + return Hash32(stateless_input.new_payload_request.hash_tree_root()) def _decode_header(header_bytes: Bytes) -> Header | PreviousForkHeader: @@ -232,8 +266,6 @@ def verify_stateless_new_payload( """ Statelessly validate the execution payload. """ - from .stateless_ssz import STATELESS_INPUT_SCHEMA_ID - new_payload_request_root = compute_new_payload_request_root( stateless_input ) diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index 20c6e190f7c..f3c0800910e 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -8,25 +8,19 @@ from ethereum.crypto.hash import Hash32 from .stateless import ( + STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_SIZE, StatelessInput, StatelessValidationResult, verify_stateless_new_payload, ) -from .stateless_ssz import ( - STATELESS_INPUT_SCHEMA_ID, - STATELESS_INPUT_SCHEMA_ID_SIZE, - SSZStatelessInput, - ssz_to_stateless_input, - validation_result_to_ssz, -) def serialize_stateless_output( output: StatelessValidationResult, ) -> Bytes: """Serialize a StatelessValidationResult to SSZ bytes.""" - ssz_obj = validation_result_to_ssz(output) - return Bytes(ssz_obj.encode_bytes()) + return Bytes(output.encode_bytes()) def deserialize_stateless_input(data: Bytes) -> StatelessInput: @@ -41,10 +35,7 @@ def deserialize_stateless_input(data: Bytes) -> StatelessInput: raise ValueError( f"Unsupported stateless input schema id: 0x{schema_id:04x}" ) - ssz_obj = SSZStatelessInput.decode_bytes( - data[STATELESS_INPUT_SCHEMA_ID_SIZE:] - ) - return ssz_to_stateless_input(ssz_obj) + return StatelessInput.decode_bytes(data[STATELESS_INPUT_SCHEMA_ID_SIZE:]) def _default_failed_stateless_output() -> StatelessValidationResult: diff --git a/src/ethereum/forks/amsterdam/stateless_host.py b/src/ethereum/forks/amsterdam/stateless_host.py index 9785df5542b..ee25ec0a644 100644 --- a/src/ethereum/forks/amsterdam/stateless_host.py +++ b/src/ethereum/forks/amsterdam/stateless_host.py @@ -17,16 +17,11 @@ from .execution_engine.types import ExecutionPayload, NewPayloadRequest from .fork_types import VersionedHash from .stateless import ( + STATELESS_INPUT_SCHEMA_ID_BYTES, ExecutionWitness, StatelessInput, StatelessValidationResult, ) -from .stateless_ssz import ( - STATELESS_INPUT_SCHEMA_ID_BYTES, - SSZStatelessValidationResult, - ssz_to_validation_result, - stateless_input_to_ssz, -) from .transactions import ( BlobTransaction, LegacyTransaction, @@ -40,16 +35,14 @@ def serialize_stateless_input( stateless_input: StatelessInput, ) -> Bytes: """Serialize a StatelessInput to schema-prefixed SSZ bytes.""" - ssz_obj = stateless_input_to_ssz(stateless_input) return Bytes( - STATELESS_INPUT_SCHEMA_ID_BYTES + bytes(ssz_obj.encode_bytes()) + STATELESS_INPUT_SCHEMA_ID_BYTES + stateless_input.encode_bytes() ) def deserialize_stateless_output(data: Bytes) -> StatelessValidationResult: """Deserialize a StatelessValidationResult from SSZ bytes.""" - ssz_obj = SSZStatelessValidationResult.decode_bytes(data) - return ssz_to_validation_result(ssz_obj) + return StatelessValidationResult.decode_bytes(data) def build_stateless_input( diff --git a/src/ethereum/forks/amsterdam/stateless_ssz.py b/src/ethereum/forks/amsterdam/stateless_ssz.py deleted file mode 100644 index 1802b70cecd..00000000000 --- a/src/ethereum/forks/amsterdam/stateless_ssz.py +++ /dev/null @@ -1,552 +0,0 @@ -""" -SSZ serialization schema for stateless validation types. - -Define ``Container`` subclasses (eth-remerkleable) that mirror the dataclass -types in ``stateless`` and ``execution_engine.types``, plus conversion -functions between the two representations. -""" - -from ethereum_types.bytes import Bytes, Bytes48, Bytes96 -from ethereum_types.numeric import U16, U64, U256, Uint -from remerkleable.basic import boolean, uint16, uint64, uint256 -from remerkleable.byte_arrays import ByteList, Bytes32, ByteVector -from remerkleable.complex import Container -from remerkleable.complex import List as SSZList -from remerkleable.progressive import ( - ProgressiveByteList, - ProgressiveContainer, - ProgressiveList, -) - -from ethereum.crypto.hash import Hash32 -from ethereum.state import Address, Root - -from .blocks import Withdrawal -from .execution_engine.requests import ( - BuilderDepositRequest, - BuilderExitRequest, - ConsolidationRequest, - DepositRequest, - ExecutionRequests, - WithdrawalRequest, -) -from .execution_engine.types import ExecutionPayload, NewPayloadRequest -from .fork_types import Bloom, VersionedHash -from .stateless import ( - ExecutionWitness, - ProtocolFork, - StatelessInput, - StatelessValidationResult, -) - -# --- SSZ max-length constants --- - - -MAX_EXTRA_DATA_BYTES = 32 - -# Execution only exposes the previous 256 block hashes. -MAX_WITNESS_HEADERS = 256 -# As defined in EIP-7954. -MAX_BYTES_PER_CODE = 2**16 -MAX_BYTES_PER_HEADER = 2**10 -# Full secured-trie branch nodes are 532 bytes when all 16 children are -# represented by hashes, which is the largest normal state witness node. -# 2**10 is the next power of two, with almost twice the needed capacity. -MAX_BYTES_PER_WITNESS_NODE = 2**10 - -PUBLIC_KEY_BYTES = 65 - -# Stateless guest input bytes are schema-prefixed: -# schema_id || encoded_payload -# schema_id is fork_index || schema_revision. Amsterdam is fork 0x15, and -# revision 0x01 uses SSZ encode(SSZStatelessInput) for the payload. -STATELESS_INPUT_SCHEMA_FORK_INDEX = ProtocolFork.Amsterdam -STATELESS_INPUT_SCHEMA_REVISION = 0x01 -STATELESS_INPUT_SCHEMA_ID = ( - STATELESS_INPUT_SCHEMA_FORK_INDEX << 8 -) | STATELESS_INPUT_SCHEMA_REVISION -STATELESS_INPUT_SCHEMA_ID_SIZE = 2 -STATELESS_INPUT_SCHEMA_ID_BYTES = STATELESS_INPUT_SCHEMA_ID.to_bytes( - STATELESS_INPUT_SCHEMA_ID_SIZE, - "big", -) - - -# --- SSZ Container types --- - - -class SSZWithdrawal(Container): - """SSZ container mirroring ``Withdrawal``.""" - - index: uint64 - validator_index: uint64 - address: ByteVector[20] - amount: uint64 - - -class SSZExecutionPayload( - ProgressiveContainer(active_fields=[1] * 19) # type: ignore[misc] -): - """SSZ container mirroring ``ExecutionPayload``.""" - - parent_hash: Bytes32 - fee_recipient: ByteVector[20] - state_root: Bytes32 - receipts_root: Bytes32 - logs_bloom: ByteVector[256] - prev_randao: Bytes32 - block_number: uint64 - gas_limit: uint64 - gas_used: uint64 - timestamp: uint64 - extra_data: ByteList[MAX_EXTRA_DATA_BYTES] - base_fee_per_gas: uint256 - block_hash: Bytes32 - transactions: ProgressiveList[ProgressiveByteList] - withdrawals: ProgressiveList[SSZWithdrawal] - blob_gas_used: uint64 - excess_blob_gas: uint64 - block_access_list: ProgressiveByteList - slot_number: uint64 - - -class SSZDepositRequest(Container): - """SSZ container mirroring ``DepositRequest``.""" - - pubkey: ByteVector[48] - withdrawal_credentials: Bytes32 - amount: uint64 - signature: ByteVector[96] - index: uint64 - - -class SSZWithdrawalRequest(Container): - """SSZ container mirroring ``WithdrawalRequest``.""" - - source_address: ByteVector[20] - validator_pubkey: ByteVector[48] - amount: uint64 - - -class SSZConsolidationRequest(Container): - """SSZ container mirroring ``ConsolidationRequest``.""" - - source_address: ByteVector[20] - source_pubkey: ByteVector[48] - target_pubkey: ByteVector[48] - - -class SSZBuilderDepositRequest(Container): - """SSZ container mirroring ``BuilderDepositRequest``.""" - - pubkey: ByteVector[48] - withdrawal_credentials: Bytes32 - amount: uint64 - signature: ByteVector[96] - - -class SSZBuilderExitRequest(Container): - """SSZ container mirroring ``BuilderExitRequest``.""" - - source_address: ByteVector[20] - pubkey: ByteVector[48] - - -class SSZExecutionRequests( - ProgressiveContainer(active_fields=[1] * 5) # type: ignore[misc] -): - """SSZ container mirroring ``ExecutionRequests``.""" - - deposits: ProgressiveList[SSZDepositRequest] - withdrawals: ProgressiveList[SSZWithdrawalRequest] - consolidations: ProgressiveList[SSZConsolidationRequest] - builder_deposits: ProgressiveList[SSZBuilderDepositRequest] - builder_exits: ProgressiveList[SSZBuilderExitRequest] - - -class SSZNewPayloadRequest(Container): - """SSZ container mirroring ``NewPayloadRequest``.""" - - execution_payload: SSZExecutionPayload - versioned_hashes: ProgressiveList[Bytes32] - parent_beacon_block_root: Bytes32 - execution_requests: SSZExecutionRequests - - -class SSZExecutionWitness(Container): - """SSZ container mirroring ``ExecutionWitness``.""" - - state: ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]] - codes: ProgressiveList[ByteList[MAX_BYTES_PER_CODE]] - headers: SSZList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] - - -class SSZStatelessInput(Container): - """SSZ container mirroring ``StatelessInput``.""" - - new_payload_request: SSZNewPayloadRequest - witness: SSZExecutionWitness - chain_id: uint64 - public_keys: ProgressiveList[ByteVector[PUBLIC_KEY_BYTES]] - - -class SSZStatelessValidationResult(Container): - """SSZ container mirroring ``StatelessValidationResult``.""" - - new_payload_request_root: Bytes32 - successful_validation: boolean - chain_id: uint64 - schema_id: uint16 - - -# --- Conversion helpers --- - - -def _withdrawal_to_ssz(w: Withdrawal) -> SSZWithdrawal: - """Convert a Withdrawal to its SSZ form.""" - return SSZWithdrawal( - index=uint64(int(w.index)), - validator_index=uint64(int(w.validator_index)), - address=ByteVector[20](bytes(w.address)), - amount=uint64(int(w.amount)), - ) - - -def _ssz_to_withdrawal(sw: SSZWithdrawal) -> Withdrawal: - """Convert an SSZ withdrawal back to a Withdrawal.""" - return Withdrawal( - index=U64(sw.index), - validator_index=U64(sw.validator_index), - address=Address(bytes(sw.address)), - amount=U64(sw.amount), - ) - - -def _payload_to_ssz( - p: ExecutionPayload, -) -> SSZExecutionPayload: - """Convert an ExecutionPayload to its SSZ form.""" - return SSZExecutionPayload( - parent_hash=Bytes32(bytes(p.parent_hash)), - fee_recipient=ByteVector[20](bytes(p.fee_recipient)), - state_root=Bytes32(bytes(p.state_root)), - receipts_root=Bytes32(bytes(p.receipts_root)), - logs_bloom=ByteVector[256](bytes(p.logs_bloom)), - prev_randao=Bytes32(bytes(p.prev_randao)), - block_number=uint64(int(p.block_number)), - gas_limit=uint64(int(p.gas_limit)), - gas_used=uint64(int(p.gas_used)), - timestamp=uint64(int(p.timestamp)), - extra_data=ByteList[MAX_EXTRA_DATA_BYTES](bytes(p.extra_data)), - base_fee_per_gas=uint256(int(p.base_fee_per_gas)), - block_hash=Bytes32(bytes(p.block_hash)), - transactions=ProgressiveList[ProgressiveByteList]( - ProgressiveByteList(bytes(tx)) for tx in p.transactions - ), - withdrawals=ProgressiveList[SSZWithdrawal]( - _withdrawal_to_ssz(w) for w in p.withdrawals - ), - blob_gas_used=uint64(int(p.blob_gas_used)), - excess_blob_gas=uint64(int(p.excess_blob_gas)), - block_access_list=ProgressiveByteList(bytes(p.block_access_list)), - slot_number=uint64(int(p.slot_number)), - ) - - -def _ssz_to_payload( - sp: SSZExecutionPayload, -) -> ExecutionPayload: - """Convert an SSZ execution payload back to ExecutionPayload.""" - return ExecutionPayload( - parent_hash=Hash32(bytes(sp.parent_hash)), - fee_recipient=Address(bytes(sp.fee_recipient)), - state_root=Root(bytes(sp.state_root)), - receipts_root=Root(bytes(sp.receipts_root)), - logs_bloom=Bloom(bytes(sp.logs_bloom)), - prev_randao=Bytes32(bytes(sp.prev_randao)), - block_number=Uint(sp.block_number), - gas_limit=Uint(sp.gas_limit), - gas_used=Uint(sp.gas_used), - timestamp=U256(sp.timestamp), - extra_data=Bytes(bytes(sp.extra_data)), - base_fee_per_gas=Uint(sp.base_fee_per_gas), - block_hash=Hash32(bytes(sp.block_hash)), - transactions=tuple(Bytes(bytes(tx)) for tx in sp.transactions), - withdrawals=tuple(_ssz_to_withdrawal(sw) for sw in sp.withdrawals), - blob_gas_used=U64(sp.blob_gas_used), - excess_blob_gas=U64(sp.excess_blob_gas), - block_access_list=Bytes(bytes(sp.block_access_list)), - slot_number=U64(sp.slot_number), - ) - - -def _deposit_request_to_ssz(d: DepositRequest) -> SSZDepositRequest: - """Convert a DepositRequest to its SSZ form.""" - return SSZDepositRequest( - pubkey=ByteVector[48](bytes(d.pubkey)), - withdrawal_credentials=Bytes32(bytes(d.withdrawal_credentials)), - amount=uint64(int(d.amount)), - signature=ByteVector[96](bytes(d.signature)), - index=uint64(int(d.index)), - ) - - -def _ssz_to_deposit_request(sd: SSZDepositRequest) -> DepositRequest: - """Convert an SSZ deposit request back.""" - return DepositRequest( - pubkey=Bytes48(bytes(sd.pubkey)), - withdrawal_credentials=Bytes32(bytes(sd.withdrawal_credentials)), - amount=U64(sd.amount), - signature=Bytes96(bytes(sd.signature)), - index=U64(sd.index), - ) - - -def _withdrawal_request_to_ssz( - w: WithdrawalRequest, -) -> SSZWithdrawalRequest: - """Convert a WithdrawalRequest to its SSZ form.""" - return SSZWithdrawalRequest( - source_address=ByteVector[20](bytes(w.source_address)), - validator_pubkey=ByteVector[48](bytes(w.validator_pubkey)), - amount=uint64(int(w.amount)), - ) - - -def _ssz_to_withdrawal_request( - sw: SSZWithdrawalRequest, -) -> WithdrawalRequest: - """Convert an SSZ withdrawal request back.""" - return WithdrawalRequest( - source_address=Address(bytes(sw.source_address)), - validator_pubkey=Bytes48(bytes(sw.validator_pubkey)), - amount=U64(sw.amount), - ) - - -def _consolidation_request_to_ssz( - c: ConsolidationRequest, -) -> SSZConsolidationRequest: - """Convert a ConsolidationRequest to its SSZ form.""" - return SSZConsolidationRequest( - source_address=ByteVector[20](bytes(c.source_address)), - source_pubkey=ByteVector[48](bytes(c.source_pubkey)), - target_pubkey=ByteVector[48](bytes(c.target_pubkey)), - ) - - -def _ssz_to_consolidation_request( - sc: SSZConsolidationRequest, -) -> ConsolidationRequest: - """Convert an SSZ consolidation request back.""" - return ConsolidationRequest( - source_address=Address(bytes(sc.source_address)), - source_pubkey=Bytes48(bytes(sc.source_pubkey)), - target_pubkey=Bytes48(bytes(sc.target_pubkey)), - ) - - -def _builder_deposit_request_to_ssz( - b: BuilderDepositRequest, -) -> SSZBuilderDepositRequest: - """Convert a BuilderDepositRequest to its SSZ form.""" - return SSZBuilderDepositRequest( - pubkey=ByteVector[48](bytes(b.pubkey)), - withdrawal_credentials=Bytes32(bytes(b.withdrawal_credentials)), - amount=uint64(int(b.amount)), - signature=ByteVector[96](bytes(b.signature)), - ) - - -def _ssz_to_builder_deposit_request( - sb: SSZBuilderDepositRequest, -) -> BuilderDepositRequest: - """Convert an SSZ builder deposit request back.""" - return BuilderDepositRequest( - pubkey=Bytes48(bytes(sb.pubkey)), - withdrawal_credentials=Bytes32(bytes(sb.withdrawal_credentials)), - amount=U64(sb.amount), - signature=Bytes96(bytes(sb.signature)), - ) - - -def _builder_exit_request_to_ssz( - b: BuilderExitRequest, -) -> SSZBuilderExitRequest: - """Convert a BuilderExitRequest to its SSZ form.""" - return SSZBuilderExitRequest( - source_address=ByteVector[20](bytes(b.source_address)), - pubkey=ByteVector[48](bytes(b.pubkey)), - ) - - -def _ssz_to_builder_exit_request( - sb: SSZBuilderExitRequest, -) -> BuilderExitRequest: - """Convert an SSZ builder exit request back.""" - return BuilderExitRequest( - source_address=Address(bytes(sb.source_address)), - pubkey=Bytes48(bytes(sb.pubkey)), - ) - - -def _execution_requests_to_ssz( - er: ExecutionRequests, -) -> SSZExecutionRequests: - """Convert an ExecutionRequests to its SSZ form.""" - return SSZExecutionRequests( - deposits=ProgressiveList[SSZDepositRequest]( - _deposit_request_to_ssz(d) for d in er.deposits - ), - withdrawals=ProgressiveList[SSZWithdrawalRequest]( - _withdrawal_request_to_ssz(w) for w in er.withdrawals - ), - consolidations=ProgressiveList[SSZConsolidationRequest]( - _consolidation_request_to_ssz(c) for c in er.consolidations - ), - builder_deposits=ProgressiveList[SSZBuilderDepositRequest]( - _builder_deposit_request_to_ssz(b) for b in er.builder_deposits - ), - builder_exits=ProgressiveList[SSZBuilderExitRequest]( - _builder_exit_request_to_ssz(b) for b in er.builder_exits - ), - ) - - -def _ssz_to_execution_requests( - ser: SSZExecutionRequests, -) -> ExecutionRequests: - """Convert an SSZ execution requests back.""" - return ExecutionRequests( - deposits=tuple(_ssz_to_deposit_request(sd) for sd in ser.deposits), - withdrawals=tuple( - _ssz_to_withdrawal_request(sw) for sw in ser.withdrawals - ), - consolidations=tuple( - _ssz_to_consolidation_request(sc) for sc in ser.consolidations - ), - builder_deposits=tuple( - _ssz_to_builder_deposit_request(sb) for sb in ser.builder_deposits - ), - builder_exits=tuple( - _ssz_to_builder_exit_request(sb) for sb in ser.builder_exits - ), - ) - - -def _new_payload_request_to_ssz( - npr: NewPayloadRequest, -) -> SSZNewPayloadRequest: - """Convert a NewPayloadRequest to its SSZ form.""" - return SSZNewPayloadRequest( - execution_payload=_payload_to_ssz(npr.execution_payload), - versioned_hashes=ProgressiveList[Bytes32]( - Bytes32(bytes(vh)) for vh in npr.versioned_hashes - ), - parent_beacon_block_root=Bytes32(bytes(npr.parent_beacon_block_root)), - execution_requests=_execution_requests_to_ssz(npr.execution_requests), - ) - - -def _ssz_to_new_payload_request( - snpr: SSZNewPayloadRequest, -) -> NewPayloadRequest: - """Convert an SSZ new payload request back.""" - return NewPayloadRequest( - execution_payload=_ssz_to_payload(snpr.execution_payload), - versioned_hashes=tuple( - VersionedHash(bytes(vh)) for vh in snpr.versioned_hashes - ), - parent_beacon_block_root=Root(bytes(snpr.parent_beacon_block_root)), - execution_requests=_ssz_to_execution_requests(snpr.execution_requests), - ) - - -def _witness_to_ssz( - w: ExecutionWitness, -) -> SSZExecutionWitness: - """Convert an ExecutionWitness to its SSZ form.""" - return SSZExecutionWitness( - state=ProgressiveList[ByteList[MAX_BYTES_PER_WITNESS_NODE]]( - ByteList[MAX_BYTES_PER_WITNESS_NODE](bytes(s)) for s in w.state - ), - codes=ProgressiveList[ByteList[MAX_BYTES_PER_CODE]]( - ByteList[MAX_BYTES_PER_CODE](bytes(c)) for c in w.codes - ), - headers=SSZList[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS]( - ByteList[MAX_BYTES_PER_HEADER](bytes(h)) for h in w.headers - ), - ) - - -def _ssz_to_witness( - sw: SSZExecutionWitness, -) -> ExecutionWitness: - """Convert an SSZ execution witness back.""" - return ExecutionWitness( - state=tuple(Bytes(bytes(s)) for s in sw.state), - codes=tuple(Bytes(bytes(c)) for c in sw.codes), - headers=tuple(Bytes(bytes(h)) for h in sw.headers), - ) - - -def stateless_input_to_ssz( - si: StatelessInput, -) -> SSZStatelessInput: - """Convert a StatelessInput to its SSZ form.""" - for public_key in si.public_keys: - if len(public_key) != PUBLIC_KEY_BYTES: - raise ValueError( - f"Transaction public key must be {PUBLIC_KEY_BYTES} bytes" - ) - - return SSZStatelessInput( - new_payload_request=_new_payload_request_to_ssz( - si.new_payload_request - ), - witness=_witness_to_ssz(si.witness), - chain_id=uint64(int(si.chain_id)), - public_keys=ProgressiveList[ByteVector[PUBLIC_KEY_BYTES]]( - ByteVector[PUBLIC_KEY_BYTES](bytes(pk)) for pk in si.public_keys - ), - ) - - -def ssz_to_stateless_input( - ssz_si: SSZStatelessInput, -) -> StatelessInput: - """Convert an SSZ stateless input back.""" - return StatelessInput( - new_payload_request=_ssz_to_new_payload_request( - ssz_si.new_payload_request - ), - witness=_ssz_to_witness(ssz_si.witness), - chain_id=U64(ssz_si.chain_id), - public_keys=tuple(Bytes(bytes(pk)) for pk in ssz_si.public_keys), - ) - - -def validation_result_to_ssz( - vr: StatelessValidationResult, -) -> SSZStatelessValidationResult: - """Convert a StatelessValidationResult to its SSZ form.""" - return SSZStatelessValidationResult( - new_payload_request_root=Bytes32(bytes(vr.new_payload_request_root)), - successful_validation=boolean(vr.successful_validation), - chain_id=uint64(int(vr.chain_id)), - schema_id=uint16(int(vr.schema_id)), - ) - - -def ssz_to_validation_result( - ssz_vr: SSZStatelessValidationResult, -) -> StatelessValidationResult: - """Convert an SSZ validation result back.""" - return StatelessValidationResult( - new_payload_request_root=Hash32( - bytes(ssz_vr.new_payload_request_root) - ), - successful_validation=bool(ssz_vr.successful_validation), - chain_id=U64(ssz_vr.chain_id), - schema_id=U16(ssz_vr.schema_id), - ) diff --git a/src/ethereum/utils/ssz.py b/src/ethereum/utils/ssz.py new file mode 100644 index 00000000000..356e0d47893 --- /dev/null +++ b/src/ethereum/utils/ssz.py @@ -0,0 +1,312 @@ +""" +Serialize specification dataclasses with SSZ while retaining Python types. +""" + +from dataclasses import dataclass, fields +from typing import ( + Annotated, + Any, + Dict, + Tuple, + Type, + TypeVar, + get_args, + get_origin, + get_type_hints, +) + +from ethereum_types.bytes import FixedBytes +from ethereum_types.numeric import FixedUnsigned, Unsigned +from remerkleable import basic as rmk_basic +from remerkleable.byte_arrays import ByteList, ByteVector +from remerkleable.complex import Container +from remerkleable.complex import List as RmkList +from remerkleable.progressive import ( + ProgressiveByteList, + ProgressiveContainer, + ProgressiveList, +) + + +class _SszType: + pass + + +@dataclass(frozen=True) +class _Uint(_SszType): + bits: int + + +@dataclass(frozen=True) +class _ByteVector(_SszType): + length: int + + +@dataclass(frozen=True) +class _ByteList(_SszType): + limit: int + + +@dataclass(frozen=True) +class _List(_SszType): + element: _SszType + limit: int + + +@dataclass(frozen=True) +class _ProgressiveList(_SszType): + element: _SszType + + +@dataclass(frozen=True) +class _Container(_SszType): + model: Any + + +class _Bool(_SszType): + pass + + +class _ProgressiveByteList(_SszType): + pass + + +@dataclass(frozen=True) +class _ListLimit: + limit: int + + +class _ProgressiveListMarker: + pass + + +def uint(bits: int) -> _Uint: + """Mark an unsigned integer field with its SSZ bit width.""" + if bits not in (8, 16, 32, 64, 128, 256): + raise ValueError(f"Unsupported SSZ integer width: {bits}") + return _Uint(bits) + + +def byte_vector(length: int) -> _ByteVector: + """Mark a byte field as a fixed-length SSZ byte vector.""" + return _ByteVector(length) + + +def byte_list(limit: int) -> _ByteList: + """Mark a byte field as a bounded SSZ byte list.""" + return _ByteList(limit) + + +def ssz_list(limit: int) -> _ListLimit: + """Mark a collection field as a bounded SSZ list.""" + return _ListLimit(limit) + + +def progressive_list() -> _ProgressiveListMarker: + """Mark a collection field as an SSZ progressive list.""" + return _ProgressiveListMarker() + + +def progressive_byte_list() -> _ProgressiveByteList: + """Mark a byte field as an SSZ progressive byte list.""" + return _ProgressiveByteList() + + +_C = TypeVar("_C", bound="SszContainer") + + +class SszContainer: + """Provide SSZ operations for a specification dataclass.""" + + def encode_bytes(self) -> bytes: + """Encode this dataclass as SSZ bytes.""" + return _to_view(self).encode_bytes() + + def hash_tree_root(self) -> bytes: + """Return the SSZ hash-tree root of this dataclass.""" + return bytes(_to_view(self).hash_tree_root()) + + @classmethod + def decode_bytes(cls: Type[_C], data: bytes) -> _C: + """Decode SSZ bytes into this dataclass type.""" + view = _container_type(cls).decode_bytes(data) + return _from_view(cls, view) + + +class ProgressiveSszContainer(SszContainer): + """Identify a dataclass encoded as an SSZ progressive container.""" + + +def _annotated(annotation: Any) -> Tuple[Any, Any]: + if get_origin(annotation) is not Annotated: + return annotation, None + base, *metadata = get_args(annotation) + markers = [ + item + for item in metadata + if isinstance(item, (_SszType, _ListLimit, _ProgressiveListMarker)) + ] + if len(markers) != 1: + raise TypeError( + f"Annotated SSZ field requires exactly one marker: {annotation!r}" + ) + return base, markers[0] + + +def _collection_element(annotation: Any) -> Any: + base, _ = _annotated(annotation) + args = get_args(base) + if get_origin(base) is not tuple or ( + len(args) != 2 or args[1] is not Ellipsis + ): + raise TypeError(f"SSZ tuple must have one repeated type: {base!r}") + return args[0] + + +def _infer(annotation: Any) -> _SszType: + base, marker = _annotated(annotation) + if isinstance(marker, _ListLimit): + return _List(_infer(_collection_element(base)), marker.limit) + if isinstance(marker, _ProgressiveListMarker): + return _ProgressiveList(_infer(_collection_element(base))) + if isinstance(marker, _SszType): + if isinstance(marker, _Uint): + if not isinstance(base, type) or not issubclass(base, Unsigned): + raise TypeError(f"SSZ uint requires Unsigned: {base!r}") + elif isinstance( + marker, (_ByteVector, _ByteList, _ProgressiveByteList) + ): + if not isinstance(base, type) or not issubclass(base, bytes): + raise TypeError(f"SSZ byte type requires bytes: {base!r}") + return marker + + if base is bool: + return _Bool() + if isinstance(base, type): + if issubclass(base, FixedBytes): + return _ByteVector(base.LENGTH) + if issubclass(base, FixedUnsigned): + return _Uint(int(base.MAX_VALUE).bit_length()) + if issubclass(base, SszContainer): + return _Container(base) + raise TypeError(f"Cannot infer an SSZ type for {base!r}") + + +def _rmk_type(ssz_type: _SszType) -> Any: + if isinstance(ssz_type, _Uint): + return getattr(rmk_basic, f"uint{ssz_type.bits}") + if isinstance(ssz_type, _ByteVector): + return ByteVector[ssz_type.length] + if isinstance(ssz_type, _ByteList): + return ByteList[ssz_type.limit] + if isinstance(ssz_type, _List): + return RmkList[_rmk_type(ssz_type.element), ssz_type.limit] + if isinstance(ssz_type, _ProgressiveList): + return ProgressiveList[_rmk_type(ssz_type.element)] + if isinstance(ssz_type, _ProgressiveByteList): + return ProgressiveByteList + if isinstance(ssz_type, _Container): + return _container_type(ssz_type.model) + if isinstance(ssz_type, _Bool): + return rmk_basic.boolean + raise TypeError(f"Unsupported SSZ type: {ssz_type!r}") + + +_FIELD_TYPES: Dict[Any, Tuple[Tuple[str, Any], ...]] = {} + + +def _field_types(model: Any) -> Tuple[Tuple[str, Any], ...]: + if model in _FIELD_TYPES: + return _FIELD_TYPES[model] + hints = get_type_hints(model, include_extras=True) + result = tuple((field.name, hints[field.name]) for field in fields(model)) + _FIELD_TYPES[model] = result + return result + + +_CONTAINER_TYPES: Dict[Any, Type[Container]] = {} + + +def _container_type(model: Any) -> Type[Container]: + if model in _CONTAINER_TYPES: + return _CONTAINER_TYPES[model] + annotations = { + name: _rmk_type(_infer(annotation)) + for name, annotation in _field_types(model) + } + if issubclass(model, ProgressiveSszContainer): + base: Any = ProgressiveContainer(active_fields=[1] * len(annotations)) + else: + base = Container + result = type( + f"_{model.__name__}Ssz", + (base,), + {"__annotations__": annotations}, + ) + _CONTAINER_TYPES[model] = result + return result + + +def _to_ssz_value(annotation: Any, ssz_type: _SszType, value: Any) -> Any: + if isinstance(ssz_type, _Container): + return _to_view(value) + if isinstance(ssz_type, (_List, _ProgressiveList)): + element = _collection_element(annotation) + return [ + _to_ssz_value(element, ssz_type.element, item) for item in value + ] + if isinstance(ssz_type, _Uint): + return int(value) + if isinstance(ssz_type, (_ByteVector, _ByteList, _ProgressiveByteList)): + encoded = bytes(value) + if ( + isinstance(ssz_type, _ByteVector) + and len(encoded) != ssz_type.length + ): + raise ValueError( + f"Expected {ssz_type.length} bytes, got {len(encoded)}" + ) + return encoded + if isinstance(ssz_type, _Bool): + return bool(value) + raise TypeError(f"Unsupported SSZ type: {ssz_type!r}") + + +def _to_view(value: SszContainer) -> Container: + model = type(value) + values = { + name: _to_ssz_value( + annotation, _infer(annotation), getattr(value, name) + ) + for name, annotation in _field_types(model) + } + return _container_type(model)(**values) + + +def _from_ssz_value(annotation: Any, ssz_type: _SszType, value: Any) -> Any: + base, _ = _annotated(annotation) + if isinstance(ssz_type, _Container): + return _from_view(ssz_type.model, value) + if isinstance(ssz_type, (_List, _ProgressiveList)): + element = _collection_element(annotation) + decoded = [ + _from_ssz_value(element, ssz_type.element, item) for item in value + ] + return tuple(decoded) + if isinstance(ssz_type, _Uint): + return base(int(value)) + if isinstance(ssz_type, (_ByteVector, _ByteList, _ProgressiveByteList)): + return base(bytes(value)) + if isinstance(ssz_type, _Bool): + return bool(value) + raise TypeError(f"Unsupported SSZ type: {ssz_type!r}") + + +def _from_view(model: Type[_C], view: Container) -> _C: + values = { + name: _from_ssz_value( + annotation, _infer(annotation), getattr(view, name) + ) + for name, annotation in _field_types(model) + } + return model(**values) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py index 842a1a2cc30..6f60327ce36 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -65,7 +65,7 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: The stateless input starts with a 2-byte schema id, followed by the encoded payload selected by that schema. For Amsterdam schema 0x1501, - the payload is an SSZ-encoded ``SSZStatelessInput`` container. Its + the payload is an SSZ-encoded ``StatelessInput`` container. Its first four SSZ bytes encode the offset to the first variable-size field. Setting that offset to 1 makes it point inside the fixed-size section, so the SSZ decoder must reject the input before stateless validation diff --git a/tests/json_loader/test_ssz.py b/tests/json_loader/test_ssz.py new file mode 100644 index 00000000000..750a38895ca --- /dev/null +++ b/tests/json_loader/test_ssz.py @@ -0,0 +1,86 @@ +"""Tests for dataclass-native SSZ serialization.""" + +from dataclasses import dataclass +from typing import Annotated, Tuple + +import pytest +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U16, U64, Uint + +from ethereum.utils.ssz import ( + ProgressiveSszContainer, + SszContainer, + byte_list, + progressive_list, + ssz_list, + uint, +) + + +@dataclass(frozen=True) +class _Item(SszContainer): + key: Bytes32 + value: U16 + + +@dataclass(frozen=True) +class _ProgressiveItems(ProgressiveSszContainer): + items: Annotated[Tuple[_Item, ...], progressive_list()] + + +@dataclass(frozen=True) +class _Envelope(SszContainer): + count: Annotated[Uint, uint(64)] + payload: Annotated[bytes, byte_list(16)] + fixed_items: Annotated[Tuple[_Item, ...], ssz_list(2)] + progressive_items: _ProgressiveItems + + +def test_nested_containers_roundtrip() -> None: + """Restore Python types after standard and progressive SSZ decoding.""" + item = _Item(key=Bytes32(b"\x11" * 32), value=U16(3)) + original = _Envelope( + count=Uint(1), + payload=b"payload", + fixed_items=(item,), + progressive_items=_ProgressiveItems(items=(item, item)), + ) + + encoded = original.encode_bytes() + recovered = _Envelope.decode_bytes(encoded) + + assert recovered == original + assert type(recovered.count) is Uint + assert type(recovered.fixed_items) is tuple + assert type(recovered.progressive_items.items) is tuple + assert len(original.hash_tree_root()) == 32 + + +def test_collection_limits_are_enforced() -> None: + """Reject dataclass values that exceed their declared SSZ limits.""" + item = _Item(key=Bytes32(b"\x22" * 32), value=U16(4)) + too_many_items = _Envelope( + count=Uint(3), + payload=b"payload", + fixed_items=(item, item, item), + progressive_items=_ProgressiveItems(items=()), + ) + + with pytest.raises(Exception, match="too many list inputs"): + too_many_items.encode_bytes() + + +def test_fixed_width_integer_roundtrip() -> None: + """Decode an explicitly sized integer to its specification type.""" + original = _Envelope( + count=Uint(2**63), + payload=b"", + fixed_items=(), + progressive_items=_ProgressiveItems(items=()), + ) + + recovered = _Envelope.decode_bytes(original.encode_bytes()) + + assert recovered.count == Uint(2**63) + assert type(recovered.count) is Uint + assert U64(recovered.count) == U64(2**63) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 0659719851f..7f4e9f707f4 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -1,6 +1,7 @@ """Tests for stateless_guest serialization roundtrip.""" import random +from hashlib import sha256 from typing import Tuple import pytest @@ -26,6 +27,14 @@ ) from ethereum.forks.amsterdam.fork_types import Bloom from ethereum.forks.amsterdam.stateless import ( + MAX_BYTES_PER_CODE, + MAX_BYTES_PER_HEADER, + MAX_BYTES_PER_WITNESS_NODE, + MAX_WITNESS_HEADERS, + STATELESS_INPUT_SCHEMA_FORK_INDEX, + STATELESS_INPUT_SCHEMA_ID, + STATELESS_INPUT_SCHEMA_ID_BYTES, + STATELESS_INPUT_SCHEMA_REVISION, ExecutionWitness, ProtocolFork, StatelessInput, @@ -43,17 +52,6 @@ deserialize_stateless_output, serialize_stateless_input, ) -from ethereum.forks.amsterdam.stateless_ssz import ( - MAX_BYTES_PER_CODE, - MAX_BYTES_PER_HEADER, - MAX_BYTES_PER_WITNESS_NODE, - MAX_WITNESS_HEADERS, - STATELESS_INPUT_SCHEMA_FORK_INDEX, - STATELESS_INPUT_SCHEMA_ID, - STATELESS_INPUT_SCHEMA_ID_BYTES, - STATELESS_INPUT_SCHEMA_REVISION, - stateless_input_to_ssz, -) from ethereum.forks.amsterdam.transactions import LegacyTransaction from ethereum.state import Address, Root @@ -198,6 +196,18 @@ def _make_stateless_output() -> StatelessValidationResult: ) +def _make_known_stateless_values() -> tuple[ + StatelessInput, StatelessValidationResult +]: + """Return order-independent values used by SSZ known-answer tests.""" + state = _RNG.getstate() + try: + _RNG.seed(0xDEADBEEF) + return _make_stateless_input(), _make_stateless_output() + finally: + _RNG.setstate(state) + + class TestBuildStatelessInput: """Test host-side StatelessInput construction.""" @@ -334,6 +344,22 @@ def test_roundtrip(self) -> None: recovered = deserialize_stateless_input(encoded) assert recovered == original + def test_known_encoding_and_request_root(self) -> None: + """Retain the schema bytes and payload request hash-tree root.""" + original, _ = _make_known_stateless_values() + encoded = serialize_stateless_input(original) + + assert len(encoded) == 3072 + assert sha256(encoded).hexdigest() == ( + "b7d516d24d8bde7426cae58f22b04fd7e824353eccc30bba9592487bcf4e55ec" + ) + assert compute_new_payload_request_root(original) == Hash32( + bytes.fromhex( + "71d67022e2df6fc9b757f8c4614f1da2" + "0c993efe8595a715829093c431b3f4eb" + ) + ) + def test_empty_witness(self) -> None: """Works with an empty witness.""" original = StatelessInput( @@ -443,6 +469,12 @@ def test_roundtrip(self) -> None: encoded = serialize_stateless_input(original) recovered = deserialize_stateless_input(encoded) assert recovered == original + payload = recovered.new_payload_request.execution_payload + assert type(payload.block_number) is Uint + assert type(payload.timestamp) is U256 + assert type(payload.transactions) is tuple + assert type(recovered.witness.state) is tuple + assert type(recovered.public_keys) is tuple def test_empty_witness(self) -> None: """Works with an empty witness.""" @@ -492,7 +524,7 @@ def test_unsupported_schema_fork_rejected(self) -> None: def test_legacy_raw_ssz_input_rejected(self) -> None: """Reject unprefixed SSZ input bytes.""" original = _make_stateless_input() - raw_ssz = Bytes(stateless_input_to_ssz(original).encode_bytes()) + raw_ssz = Bytes(original.encode_bytes()) assert raw_ssz[:2] != STATELESS_INPUT_SCHEMA_ID_BYTES with pytest.raises(ValueError, match="Unsupported stateless input"): deserialize_stateless_input(raw_ssz) @@ -511,6 +543,14 @@ def test_roundtrip(self) -> None: assert recovered.chain_id == U64(1) assert recovered.schema_id == U16(STATELESS_INPUT_SCHEMA_ID) + def test_known_encoding(self) -> None: + """Retain the fixed SSZ output encoding.""" + _, original = _make_known_stateless_values() + assert serialize_stateless_output(original).hex() == ( + "0d663a7de3d811fbc797f605a65d2745df3a97d9f1994dfb685ff66d2917009f" + "0101000000000000000115" + ) + def test_failed_validation(self) -> None: """Preserve the input schema when later validation fails.""" original = StatelessValidationResult( From e2b55dc3cbad70daba5c8a97cfec917cef4f0672 Mon Sep 17 00:00:00 2001 From: jsign Date: Wed, 26 Aug 2026 14:06:53 -0300 Subject: [PATCH 262/265] lints --- src/ethereum/utils/ssz.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ethereum/utils/ssz.py b/src/ethereum/utils/ssz.py index 356e0d47893..2ef05555aed 100644 --- a/src/ethereum/utils/ssz.py +++ b/src/ethereum/utils/ssz.py @@ -10,6 +10,7 @@ Tuple, Type, TypeVar, + final, get_args, get_origin, get_type_hints, @@ -32,32 +33,38 @@ class _SszType: pass +@final @dataclass(frozen=True) class _Uint(_SszType): bits: int +@final @dataclass(frozen=True) class _ByteVector(_SszType): length: int +@final @dataclass(frozen=True) class _ByteList(_SszType): limit: int +@final @dataclass(frozen=True) class _List(_SszType): element: _SszType limit: int +@final @dataclass(frozen=True) class _ProgressiveList(_SszType): element: _SszType +@final @dataclass(frozen=True) class _Container(_SszType): model: Any @@ -71,6 +78,7 @@ class _ProgressiveByteList(_SszType): pass +@final @dataclass(frozen=True) class _ListLimit: limit: int From 1cc29478d411205124e33a54b60cdbc91b683394 Mon Sep 17 00:00:00 2001 From: jsign Date: Tue, 1 Sep 2026 20:50:02 -0300 Subject: [PATCH 263/265] cleanup 7610 code --- src/ethereum/forks/amsterdam/witness_state.py | 11 ----------- tests/json_loader/test_witness_state.py | 16 ---------------- 2 files changed, 27 deletions(-) diff --git a/src/ethereum/forks/amsterdam/witness_state.py b/src/ethereum/forks/amsterdam/witness_state.py index 78de0404557..8246fc0031b 100644 --- a/src/ethereum/forks/amsterdam/witness_state.py +++ b/src/ethereum/forks/amsterdam/witness_state.py @@ -212,17 +212,6 @@ def get_code(self, code_hash: Hash32) -> Bytes: return b"" return self._code_db[code_hash] - def account_has_storage(self, address: Address) -> bool: - """ - Check whether an account has any storage. - - Only needed for EIP-7610. - """ - if address not in self._storage_root_cache: - self.get_account_optional(address) - storage_root = self._storage_root_cache.get(address, EMPTY_TRIE_ROOT) - return storage_root != EMPTY_TRIE_ROOT - def compute_state_root(self, block_diff: BlockDiff) -> Root: """ Compute the state root after applying ``block_diff``. diff --git a/tests/json_loader/test_witness_state.py b/tests/json_loader/test_witness_state.py index 23459531271..b76d82c8335 100644 --- a/tests/json_loader/test_witness_state.py +++ b/tests/json_loader/test_witness_state.py @@ -221,22 +221,6 @@ def test_known_code(self) -> None: assert witness_state.get_code(_CODE_HASH) == _CODE -class TestAccountHasStorage: - """Test WitnessState.account_has_storage.""" - - def test_with_storage(self) -> None: - """Returns True for an account that has a non-empty storage trie.""" - witness_state = _make_ws( - {_ADDR1: _acct()}, {_ADDR1: {_SLOT1: U256(1)}} - ) - assert witness_state.account_has_storage(_ADDR1) is True - - def test_without_storage(self) -> None: - """Returns False for an account with an empty storage trie.""" - witness_state = _make_ws({_ADDR1: _acct(balance=100)}) - assert witness_state.account_has_storage(_ADDR1) is False - - class TestComputeStateRoot: """Test WitnessState.compute_state_root_and_trie_changes.""" From e414822d3b9c258eee81d876bcdf0d669ba59235 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 4 Sep 2026 12:20:03 -0300 Subject: [PATCH 264/265] feat(tests): enforce canonicality of input bytes --- src/ethereum/forks/amsterdam/stateless.py | 22 ++++---- .../forks/amsterdam/stateless_guest.py | 5 +- src/ethereum/utils/ssz.py | 7 ++- tests/json_loader/test_ssz.py | 53 +++++++++++++++++++ tests/json_loader/test_stateless_guest.py | 44 +++++++++++++++ 5 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/ethereum/forks/amsterdam/stateless.py b/src/ethereum/forks/amsterdam/stateless.py index e111ee6eea4..64d00d9dfe2 100644 --- a/src/ethereum/forks/amsterdam/stateless.py +++ b/src/ethereum/forks/amsterdam/stateless.py @@ -184,36 +184,36 @@ class StatelessValidationResult(SszContainer): Note: We use return values to denote "public inputs". - If ``successful_validation`` is ``False``, the remaining fields are - only meaningful when the input bytes were decoded successfully. If - decoding failed before a ``StatelessInput`` existed, - ``run_stateless_guest`` returns a failed result with sentinel defaults. + If ``schema_id`` is zero, the guest could not decode the input or + produce a validation result, and the remaining fields have sentinel + defaults. Otherwise, the fields identify the decoded input, including + when execution validation fails. """ new_payload_request_root: Hash32 """ - SSZ root of the decoded ``NewPayloadRequest``. This is zero when - ``run_stateless_guest`` cannot decode the input bytes. + SSZ root of the decoded ``NewPayloadRequest``. This is zero in the + sentinel result. """ successful_validation: bool """ Whether the decoded stateless input validated successfully. ``False`` - means validation failed or the guest input bytes could not be decoded. + means validation failed or the guest could not produce a result. """ chain_id: U64 """ - Chain identifier decoded from the input. This is zero when - ``run_stateless_guest`` cannot decode the input bytes. + Chain identifier decoded from the input. This is zero in the sentinel + result. """ schema_id: U16 """ Exact input schema decoded and executed by the guest. This uses the - invalid-input sentinel when ``run_stateless_guest`` cannot decode the - input bytes. + zero sentinel when the guest cannot decode the input or produce a + validation result. """ diff --git a/src/ethereum/forks/amsterdam/stateless_guest.py b/src/ethereum/forks/amsterdam/stateless_guest.py index f3c0800910e..dbb9481116e 100644 --- a/src/ethereum/forks/amsterdam/stateless_guest.py +++ b/src/ethereum/forks/amsterdam/stateless_guest.py @@ -40,7 +40,7 @@ def deserialize_stateless_input(data: Bytes) -> StatelessInput: def _default_failed_stateless_output() -> StatelessValidationResult: """ - Return the sentinel result used when stateless input cannot be decoded. + Return the sentinel when the guest cannot produce a validation result. """ return StatelessValidationResult( new_payload_request_root=Hash32(b"\0" * 32), @@ -56,10 +56,9 @@ def run_stateless_guest(input_bytes: Bytes) -> Bytes: """ try: stateless_input = deserialize_stateless_input(input_bytes) + stateless_output = verify_stateless_new_payload(stateless_input) except Exception: stateless_output = _default_failed_stateless_output() - else: - stateless_output = verify_stateless_new_payload(stateless_input) output_bytes = serialize_stateless_output(stateless_output) return output_bytes diff --git a/src/ethereum/utils/ssz.py b/src/ethereum/utils/ssz.py index 2ef05555aed..887517d0352 100644 --- a/src/ethereum/utils/ssz.py +++ b/src/ethereum/utils/ssz.py @@ -136,8 +136,13 @@ def hash_tree_root(self) -> bytes: @classmethod def decode_bytes(cls: Type[_C], data: bytes) -> _C: - """Decode SSZ bytes into this dataclass type.""" + """Decode canonical SSZ bytes into this dataclass type.""" view = _container_type(cls).decode_bytes(data) + # The underlying decoder can accept offset gaps and leave bytes + # unread. Require the exact encoding, including nested containers, + # before exposing the decoded value to validation or hashing. + if view.encode_bytes() != data: + raise ValueError("Non-canonical SSZ encoding") return _from_view(cls, view) diff --git a/tests/json_loader/test_ssz.py b/tests/json_loader/test_ssz.py index 750a38895ca..57dc9b7b9a2 100644 --- a/tests/json_loader/test_ssz.py +++ b/tests/json_loader/test_ssz.py @@ -36,6 +36,16 @@ class _Envelope(SszContainer): progressive_items: _ProgressiveItems +@dataclass(frozen=True) +class _ByteLists(SszContainer): + bounded: Annotated[ + Tuple[Annotated[bytes, byte_list(16)], ...], ssz_list(2) + ] + progressive: Annotated[ + Tuple[Annotated[bytes, byte_list(16)], ...], progressive_list() + ] + + def test_nested_containers_roundtrip() -> None: """Restore Python types after standard and progressive SSZ decoding.""" item = _Item(key=Bytes32(b"\x11" * 32), value=U16(3)) @@ -84,3 +94,46 @@ def test_fixed_width_integer_roundtrip() -> None: assert recovered.count == Uint(2**63) assert type(recovered.count) is Uint assert U64(recovered.count) == U64(2**63) + + +@pytest.mark.parametrize("nested", [False, True]) +def test_container_offset_gap_rejected(nested: bool) -> None: + """Reject offset gaps in both standard and progressive containers.""" + original = _Envelope( + count=Uint(0), + payload=b"", + fixed_items=(), + progressive_items=_ProgressiveItems(items=()), + ) + encoded = bytearray(original.encode_bytes()) + offsets: tuple[int, ...] + if nested: + # The final field is a progressive container with one offset. + offsets = (int.from_bytes(encoded[16:20], "little"),) + else: + offsets = (8, 12, 16) + for offset in offsets: + value = int.from_bytes(encoded[offset : offset + 4], "little") + encoded[offset : offset + 4] = (value + 1).to_bytes(4, "little") + encoded.append(0xFF) + + with pytest.raises(ValueError, match="Non-canonical SSZ encoding"): + _Envelope.decode_bytes(bytes(encoded)) + + +@pytest.mark.parametrize("progressive", [False, True]) +@pytest.mark.parametrize("trailing", [b"", b"ignored"]) +def test_nonempty_list_with_zero_first_offset_rejected( + progressive: bool, trailing: bytes +) -> None: + """An empty variable-element list must have a zero-byte encoding.""" + invalid_list = b"\x00" * 4 + trailing + second_offset = 8 if progressive else 8 + len(invalid_list) + encoded = ( + (8).to_bytes(4, "little") + + second_offset.to_bytes(4, "little") + + invalid_list + ) + + with pytest.raises(ValueError): + _ByteLists.decode_bytes(encoded) diff --git a/tests/json_loader/test_stateless_guest.py b/tests/json_loader/test_stateless_guest.py index 7f4e9f707f4..997fe0ec560 100644 --- a/tests/json_loader/test_stateless_guest.py +++ b/tests/json_loader/test_stateless_guest.py @@ -568,6 +568,50 @@ def test_failed_validation(self) -> None: class TestRunStatelessGuest: """Test stateless guest input and output handling.""" + def test_request_commitment_failure_returns_sentinel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Failure to compute the public commitment must fail closed.""" + stateless_input = _make_stateless_input() + input_bytes = serialize_stateless_input(stateless_input) + + def fail_hash_tree_root(self: NewPayloadRequest) -> bytes: + raise RuntimeError("Request commitment unavailable") + + monkeypatch.setattr( + NewPayloadRequest, "hash_tree_root", fail_hash_tree_root + ) + result = deserialize_stateless_output(run_stateless_guest(input_bytes)) + + assert result == StatelessValidationResult( + new_payload_request_root=Hash32(b"\0" * 32), + successful_validation=False, + chain_id=U64(0), + schema_id=U16(0), + ) + + def test_noncanonical_ssz_returns_sentinel_failure(self) -> None: + """Reject shifted container offsets that leave trailing data unread.""" + stateless_input = _make_stateless_input() + encoded = bytearray(serialize_stateless_input(stateless_input)) + # After the schema prefix: request offset, witness offset, chain ID, + # and public-key offset. Shift all three offsets by one byte. + for offset in (2, 6, 18): + value = int.from_bytes(encoded[offset : offset + 4], "little") + encoded[offset : offset + 4] = (value + 1).to_bytes(4, "little") + encoded.append(0xFF) + + result = deserialize_stateless_output( + run_stateless_guest(Bytes(encoded)) + ) + + assert result == StatelessValidationResult( + new_payload_request_root=Hash32(b"\0" * 32), + successful_validation=False, + chain_id=U64(0), + schema_id=U16(0), + ) + def test_invalid_input_bytes_return_failed_validation(self) -> None: """Malformed input returns a failed result with sentinel fields.""" encoded = run_stateless_guest(Bytes(b"")) From d5f94296b26b19581ce36727f5bb3ae47bf895d9 Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 4 Sep 2026 12:20:13 -0300 Subject: [PATCH 265/265] feat(tests): add shifted_ssz_offsets modifier for input byte validation --- .../test_stateless_input_bytes.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py index 6f60327ce36..38b5fb1e3e9 100644 --- a/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py +++ b/tests/amsterdam/eip8025_optional_proofs/test_stateless_input_bytes.py @@ -74,6 +74,16 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: return Bytes(input_bytes[:2] + b"\x01\x00\x00\x00" + input_bytes[6:]) +def shifted_ssz_offsets(input_bytes: Bytes) -> Bytes: + """Shift every top-level offset and leave an extra byte at the end.""" + encoded = bytearray(input_bytes) + for offset in (2, 6, 18): + value = int.from_bytes(encoded[offset : offset + 4], "little") + encoded[offset : offset + 4] = (value + 1).to_bytes(4, "little") + encoded.append(0xFF) + return Bytes(bytes(encoded)) + + @pytest.mark.parametrize( "modifier", [ @@ -88,6 +98,7 @@ def invalid_first_ssz_offset(input_bytes: Bytes) -> Bytes: pytest.param(truncated_ssz_body, id="truncated_ssz_body"), pytest.param(trailing_garbage, id="trailing_garbage"), pytest.param(invalid_first_ssz_offset, id="invalid_first_ssz_offset"), + pytest.param(shifted_ssz_offsets, id="shifted_ssz_offsets"), ], ) def test_invalid_stateless_input_bytes_are_rejected(