diff --git a/packages/testing/src/execution_testing/client_clis/__init__.py b/packages/testing/src/execution_testing/client_clis/__init__.py index 16121a72516..2a9e603a118 100644 --- a/packages/testing/src/execution_testing/client_clis/__init__.py +++ b/packages/testing/src/execution_testing/client_clis/__init__.py @@ -14,6 +14,15 @@ ) from .client_backend import ClientBackend, ClientBackendExceptionMapper from .clis.besu import BesuFixtureConsumer, BesuTransitionTool + +# NOTE: erigon is imported before geth so it is registered (and thus probed) +# first. Both expose an `evm` binary printing `evm version ...`; go-ethereum's +# detection matches that banner unconditionally, so it would otherwise claim an +# Erigon binary. ErigonEvm.detect_binary positively fingerprints Erigon (via +# the `enginextest` subcommand) and declines anything else, so a go-ethereum +# binary checked here falls through to GethEvm — the ordering only gives Erigon +# first look, it does not by itself decide identity. +from .clis.erigon import ErigonExceptionMapper, ErigonFixtureConsumer from .clis.ethereumjs import EthereumJSTransitionTool from .clis.evmone import ( EvmOneBlockchainFixtureConsumer, @@ -50,6 +59,8 @@ "CLINotFoundInPathError", "ClientBackend", "ClientBackendExceptionMapper", + "ErigonExceptionMapper", + "ErigonFixtureConsumer", "EthereumJSTransitionTool", "EvmoneExceptionMapper", "EvmOneTransitionTool", diff --git a/packages/testing/src/execution_testing/client_clis/clis/erigon.py b/packages/testing/src/execution_testing/client_clis/clis/erigon.py index 89c60c6319a..e0a77b5f71a 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/erigon.py +++ b/packages/testing/src/execution_testing/client_clis/clis/erigon.py @@ -1,10 +1,28 @@ """Erigon execution client transition tool.""" +import json +import re +import shlex +import shutil +import subprocess +import textwrap +from functools import cache +from pathlib import Path +from typing import Any, Dict, List, Optional + from execution_testing.exceptions import ( BlockException, ExceptionMapper, TransactionException, ) +from execution_testing.fixtures import ( + BlockchainFixture, + FixtureFormat, + StateFixture, +) + +from ..ethereum_cli import EthereumCLI +from ..fixture_consumer_tool import FixtureConsumerTool class ErigonExceptionMapper(ExceptionMapper): @@ -124,3 +142,292 @@ class ErigonExceptionMapper(ExceptionMapper): r"invalid gasUsed: have \d+, gasLimit \d+" ), } + + +class ErigonEvm(EthereumCLI): + """ + Erigon `evm` base class. + + Erigon's `evm` tool shares go-ethereum's command surface (`evm version`, + `evm statetest`, `evm blocktest`, `evm t8n`) and prints an + indistinguishable ``evm version ...`` banner, so the version string + alone cannot tell the two apart. ``detect_binary`` instead probes the + binary itself: Erigon's `evm` exposes an ``enginextest`` subcommand (its + engine-x test runner) that go-ethereum's `evm` does not, which is a stable, + version-independent fingerprint. + """ + + default_binary = Path("evm") + # Cheap pre-filter shared with go-ethereum; the binary probe in + # `detect_binary` is what actually confirms Erigon. + detect_binary_pattern = re.compile(r"^evm(\.exe)? version\b") + # Erigon-only subcommand, used as the disambiguating fingerprint. + erigon_marker = "enginextest" + cached_version: Optional[str] = None + trace: bool + + def __init__( + self, + binary: Optional[Path] = None, + trace: bool = False, + ): + """Initialize the ErigonEvm class.""" + self.binary = binary if binary else self.default_binary + self.trace = trace + self._info_metadata: Optional[Dict[str, Any]] = {} + + @classmethod + def detect_binary( + cls, binary_output: str, binary: Optional[Path] = None + ) -> bool: + """ + Confirm the binary is Erigon's `evm`, not go-ethereum's. + + Both print ``evm version ...``; after that cheap check passes we probe + the binary's ``--help`` for Erigon's ``enginextest`` subcommand. + Without a binary to probe (or if the probe fails) we cannot positively + identify Erigon, so we decline and let go-ethereum's consumer claim it. + """ + if not super().detect_binary(binary_output, binary): + return False + if binary is None: + return False + try: + help_output = subprocess.run( + [str(binary), "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + ).stdout + except Exception: + return False + return cls.erigon_marker in help_output + + def _run_command(self, command: List[str]) -> subprocess.CompletedProcess: + try: + return subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as e: + raise Exception("Command failed with non-zero status.") from e + except Exception as e: + raise Exception("Unexpected exception calling evm tool.") from e + + def _consume_debug_dump( + self, + command: List[str], + result: subprocess.CompletedProcess, + fixture_path: Path, + debug_output_path: Path, + ) -> None: + assert all(isinstance(x, str) for x in command), ( + f"Not all elements of 'command' list are strings: {command}" + ) + assert len(command) > 0 + + # the fixture path is always the last argument + debug_fixture_path = str(debug_output_path / "fixtures.json") + command[-1] = debug_fixture_path + + consume_direct_call = " ".join(shlex.quote(arg) for arg in command) + consume_direct_script = textwrap.dedent( + f"""\ + #!/bin/bash + {consume_direct_call} + """ + ) + from ..transition_tool import dump_files_to_directory + + dump_files_to_directory( + debug_output_path, + { + "consume_direct_args.py": command, + "consume_direct_returncode.txt": result.returncode, + "consume_direct_stdout.txt": result.stdout, + "consume_direct_stderr.txt": result.stderr, + "consume_direct.sh+x": consume_direct_script, + }, + ) + shutil.copyfile(fixture_path, debug_fixture_path) + + @cache # noqa + def help(self, subcommand: str | None = None) -> str: + """Return the help string, optionally for a subcommand.""" + help_command = [str(self.binary)] + if subcommand: + help_command.append(subcommand) + help_command.append("--help") + return self._run_command(help_command).stdout + + +class ErigonFixtureConsumer( + ErigonEvm, + FixtureConsumerTool, + fixture_formats=[StateFixture, BlockchainFixture], +): + """ + Erigon's implementation of the fixture consumer. + + Mirrors ``GethFixtureConsumer`` but passes ``--jsonout`` to ``statetest`` + and ``blocktest``: unlike go-ethereum, Erigon defaults to human-readable + output and only emits the JSON result array (``[{name, pass, error, ...}]`` + on stdout) when ``--jsonout`` is given. Without it the consumer's + ``json.loads`` fails on the first non-JSON line. + """ + + def consume_blockchain_test( + self, + fixture_path: Path, + fixture_name: Optional[str] = None, + debug_output_path: Optional[Path] = None, + ) -> None: + """Consume a single blockchain test.""" + subcommand = "blocktest" + subcommand_options = ["--jsonout"] + if debug_output_path: + subcommand_options += ["--verbosity", "100"] + + if fixture_name: + subcommand_options += ["--run", re.escape(fixture_name)] + + command = ( + [str(self.binary)] + + [subcommand] + + subcommand_options + + [str(fixture_path)] + ) + + result = self._run_command(command) + + if debug_output_path: + self._consume_debug_dump( + command, result, fixture_path, debug_output_path + ) + + if result.returncode != 0: + raise Exception( + f"Unexpected exit code:\n{' '.join(command)}\n\n" + f"Error:\n{result.stderr}" + ) + + result_json = json.loads(result.stdout) + if not isinstance(result_json, list): + raise Exception( + f"Unexpected result from evm blocktest: {result_json}" + ) + + if any(not test_result["pass"] for test_result in result_json): + exception_text = "Blockchain test failed: \n" + "\n".join( + f"{test_result['name']}: " + test_result["error"] + for test_result in result_json + if not test_result["pass"] + ) + raise Exception(exception_text) + + @cache # noqa + def consume_state_test_file( + self, + fixture_path: Path, + debug_output_path: Optional[Path] = None, + ) -> List[Dict[str, Any]]: + """ + Consume an entire state test file. + + `evm statetest` executes every test in the file at once, so the result + is cached and `consume_state_test` selects the requested test from it. + """ + subcommand = "statetest" + subcommand_options = ["--jsonout"] + if debug_output_path: + subcommand_options += ["--json"] + + command = ( + [str(self.binary)] + + [subcommand] + + subcommand_options + + [str(fixture_path)] + ) + result = self._run_command(command) + + if debug_output_path: + self._consume_debug_dump( + command, result, fixture_path, debug_output_path + ) + + if result.returncode != 0: + raise Exception( + f"Unexpected exit code:\n{' '.join(command)}\n\n" + f"Error:\n{result.stderr}" + ) + + result_json = json.loads(result.stdout) + if not isinstance(result_json, list): + raise Exception( + f"Unexpected result from evm statetest: {result_json}" + ) + return result_json + + def consume_state_test( + self, + fixture_path: Path, + fixture_name: Optional[str] = None, + debug_output_path: Optional[Path] = None, + ) -> None: + """Consume a single state test from the cached file results.""" + file_results = self.consume_state_test_file( + fixture_path=fixture_path, + debug_output_path=debug_output_path, + ) + if fixture_name: + test_result = [ + test_result + for test_result in file_results + if test_result["name"] == fixture_name + ] + assert len(test_result) < 2, ( + f"Multiple test results for {fixture_name}" + ) + assert len(test_result) == 1, ( + f"Test result for {fixture_name} missing" + ) + assert test_result[0]["pass"], ( + f"State test failed: {test_result[0]['error']}" + ) + else: + if any(not test_result["pass"] for test_result in file_results): + exception_text = "State test failed: \n" + "\n".join( + f"{test_result['name']}: " + test_result["error"] + for test_result in file_results + if not test_result["pass"] + ) + raise Exception(exception_text) + + def consume_fixture( + self, + fixture_format: FixtureFormat, + fixture_path: Path, + fixture_name: Optional[str] = None, + debug_output_path: Optional[Path] = None, + ) -> None: + """Dispatch to the appropriate Erigon fixture consumer.""" + if fixture_format == BlockchainFixture: + self.consume_blockchain_test( + fixture_path=fixture_path, + fixture_name=fixture_name, + debug_output_path=debug_output_path, + ) + elif fixture_format == StateFixture: + self.consume_state_test( + fixture_path=fixture_path, + fixture_name=fixture_name, + debug_output_path=debug_output_path, + ) + else: + raise Exception( + f"Fixture format {fixture_format.format_name} " + f"not supported by {self.binary}" + ) diff --git a/packages/testing/src/execution_testing/client_clis/ethereum_cli.py b/packages/testing/src/execution_testing/client_clis/ethereum_cli.py index 1e02634f5b8..c2ca9d11d58 100644 --- a/packages/testing/src/execution_testing/client_clis/ethereum_cli.py +++ b/packages/testing/src/execution_testing/client_clis/ethereum_cli.py @@ -180,7 +180,7 @@ def from_binary_path( for subclass in subclasses: logger.debug(f"Trying subclass {subclass}") try: - if subclass.detect_binary(binary_output): + if subclass.detect_binary(binary_output, binary): subclass_check_result = subclass( binary=binary, **kwargs ) @@ -204,10 +204,19 @@ def from_binary_path( raise UnknownCLIError(f"Unknown CLI: {binary}") @classmethod - def detect_binary(cls, binary_output: str) -> bool: + def detect_binary( + cls, + binary_output: str, + binary: Optional[Path] = None, # noqa: ARG003 + ) -> bool: """ Return True if a CLI's `binary_output` matches the class's expected output. + + `binary` is the resolved path to the tool being probed. Subclasses may + use it to disambiguate tools that share a version banner by inspecting + the binary itself (e.g. its subcommands), instead of relying on the + version string alone. """ logger.debug(f"Trying to detect binary for {binary_output}..") assert cls.detect_binary_pattern is not None diff --git a/tests/frontier/validation/test_transaction.py b/tests/frontier/validation/test_transaction.py index 372f68fa320..1b665a4e073 100644 --- a/tests/frontier/validation/test_transaction.py +++ b/tests/frontier/validation/test_transaction.py @@ -1,7 +1,14 @@ """Test the transaction level validations applied from Frontier.""" import pytest -from execution_testing import Alloc, Transaction +from execution_testing import ( + Account, + Alloc, + Op, + StateTestFiller, + Storage, + Transaction, +) from execution_testing.base_types.base_types import ZeroPaddedHexNumber from execution_testing.exceptions.exceptions import TransactionException from execution_testing.forks.base_fork import BaseFork @@ -149,3 +156,44 @@ def test_sender_balance( ) blockchain_test(pre=pre, post={}, blocks=[block], genesis_environment=env) + + +@pytest.mark.valid_from("Frontier") +@pytest.mark.state_test_only +@pytest.mark.exception_test +@pytest.mark.eels_base_coverage +def test_sender_balance_insufficient_state_test( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + A legacy transaction from a sender that cannot afford `gas * gasPrice` + must be rejected, exercised through the state-test code path. + """ + storage = Storage() + # If the transaction were (incorrectly) executed, this SSTORE would land a + # non-default value in slot 0, diverging the post-state root from the + # rejected (pre == post) outcome. + contract = pre.deploy_contract( + code=Op.SSTORE(storage.store_next(0, "must_stay_unset"), 0x1) + + Op.STOP, + ) + # Zero balance, unable to cover any gas cost. + sender = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=sender, + to=contract, + gas_limit=100_000, + gas_price=10, + protected=False, # legacy tx + error=TransactionException.INSUFFICIENT_ACCOUNT_FUNDS, + ) + + state_test( + env=Environment(), + pre=pre, + # Transaction rejected: contract storage stays empty. + post={contract: Account(storage=storage)}, + tx=tx, + )