Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/testing/src/execution_testing/client_clis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -50,6 +59,8 @@
"CLINotFoundInPathError",
"ClientBackend",
"ClientBackendExceptionMapper",
"ErigonExceptionMapper",
"ErigonFixtureConsumer",
"EthereumJSTransitionTool",
"EvmoneExceptionMapper",
"EvmOneTransitionTool",
Expand Down
307 changes: 307 additions & 0 deletions packages/testing/src/execution_testing/client_clis/clis/erigon.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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 <semver>...`` 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}"
)
13 changes: 11 additions & 2 deletions packages/testing/src/execution_testing/client_clis/ethereum_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
Loading
Loading