From c90d1179f28287c820957539d7264a77ea255510 Mon Sep 17 00:00:00 2001 From: felix Date: Fri, 27 Mar 2026 09:55:35 +0100 Subject: [PATCH 1/5] feat(test-types): improve error messages for invalid tx fields (#2551) Co-authored-by: danceratopz --- .../filler/tests/test_error_messages.py | 70 +++++++++++++++++++ .../test_types/tests/test_types.py | 34 ++++++++- .../test_types/transaction_types.py | 37 +++++++--- 3 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py new file mode 100644 index 00000000000..92798394af0 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_error_messages.py @@ -0,0 +1,70 @@ +"""Regression tests for fill error messages.""" + +import textwrap +from typing import Any + +invalid_fee_payment_test_module = textwrap.dedent( + """\ + from execution_testing import Transaction + + + def test_invalid_fee_payment_error(state_test, pre) -> None: + tx = Transaction( + to=0, + gas_limit=21_000, + sender=pre.fund_eoa(), + gas_price=1, + max_fee_per_gas=2, + ) + state_test(pre=pre, post={}, tx=tx) + """ +) + + +def test_fill_reports_conflicting_fee_fields( + pytester: Any, capsys: Any, pytestconfig: Any +) -> None: + """Test that fill surfaces the conflicting fee fields in failures.""" + tests_dir = pytester.mkdir("tests") + berlin_tests_dir = tests_dir / "berlin" + berlin_tests_dir.mkdir() + fee_test_dir = berlin_tests_dir / "invalid_fee_payment_module" + fee_test_dir.mkdir() + test_module = fee_test_dir / "test_invalid_fee_payment_error.py" + test_module.write_text(invalid_fee_payment_test_module) + + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + + result = pytester.runpytest_subprocess( + "-c", + "pytest-fill.ini", + "--fork", + "Berlin", + "-m", + "state_test", + "--no-html", + "--output=stdout", + str(test_module.relative_to(pytester.path)), + ) + # Suppress the expected inner pytest failure output from the outer test + # When using subprocess directly this would not be necessary + capsys.readouterr() + + assert result.ret != 0, "Fill command was expected to fail" + + output = "\n".join(result.outlines + result.errlines) + expected_message = ( + "cannot mix fee fields in a single tx: " + "'gas_price' (legacy/type-1), 'max_fee_per_gas' (type-2+)" + ) + assert expected_message in output + + error_line = next( + line for line in output.splitlines() if expected_message in line + ) + # show print but only when -s is passed + if pytestconfig.getoption("capture") == "no": + with capsys.disabled(): + print(error_line) 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..41000515298 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 @@ -690,21 +690,38 @@ def test_json_deserialization( pytest.param( {"gas_price": 1, "max_fee_per_gas": 2}, Transaction.InvalidFeePaymentError, - "only one type of fee payment field can be used", + "'gas_price' (legacy/type-1), 'max_fee_per_gas' (type-2+)", id="gas-price-and-max-fee-per-gas", ), pytest.param( {"gas_price": 1, "max_priority_fee_per_gas": 2}, Transaction.InvalidFeePaymentError, - "only one type of fee payment field can be used", + ( + "'gas_price' (legacy/type-1), " + "'max_priority_fee_per_gas' (type-2+)" + ), id="gas-price-and-max-priority-fee-per-gas", ), pytest.param( {"gas_price": 1, "max_fee_per_blob_gas": 2}, Transaction.InvalidFeePaymentError, - "only one type of fee payment field can be used", + "'gas_price' (legacy/type-1), 'max_fee_per_blob_gas' (type-3+)", id="gas-price-and-max-fee-per-blob-gas", ), + pytest.param( + { + "gas_price": 1, + "max_fee_per_gas": 2, + "max_priority_fee_per_gas": 3, + }, + Transaction.InvalidFeePaymentError, + ( + "'gas_price' (legacy/type-1), " + "'max_fee_per_gas' (type-2+), " + "'max_priority_fee_per_gas' (type-2+)" + ), + id="gas-price-and-multiple-dynamic-fee-fields", + ), pytest.param( {"ty": 0, "v": 1, "secret_key": 2}, Transaction.InvalidSignaturePrivateKeyError, @@ -727,6 +744,17 @@ def test_transaction_post_init_invalid_arg_combinations( # noqa: D103 assert expected_exception_substring in str(exc_info.value) +def test_invalid_fee_payment_error_message() -> None: + """Test the exact error message for mixed legacy and type-2+ fee fields.""" + with pytest.raises(Transaction.InvalidFeePaymentError) as exc_info: + Transaction(gas_price=1, max_fee_per_gas=2) + + error_msg = f"\n\t{str(exc_info.value)}" + print(error_msg) + + assert "cannot mix" in error_msg + + @pytest.mark.parametrize( ["tx_args", "expected_attributes_and_values"], [ diff --git a/packages/testing/src/execution_testing/test_types/transaction_types.py b/packages/testing/src/execution_testing/test_types/transaction_types.py index 81276da8621..7153faab6b1 100644 --- a/packages/testing/src/execution_testing/test_types/transaction_types.py +++ b/packages/testing/src/execution_testing/test_types/transaction_types.py @@ -343,11 +343,26 @@ def strip_hash_from_t8n_output(cls, data: Any) -> Any: class InvalidFeePaymentError(Exception): """Transaction described more than one fee payment type.""" + FEE_FIELD_LABELS = { + "gas_price": "legacy/type-1", + "max_fee_per_gas": "type-2+", + "max_priority_fee_per_gas": "type-2+", + "max_fee_per_blob_gas": "type-3+", + } + + def __init__(self, *conflicting_fields: str) -> None: + """Store the conflicting fee fields used in the transaction.""" + self.conflicting_fields = conflicting_fields + def __str__(self) -> str: """Print exception string.""" - return ( - "only one type of fee payment field can be used in a single tx" + if not self.conflicting_fields: + return "cannot mix fee fields in a single tx" + labels = ", ".join( + f"'{f}' ({self.FEE_FIELD_LABELS[f]})" + for f in self.conflicting_fields ) + return f"cannot mix fee fields in a single tx: {labels}" class InvalidSignaturePrivateKeyError(Exception): """ @@ -363,12 +378,18 @@ def model_post_init(self, __context: Any) -> None: """Ensure transaction has no conflicting properties.""" super().model_post_init(__context) - if self.gas_price is not None and ( - self.max_fee_per_gas is not None - or self.max_priority_fee_per_gas is not None - or self.max_fee_per_blob_gas is not None - ): - raise Transaction.InvalidFeePaymentError() + conflicting_fee_fields = [ + field_name + for field_name in ( + "gas_price", + "max_fee_per_gas", + "max_priority_fee_per_gas", + "max_fee_per_blob_gas", + ) + if getattr(self, field_name) is not None + ] + if self.gas_price is not None and len(conflicting_fee_fields) > 1: + raise Transaction.InvalidFeePaymentError(*conflicting_fee_fields) if "ty" not in self.model_fields_set: # Try to deduce transaction type from included fields From 3bfb78ea31bd4915f6b66661493cfd8969a22c32 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Fri, 27 Mar 2026 12:36:16 +0100 Subject: [PATCH 2/5] feat(tooling,docs): add pytester best practices skill and docs (#2573) --- .claude/commands/pytester.md | 36 +++++++++++++++++++ CLAUDE.md | 2 ++ .../getting_started/code_standards_details.md | 18 ++++------ 3 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 .claude/commands/pytester.md diff --git a/.claude/commands/pytester.md b/.claude/commands/pytester.md new file mode 100644 index 00000000000..a1d23d78303 --- /dev/null +++ b/.claude/commands/pytester.md @@ -0,0 +1,36 @@ +# Pytester + +Guide for pytester-based plugin/CLI tests. Run before writing or modifying these tests. + +## Which execution mode to use + +- **`runpytest()`** — default. In-process, fast, full `RunResult` API (`assert_outcomes()`, `fnmatch_lines()`). +- **`runpytest_subprocess()`** — use only when in-process causes state leakage (Pydantic cache pollution, global mutation in `pytest_configure`). Same `RunResult` API. +- **Raw `subprocess.run()`** — never use alongside pytester. Use `runpytest_subprocess()` instead. + +Subprocess isolation masks bugs rather than fixing them. Prefer fixing the root cause and use subprocess as defense-in-depth. + +## Expected inner failures + +`runpytest_subprocess()` replays inner output to outer stdout (by design). Suppress with `capsys.readouterr()`: + +```python +def test_expected_failure(pytester: Any, capsys: Any, pytestconfig: Any) -> None: + result = pytester.runpytest_subprocess(...) + capsys.readouterr() # suppress inner failure bleed + assert result.ret != 0 + output = "\n".join(result.outlines + result.errlines) + # conditional print for -s debugging + if pytestconfig.getoption("capture") == "no": + with capsys.disabled(): + print(output) +``` + +## RunResult API + +Prefer `assert_outcomes()` and `fnmatch_lines()` over manual `any(... in line ...)` — better failure messages. + +- `result.ret` — exit code +- `result.outlines` / `result.errlines` — line lists +- `result.assert_outcomes(passed=N, failed=N)` +- `result.stdout.fnmatch_lines(["*pattern*"])` diff --git a/CLAUDE.md b/CLAUDE.md index eb3a2e04fd0..1e0d5b85923 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ When reviewing PRs that implement or test EIPs: ## When to Use Skills - Writing or modifying tests → run `/write-test` first +- Writing or modifying pytester-based plugin tests → run `/pytester` first - Filling test fixtures → run `/fill-tests` first - Implementing an EIP or modifying fork code in `src/` → run `/implement-eip` first - Modifying GitHub Actions workflows → run `/edit-workflow` first @@ -53,6 +54,7 @@ When reviewing PRs that implement or test EIPs: ## Available Skills - `/write-test` — test writing patterns, fixtures, markers, bytecode helpers +- `/pytester` — pytester execution modes, isolation, output handling for plugin tests - `/fill-tests` — `fill` CLI reference, flags, debugging, benchmark tests - `/implement-eip` — fork structure, import rules, adding opcodes/precompiles/tx types - `/edit-workflow` — GitHub Actions conventions and version pinning diff --git a/docs/getting_started/code_standards_details.md b/docs/getting_started/code_standards_details.md index 50824deb145..44d1918416a 100644 --- a/docs/getting_started/code_standards_details.md +++ b/docs/getting_started/code_standards_details.md @@ -91,23 +91,17 @@ uvx pre-commit install For more information, see [Pre-commit Hooks Documentation](../dev/precommit.md). -## Formatting and Line Length +## Testing Framework Plugins with Pytester -The Python code in @ethereum/execution-spec-tests is formatted with `ruff` with a line length of 100 characters. +Use pytest's `pytester` fixture when writing tests for our pytest plugins and CLI commands. -### Ignoring Bulk Change Commits +`runpytest()` is the default. It runs the inner session in-process, is fast, and gives access to helpers like `assert_outcomes()` and `fnmatch_lines()`. -The maximum line length was changed from 80 to 100 in Q2 2023. To ignore this bulk change commit in git blame output, use the `.git-blame-ignore-revs` file: +`runpytest_subprocess()` runs the inner session in a separate process. Use it only when in-process mode causes state leakage (e.g., Pydantic `ModelMetaclass` cache pollution or global mutation in `pytest_configure`). Subprocess isolation masks these bugs rather than fixing them, so prefer fixing the root cause and use subprocess mode as defense-in-depth. -```console -git blame --ignore-revs-file .git-blame-ignore-revs docs/gen_test_case_reference.py -``` - -To use the revs file persistently with `git blame`: +Don't use raw `subprocess.run()` in pytester-based tests. If you need process isolation, use `runpytest_subprocess()`. -```console -git config blame.ignoreRevsFile .git-blame-ignore-revs -``` +Both methods return a `RunResult` with `.ret`, `.outlines`, `.errlines`, `assert_outcomes()`, and `fnmatch_lines()`. When the inner test is expected to fail, use `capsys.readouterr()` after `runpytest_subprocess()` to suppress the inner failure output that pytester replays to stdout. ## Building and Verifying Docs Locally From dbf15aab02cf04c81bcc34ec28d8b22c4a097441 Mon Sep 17 00:00:00 2001 From: kevaundray Date: Fri, 27 Mar 2026 13:06:00 +0000 Subject: [PATCH 3/5] refactor(test-vm): optimize `Bytecode.__mul__` and `__add__` performance (#2572) Co-authored-by: danceratopz --- .../src/execution_testing/vm/bytecode.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/vm/bytecode.py b/packages/testing/src/execution_testing/vm/bytecode.py index e8df88e0dd6..74b2ede512f 100644 --- a/packages/testing/src/execution_testing/vm/bytecode.py +++ b/packages/testing/src/execution_testing/vm/bytecode.py @@ -223,7 +223,7 @@ def __add__(self, other: "Bytecode | bytes | int | None") -> "Bytecode": ) return Bytecode( - bytes(self) + bytes(other), + self._bytes_ + other._bytes_, popped_stack_items=c_pop, pushed_stack_items=c_push, min_stack_height=c_min, @@ -234,7 +234,7 @@ def __add__(self, other: "Bytecode | bytes | int | None") -> "Bytecode": def __radd__(self, other: "Bytecode | int | None") -> "Bytecode": """ - Concatenate the opcode byte representation with another bytes object. + Repeat the bytecode a given number of times. """ if other is None or (isinstance(other, int) and other == 0): # Edge case for sum() function @@ -252,10 +252,32 @@ def __mul__(self, other: int) -> "Bytecode": raise ValueError("Cannot multiply by a negative number") if other == 0: return Bytecode() - output = self - for _ in range(other - 1): - output += self - return output + if other == 1: + return Bytecode(self) + + result_bytes = self._bytes_ * other + + a_pop = self.popped_stack_items + a_push = self.pushed_stack_items + a_min = self.min_stack_height + a_max = self.max_stack_height + net = a_push - a_pop + repeats = other - 1 + + c_pop = a_pop + max(0, -net) * repeats + c_push = a_push + max(0, net) * repeats + c_min = a_min + max(0, -net) * repeats + c_max = a_max + abs(net) * repeats + + return Bytecode( + result_bytes, + popped_stack_items=c_pop, + pushed_stack_items=c_push, + min_stack_height=c_min, + max_stack_height=c_max, + terminating=self.terminating, + opcode_list=self.opcode_list * other, + ) def hex(self) -> str: """ From 65a3009d829b92ee750d1831bcba0739b9037865 Mon Sep 17 00:00:00 2001 From: spencer Date: Fri, 27 Mar 2026 13:35:38 +0000 Subject: [PATCH 4/5] chore(test-client-clis): update geth exception mapper for BAL devnet-3 (#2575) Add regex mappings for new geth error strings from bal-devnet-3: - INVALID_BAL_MISSING_ACCOUNT: add "invalid block access list:" for the parallel processor's mismatch error format - BLOCK_ACCESS_LIST_GAS_LIMIT_EXCEEDED: new entry for EIP-7928 ValidateGasLimit check - GAS_USED_OVERFLOW: "gas limit reached" from the parallel processor's 2D gas check (matches EELS GasUsedExceedsLimitError) - INTRINSIC_GAS_TOO_LOW: "insufficient gas for floor data gas cost" matches EELS InsufficientTransactionGasError which uses one error type for both intrinsic gas and calldata floor failures --- .../src/execution_testing/client_clis/clis/geth.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/client_clis/clis/geth.py b/packages/testing/src/execution_testing/client_clis/clis/geth.py index c41aff697d2..7fdedd348d6 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/geth.py +++ b/packages/testing/src/execution_testing/client_clis/clis/geth.py @@ -164,13 +164,21 @@ class GethExceptionMapper(ExceptionMapper): BlockException.INVALID_BAL_HASH: (r"invalid block access list:"), BlockException.INVALID_BAL_MISSING_ACCOUNT: ( r"computed state diff contained mutated accounts " - r"which weren't reported in BAL" + r"which weren't reported in BAL|" + r"invalid block access list:" ), BlockException.INVALID_BLOCK_ACCESS_LIST: ( r"difference between computed state diff and " r"BAL entry for account|invalid block access list:" ), BlockException.INCORRECT_BLOCK_FORMAT: (r"invalid block access list:"), + BlockException.BLOCK_ACCESS_LIST_GAS_LIMIT_EXCEEDED: ( + r"block access list exceeds gas limit" + ), + BlockException.GAS_USED_OVERFLOW: (r"gas limit reached"), + TransactionException.INTRINSIC_GAS_TOO_LOW: ( + r"insufficient gas for floor data gas cost" + ), } From c1d8f4215d470d237080f26f3833926e011e0ebf Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Fri, 27 Mar 2026 14:50:19 +0100 Subject: [PATCH 5/5] feat(test-execute): Add execute remote unit tests (#2485) * feat(test-execute): Add unit tests * fix: fails * feat: add test * fix: rebase issues --------- Co-authored-by: Felix H --- .github/workflows/hive-execute.yaml | 97 ++ .../execute/rpc/chain_builder_eth_rpc.py | 6 +- .../plugins/execute/rpc/remote.py | 15 +- .../execute/tests/test_execute_remote.py | 866 ++++++++++++++++++ 4 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/hive-execute.yaml create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py diff --git a/.github/workflows/hive-execute.yaml b/.github/workflows/hive-execute.yaml new file mode 100644 index 00000000000..b4508a5423e --- /dev/null +++ b/.github/workflows/hive-execute.yaml @@ -0,0 +1,97 @@ +name: Hive Execute E2E + +on: + push: + branches: + - "forks/**" + paths: + - "packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/**" + - "packages/testing/src/execution_testing/rpc/**" + - ".github/workflows/hive-execute.yaml" + - ".github/actions/start-hive-dev/**" + pull_request: + paths: + - "packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/**" + - "packages/testing/src/execution_testing/rpc/**" + - ".github/workflows/hive-execute.yaml" + - ".github/actions/start-hive-dev/**" + workflow_dispatch: + +concurrency: + group: hive-execute-${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.ref_name != github.event.repository.default_branch }} + +jobs: + cache-docker-images: + name: Cache Docker Images + runs-on: [self-hosted-ghr, size-l-x64] + steps: + - name: Checkout execution-specs + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Cache Docker images + uses: ./.github/actions/cache-docker-images + with: + images: "docker.io/ethereum/client-go:latest docker.io/alpine:latest docker.io/library/golang:1-alpine" + + test-execute-remote: + name: Execute Remote E2E + needs: cache-docker-images + runs-on: [self-hosted-ghr, size-l-x64] + steps: + - name: Checkout execution-specs + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + path: execution-specs + submodules: recursive + + - name: Checkout Hive + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: ethereum/hive + ref: master + path: hive + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: ">=1.24" + cache-dependency-path: hive/go.sum + + - name: Install uv and python + uses: ./execution-specs/.github/actions/setup-uv + with: + cache-dependency-glob: "execution-specs/uv.lock" + + - name: Setup environment + uses: ./execution-specs/.github/actions/setup-env + + - name: Load cached Docker images + uses: ./execution-specs/.github/actions/load-docker-images + + - name: Build Hive + run: | + cd hive + go build . + + - name: Start Hive in dev mode + id: start-hive + uses: ./execution-specs/.github/actions/start-hive-dev + with: + clients: go-ethereum + client-file: execution-specs/.github/configs/hive/latest.yaml + hive-path: hive + timeout: "120" + + - name: Run execute remote E2E tests + working-directory: execution-specs + env: + HIVE_SIMULATOR: ${{ steps.start-hive.outputs.hive-url }} + run: | + uv sync --all-extras + cd packages/testing + uv run pytest \ + --basetemp="${{ runner.temp }}/pytest" \ + -v \ + -p execution_testing.cli.pytest_commands.plugins.concurrency \ + src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py index f89ee564e8e..11373581f8c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/chain_builder_eth_rpc.py @@ -7,6 +7,7 @@ from contextlib import AbstractContextManager from pathlib import Path from typing import Any, List, Sequence +from urllib.parse import urlparse from filelock import FileLock @@ -62,14 +63,15 @@ def __init__( ) self.fork = fork self.engine_rpc = engine_rpc + parsed = urlparse(rpc_endpoint) self.block_building_lock = FileLock( - session_temp_folder / "chain_builder_fcu.lock" + session_temp_folder / f"chain_builder_fcu_{parsed.hostname}.lock" ) self.get_payload_wait_time = get_payload_wait_time self.testing_rpc = testing_rpc # Send initial forkchoice updated only if we are the first worker - base_name = "eth_rpc_forkchoice_updated" + base_name = f"eth_rpc_forkchoice_updated_{parsed.hostname}" base_file = session_temp_folder / base_name base_error_file = session_temp_folder / f"{base_name}.err" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py index 662ebe72307..8b7b7d75847 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote.py @@ -92,6 +92,16 @@ def pytest_addoption(parser: pytest.Parser) -> None: "only the JWT secret as a hex string." ), ) + engine_rpc_group.addoption( + "--session-sync-folder", + required=False, + action="store", + default=None, + dest="session_sync_folder", + help=( + "Folder used to sync multiple instances of the execute command." + ), + ) def pytest_configure(config: pytest.Config) -> None: @@ -198,11 +208,14 @@ def eth_rpc( testing_rpc = None if use_testing_build_block: testing_rpc = TestingRPC(rpc_endpoint) + session_sync_folder = request.config.getoption("session_sync_folder") return ChainBuilderEthRPC( rpc_endpoint=rpc_endpoint, fork=session_fork, engine_rpc=engine_rpc, - session_temp_folder=session_temp_folder, + session_temp_folder=Path(session_sync_folder) + if session_sync_folder is not None + else session_temp_folder, get_payload_wait_time=get_payload_wait_time, transaction_wait_timeout=tx_wait_timeout, max_transactions_per_batch=max_transactions_per_batch, diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py new file mode 100644 index 00000000000..43021ed177a --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/tests/test_execute_remote.py @@ -0,0 +1,866 @@ +""" +End-to-end tests for the execute remote command. + +Each test uses pytester to run an inline test module through the +pytest-execute.ini plugin stack, pointing at a real execution client +spawned via hive. + +Requires HIVE_SIMULATOR to be set (e.g. start hive in --dev mode). +""" + +import contextlib +import hashlib +import io +import json +import os +import random +import textwrap +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, Generator, List, cast + +import pytest +import requests +from filelock import FileLock, Timeout +from hive.simulation import Simulation +from hive.testing import HiveTest, HiveTestResult, HiveTestSuite + +from execution_testing.base_types import ( + Account, + Address, + EmptyOmmersRoot, + EmptyTrieRoot, + Hash, + Number, + to_json, +) +from execution_testing.cli.pytest_commands.plugins.consume.simulators.helpers.ruleset import ( # noqa: E501 + ruleset, +) +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.forks import Osaka +from execution_testing.rpc import EngineRPC, EthRPC +from execution_testing.test_types import ( + DETERMINISTIC_FACTORY_ADDRESS, + DETERMINISTIC_FACTORY_BYTECODE, + EOA, + Alloc, + ChainConfig, + Environment, + Requests, + Transaction, + Withdrawal, + compute_deterministic_create2_address, +) +from execution_testing.tools import Initcode +from execution_testing.vm import Op + +from ..pre_alloc import AddressStubs +from ..rpc.chain_builder_eth_rpc import ChainBuilderEthRPC, TestingRPC + +# Number of seed keys to pre-fund (one per test case) +SEED_KEY_COUNT = 100 +SEED_KEY_BALANCE = 10**26 + +# The fork to test with +TEST_FORK = Osaka + + +pytestmark = pytest.mark.skipif( + os.environ.get("HIVE_SIMULATOR") is None, + reason="HIVE_SIMULATOR not set; start hive in --dev mode to run", +) + + +@pytest.fixture(scope="module") +def seed_keys(testrun_uid: str) -> List[EOA]: + """Generate seed keys for each test case.""" + start_index = int.from_bytes( + hashlib.sha256(testrun_uid.encode()).digest(), byteorder="big" + ) + return [ + EOA(key=i) for i in range(start_index, start_index + SEED_KEY_COUNT) + ] + + +def _build_client_genesis(seed_keys: List[EOA]) -> dict: + """Build a valid client genesis for the Hive-backed E2E tests.""" + alloc_dict: Dict = { + DETERMINISTIC_FACTORY_ADDRESS: Account( + nonce=1, code=DETERMINISTIC_FACTORY_BYTECODE + ), + } + for key in seed_keys: + alloc_dict[key] = Account(balance=SEED_KEY_BALANCE) + + env = Environment().set_fork_requirements(TEST_FORK) + assert env.withdrawals is None or len(env.withdrawals) == 0, ( + "withdrawals must be empty at genesis" + ) + assert ( + env.parent_beacon_block_root is None + or env.parent_beacon_block_root == Hash(0) + ), "parent_beacon_block_root must be empty at genesis" + + genesis_alloc = Alloc.merge( + Alloc.model_validate(TEST_FORK.pre_allocation_blockchain()), + Alloc(alloc_dict), + ) + if empty_accounts := genesis_alloc.empty_accounts(): + raise Exception(f"Empty accounts in pre state: {empty_accounts}") + + block_number = 0 + timestamp = 1 + genesis_header = FixtureHeader( + parent_hash=0, + ommers_hash=EmptyOmmersRoot, + fee_recipient=0, + state_root=genesis_alloc.state_root(), + transactions_trie=EmptyTrieRoot, + receipts_root=EmptyTrieRoot, + logs_bloom=0, + difficulty=0x20000 if env.difficulty is None else env.difficulty, + number=block_number, + gas_limit=env.gas_limit, + gas_used=0, + timestamp=timestamp, + extra_data=b"\x00", + prev_randao=0, + nonce=0, + base_fee_per_gas=env.base_fee_per_gas, + blob_gas_used=env.blob_gas_used, + excess_blob_gas=env.excess_blob_gas, + withdrawals_root=( + Withdrawal.list_root(env.withdrawals) + if env.withdrawals is not None + else None + ), + parent_beacon_block_root=env.parent_beacon_block_root, + requests_hash=Requests() + if TEST_FORK.header_requests_required() + else None, + block_access_list_hash=Hash(EmptyTrieRoot) + if TEST_FORK.header_bal_hash_required() + else None, + ) + + client_genesis = to_json(genesis_header) + alloc = to_json(genesis_alloc) + client_genesis["alloc"] = { + k.replace("0x", ""): v for k, v in alloc.items() + } + return client_genesis + + +@pytest.fixture(scope="module") +def hive_client_ip( + seed_keys: List[EOA], + session_temp_folder: Path, +) -> Generator[str, None, None]: + """ + Start a hive execution client for the duration of the module. + + Only one process initializes the hive suite and client; the rest + read the client information from a shared file. Each process + registers itself in a counter file, and the last process to + finish tears down the hive resources. + """ + hive_info_name = "hive_e2e_client_info" + hive_info_file = session_temp_folder / hive_info_name + hive_info_lock = session_temp_folder / f"{hive_info_name}.lock" + + hive_users_name = "hive_e2e_client_users" + hive_users_file = session_temp_folder / hive_users_name + hive_users_lock = session_temp_folder / f"{hive_users_name}.lock" + + with FileLock(hive_info_lock): + if hive_info_file.exists(): + with hive_info_file.open("r") as f: + hive_info = json.load(f) + else: + url = os.environ["HIVE_SIMULATOR"] + simulator = Simulation(url=url) + client_type = simulator.client_types()[0] + client_genesis = _build_client_genesis(seed_keys) + + assert TEST_FORK in ruleset, ( + f"fork '{TEST_FORK}' missing in hive ruleset" + ) + hive_environment = { + "HIVE_CHAIN_ID": str(ChainConfig().chain_id), + "HIVE_FORK_DAO_VOTE": "1", + "HIVE_NODETYPE": "full", + **{k: f"{v:d}" for k, v in ruleset[TEST_FORK].items()}, + } + suite: HiveTestSuite = simulator.start_suite( + name="eels/execute-remote-e2e", + description=("E2E tests for execute remote command"), + ) + test: HiveTest = suite.start_test( + name="execute-remote-e2e", + description="E2E test client", + ) + + genesis_json = json.dumps(client_genesis) + genesis_bytes = genesis_json.encode("utf-8") + buffered = io.BufferedReader( + cast(io.RawIOBase, io.BytesIO(genesis_bytes)) + ) + files = {"/genesis.json": buffered} + + client = test.start_client( + client_type=client_type, + environment=hive_environment, + files=files, + ) + assert client is not None, ( + f"Failed to start hive client ({client_type.name})" + ) + + hive_info = { + "client_ip": f"{client.ip}", + "client_url": client.url, + "client_id": client.id, + "suite": asdict(suite), + "test": asdict(test), + } + with hive_info_file.open("w") as f: + json.dump(hive_info, f) + + client_ip = hive_info["client_ip"] + + # Register this process as a user of the hive client. + with FileLock(hive_users_lock): + if hive_users_file.exists(): + with hive_users_file.open("r") as f: + users = int(f.read()) + else: + users = 0 + users += 1 + with hive_users_file.open("w") as f: + f.write(str(users)) + + yield client_ip + + # Deregister and tear down if this is the last user. + with FileLock(hive_users_lock): + with hive_users_file.open("r") as f: + users = int(f.read()) + users -= 1 + with hive_users_file.open("w") as f: + f.write(str(users)) + if users == 0: + with hive_info_file.open("r") as f: + hive_info = json.load(f) + + # Stop the client. + stop_url = f"{hive_info['client_url']}/{hive_info['client_id']}" + requests.delete(stop_url).raise_for_status() + + # End the test. + test_obj = HiveTest(**hive_info["test"]) + test_obj.end( + result=HiveTestResult( + test_pass=True, + details="E2E test completed", + ) + ) + + # End the suite. + suite_obj = HiveTestSuite(**hive_info["suite"]) + suite_obj.end() + + # Clean up coordination files. + hive_info_file.unlink(missing_ok=True) + hive_users_file.unlink(missing_ok=True) + + +@pytest.fixture(scope="module") +def rpc_endpoint(hive_client_ip: str) -> str: + """Return the JSON-RPC endpoint of the hive client.""" + return f"http://{hive_client_ip}:8545" + + +@pytest.fixture(scope="module") +def engine_endpoint(hive_client_ip: str) -> str: + """Return the engine API endpoint of the hive client.""" + return f"http://{hive_client_ip}:8551" + + +@pytest.fixture(scope="function") +def chain_builder_eth_rpc( + rpc_endpoint: str, + engine_endpoint: str, + session_temp_folder: Path, +) -> EthRPC: + """ + Return the chain builder ETH RPC to use for some tests that send + transactions before the actual execute test starts. + """ + return ChainBuilderEthRPC( + rpc_endpoint=rpc_endpoint, + fork=TEST_FORK, + engine_rpc=EngineRPC(engine_endpoint), + session_temp_folder=session_temp_folder, + get_payload_wait_time=1, + transaction_wait_timeout=20, + max_transactions_per_batch=10, + testing_rpc=TestingRPC(rpc_endpoint), + ) + + +class KeysPool: + """ + A pool of keys safe for use across multiple processes. + + Each key is backed by a lock file inside `lock_dir`. Calling `pop()` + returns a context manager that: + 1. Blocks until a key is free (no other process holds it). + 2. Yields the EOA. + 3. Releases the lock automatically on exit. + """ + + def __init__(self, *, keys: List[EOA], session_temp_folder: Path) -> None: + if not keys: + raise ValueError("Key list must not be empty.") + + self._lock_dir = session_temp_folder / "key_locks" + self._lock_dir.mkdir(parents=True, exist_ok=True) + self._pool: Dict[EOA, FileLock] = { + key: FileLock(self._lock_dir / f"{key}") for key in keys + } + + @contextlib.contextmanager + def pop( + self, poll_interval: float = 0.5, timeout: float | None = None + ) -> Generator[EOA, None, None]: + """ + Acquire an available key and yield it as a context manager. + """ + deadline = None if timeout is None else time.monotonic() + timeout + + while True: + for key, lock in self._pool.items(): + try: + lock.acquire(timeout=0) + except Timeout: + continue + + try: + yield key + finally: + lock.release() + return + + if deadline is not None and time.monotonic() >= deadline: + raise Timeout( + f"No EOA key became available within {timeout}s " + f"(pool size: {len(self._pool)})." + ) + time.sleep(poll_interval) + + +@pytest.fixture(scope="module", autouse=True) +def keys_pool(session_temp_folder: Path, seed_keys: List[EOA]) -> KeysPool: + """ + Write all starting set of keys to the session coordinator folder. + """ + return KeysPool(keys=seed_keys, session_temp_folder=session_temp_folder) + + +@pytest.fixture() +def test_seed_key( + keys_pool: KeysPool, chain_builder_eth_rpc: EthRPC +) -> Generator[EOA, None, None]: + """Return the seed key for the current test.""" + with keys_pool.pop() as key: + current_nonce = chain_builder_eth_rpc.get_transaction_count(key) + key.nonce = Number(current_nonce) + yield key + + +@pytest.fixture(scope="session") +def fork_name() -> str: + """Return the fork name string for CLI args.""" + return str(TEST_FORK) + + +@dataclass(kw_only=True) +class ExecuteRunner: + """Formatted runner of a test.""" + + testdir: pytest.Testdir + monkeypatch: pytest.MonkeyPatch + session_temp_folder: Path + rpc_endpoint: str + engine_endpoint: str + test_seed_key: EOA + fork_name: str + + def run_assert( + self, + *, + test_method: str, + stubs: AddressStubs | None = None, + passed: int | None = None, + failed: int | None = None, + errors: int | None = None, + ) -> None: + """ + Run an inline test module through the execute remote plugin stack. + + Write the test module, copy the ini file, and invoke pytester. + """ + tests_dir = self.testdir.mkdir("tests") + test_file = tests_dir.join("test_module.py") + test_module = ( + "from execution_testing import " + + "Account, Address, Storage, Transaction, Op\n" + + textwrap.dedent(test_method) + ) + test_file.write(textwrap.dedent(test_module)) + + self.testdir.copy_example( + name="src/execution_testing/cli/pytest_commands/" + "pytest_ini_files/pytest-execute.ini" + ) + + args = [ + "-c", + "pytest-execute.ini", + "-v", + "--fork", + self.fork_name, + "--rpc-endpoint", + self.rpc_endpoint, + "--engine-endpoint", + self.engine_endpoint, + "--use-testing-build-block", + "--rpc-seed-key", + str(self.test_seed_key.key), + "--rpc-chain-id", + str(ChainConfig().chain_id), + "--session-sync-folder", + str(self.session_temp_folder), + "--engine-jwt-secret", + "secretsecretsecretsecretsecretse", + "--no-html", + ] + if stubs: + args.extend(["--address-stubs", stubs.model_dump_json(indent=0)]) + + if all(x is None for x in (passed, failed, errors)): + passed, failed, errors = (1, 0, 0) + else: + passed, failed, errors = ( + passed if passed is not None else 0, + failed if failed is not None else 0, + errors if errors is not None else 0, + ) + self.monkeypatch.setenv("PYTEST_XDIST_WORKER_COUNT", "1") + self.testdir.runpytest(*args).assert_outcomes( + passed=passed, failed=failed, errors=errors + ) + + +@pytest.fixture(scope="function") +def execute_runner( + testdir: pytest.Testdir, + monkeypatch: pytest.MonkeyPatch, + session_temp_folder: Path, + rpc_endpoint: str, + engine_endpoint: str, + test_seed_key: EOA, + fork_name: str, +) -> ExecuteRunner: + """Return the runner of the test.""" + return ExecuteRunner( + testdir=testdir, + monkeypatch=monkeypatch, + session_temp_folder=session_temp_folder, + rpc_endpoint=rpc_endpoint, + engine_endpoint=engine_endpoint, + test_seed_key=test_seed_key, + fork_name=fork_name, + ) + + +@dataclass(kw_only=True) +class ContractDeployer: + """Formatted runner of a test.""" + + chain_builder_eth_rpc: ChainBuilderEthRPC + test_seed_key: EOA + salt: int + + def deploy(self, code: str) -> Address: + """Deploy a contract.""" + bytecode = eval(code, {"Op": Op}) + initcode = Initcode(deploy_code=bytecode) + tx = Transaction( + sender=self.test_seed_key, + to=None, + gas_limit=1_000_000, + data=initcode, + ) + self.chain_builder_eth_rpc.send_wait_transactions([tx]) + contract_address = tx.created_contract + chain_code = self.chain_builder_eth_rpc.get_code(contract_address) + assert chain_code == bytecode + return contract_address + + def deterministic_deploy(self, code: str) -> Address: + """Deploy a contract to a deterministic address.""" + bytecode = eval(code, {"Op": Op}) + initcode = Initcode(deploy_code=bytecode) + deploy_address = compute_deterministic_create2_address( + salt=self.salt, initcode=initcode, fork=TEST_FORK + ) + chain_code = self.chain_builder_eth_rpc.get_code(deploy_address) + if chain_code != b"": + raise Exception(f"Contract already deployed: {deploy_address}") + tx = Transaction( + sender=self.test_seed_key, + to=DETERMINISTIC_FACTORY_ADDRESS, + gas_limit=1_000_000, + data=Hash(self.salt) + bytes(initcode), + ) + self.chain_builder_eth_rpc.send_wait_transactions([tx]) + chain_code = self.chain_builder_eth_rpc.get_code(deploy_address) + assert chain_code == bytecode + return deploy_address + + +@pytest.fixture(scope="function") +def contract_deployer( + chain_builder_eth_rpc: ChainBuilderEthRPC, + test_seed_key: EOA, +) -> ContractDeployer: + """ + Contract deployer for the current test. + + Takes a string bytecode to be passed to `eval` (for convenience) + and returns the address. + """ + return ContractDeployer( + chain_builder_eth_rpc=chain_builder_eth_rpc, + test_seed_key=test_seed_key, + salt=random.randint(0, 2**256), + ) + + +def test_simple_state_test(execute_runner: ExecuteRunner) -> None: + """Execute a minimal state test against a live client.""" + test_method = """\ + def test_simple(state_test, pre) -> None: + sender = pre.fund_eoa() + state_test( + pre=pre, + post={{}}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + """.format() + + execute_runner.run_assert(test_method=test_method) + + +def test_deploy_contract(execute_runner: ExecuteRunner) -> None: + """Execute a test that deploys a contract and checks storage.""" + test_method = """\ + def test_deploy(state_test, pre) -> None: + code = Op.SSTORE(0, 1) + Op.STOP + contract = pre.deploy_contract(code) + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=contract, + gas_limit=100_000, + ) + state_test( + pre=pre, + post={{ + contract: Account( + storage=Storage({{0: 1}}), + ), + }}, + tx=tx, + ) + """.format() + + execute_runner.run_assert(test_method=test_method) + + +@pytest.mark.parametrize( + "already_deployed", + [ + pytest.param(True, id="already_deployed"), + pytest.param(False, id="not_deployed"), + ], +) +def test_deterministic_deploy_contract( + execute_runner: ExecuteRunner, + contract_deployer: ContractDeployer, + test_seed_key: EOA, + already_deployed: bool, + chain_builder_eth_rpc: ChainBuilderEthRPC, +) -> None: + """ + Execute a test that deploys a contract to a deterministic address + and checks storage. + """ + # We have to use a different salt because otherwise the multiple + # parametrization makes different tests have the same contract already + # deployed. + code = "Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)) + Op.STOP" + expected_value = 1 + if already_deployed: + deploy_address = contract_deployer.deterministic_deploy(code) + # Send tx to increase the storage + tx = Transaction( + sender=test_seed_key, + to=deploy_address, + gas_limit=100_000, + ) + chain_builder_eth_rpc.send_wait_transactions([tx]) + expected_value = 2 + test_method = """\ + def test_deploy(state_test, pre) -> None: + code = {code} + contract = pre.deterministic_deploy_contract( + deploy_code=code, salt={salt} + ) + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=contract, + gas_limit=100_000, + ) + state_test( + pre=pre, + post={{ + contract: Account( + storage=Storage({{0: {expected_value}}}), + ), + }}, + tx=tx, + ) + """.format( + code=code, + expected_value=expected_value, + salt=contract_deployer.salt, + ) + + execute_runner.run_assert(test_method=test_method) + + +def test_multiple_tests_single_module(execute_runner: ExecuteRunner) -> None: + """Execute multiple tests in a single module.""" + test_method = """\ + def test_one(state_test, pre) -> None: + sender = pre.fund_eoa() + state_test( + pre=pre, + post={{}}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + + def test_two(state_test, pre) -> None: + sender = pre.fund_eoa() + state_test( + pre=pre, + post={{}}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + """.format() + + execute_runner.run_assert(test_method=test_method, passed=2) + + +def test_fund_address(execute_runner: ExecuteRunner) -> None: + """Execute a test that uses fund_address for pre-existing addresses.""" + funded_address = "0x1234567890ABCDEF1234567890ABCDEF12345678" + test_method = """\ + def test_fund(state_test, pre) -> None: + sender = pre.fund_eoa() + funded_address = Address({funded_address}) + pre.fund_address(funded_address, 10**18) + state_test( + pre=pre, + post={{ + funded_address: Account( + balance=10**18, + ), + }}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + """.format(funded_address=funded_address) + + execute_runner.run_assert(test_method=test_method) + + +def test_fail_fund_address_twice(execute_runner: ExecuteRunner) -> None: + """ + Execute a test that uses fund_address twice on the same account + for pre-existing addresses. + """ + funded_address = "0x1234567890ABCDEF1234567890ABCDEF12345678" + test_method = """\ + def test_fund(state_test, pre) -> None: + sender = pre.fund_eoa() + funded_address = Address({funded_address}) + pre.fund_address(funded_address, 10**18) + pre.fund_address(funded_address, 10**18) + state_test( + pre=pre, + post={{ + funded_address: Account( + balance=10**18, + ), + }}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + """.format(funded_address=funded_address) + + execute_runner.run_assert(test_method=test_method, failed=1) + + +@pytest.mark.parametrize( + "account_type", + [ + "funded_eoa", + "deployed_contract", + "stubbed_contract", + "deterministic_contract", + "deterministic_contract_already_deployed", + ], +) +def test_fail_fund_account_in_alloc( + execute_runner: ExecuteRunner, + account_type: str, + contract_deployer: ContractDeployer, +) -> None: + """Execute a test that uses fund_address on an account already in alloc.""" + stubs: AddressStubs | None = None + code = "Op.SSTORE(0, 1) + Op.STOP" + match account_type: + case "funded_eoa": + account_in_alloc = "pre.fund_eoa(amount=1)" + case "deployed_contract": + account_in_alloc = f"pre.deploy_contract({code})" + case "stubbed_contract": + stub_name = "stubbed_contract" + stub_address = contract_deployer.deploy(code) + stubs = AddressStubs({stub_name: stub_address}) + account_in_alloc = ( + f'pre.deploy_contract({code}, stub="{stub_name}")' + ) + case "deterministic_contract": + account_in_alloc = ( + f"pre.deterministic_deploy_contract(deploy_code={code}, " + f"salt={contract_deployer.salt})" + ) + case "deterministic_contract_already_deployed": + contract_deployer.deterministic_deploy(code) + account_in_alloc = ( + f"pre.deterministic_deploy_contract(deploy_code={code}, " + f"salt={contract_deployer.salt})" + ) + case _: + raise Exception(f"account type not implemented: {account_type}") + + test_method = """\ + def test_fund_pre_deploy(state_test, pre) -> None: + sender = pre.fund_eoa() + account_in_alloc = {account_in_alloc} + pre.fund_address(account_in_alloc, 1) + state_test( + pre=pre, + post={{}}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + """.format(account_in_alloc=account_in_alloc) + + execute_runner.run_assert(test_method=test_method, stubs=stubs, failed=1) + + +def test_stubs( + execute_runner: ExecuteRunner, + contract_deployer: ContractDeployer, +) -> None: + """Execute a test that uses stubs for pre-existing contracts.""" + code = "Op.SSTORE(0, 1) + Op.STOP" + stub_address = contract_deployer.deploy(code) + stub_name = "stubbed_contract" + test_method = """\ + def test_stubs(state_test, pre) -> None: + code = {code} + contract = pre.deploy_contract(code, stub="{stub_name}") + assert contract == Address("{stub_address}") + sender = pre.fund_eoa() + tx = Transaction( + sender=sender, + to=contract, + gas_limit=100_000, + ) + state_test( + pre=pre, + post={{ + {stub_address}: Account( + storage=Storage({{0: 1}}), + ), + }}, + tx=tx, + ) + """.format( + code=code, + stub_name=stub_name, + stub_address=f"{stub_address}", + ) + + stubs = AddressStubs({stub_name: stub_address}) + execute_runner.run_assert(test_method=test_method, stubs=stubs) + + +def test_fail_pre_mutation(execute_runner: ExecuteRunner) -> None: + """Verify attempting to mutate the pre-allocation results in failure.""" + test_method = """\ + def test_simple(state_test, pre) -> None: + sender = pre.fund_eoa() + pre[0x1] = Account() + state_test( + pre=pre, + post={{}}, + tx=Transaction( + sender=sender, + to=None, + gas_limit=100_000, + ), + ) + """.format() + + execute_runner.run_assert(test_method=test_method, failed=1)