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
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,3 @@ def test_eest_bytes_keccak256_matches_eels() -> None:
from_eest = bytes(Bytes(buffer).keccak256())
from_eels = bytes(keccak256(buffer))
assert from_eest == from_eels


def test_eest_trie_keccak256_matches_eels() -> None:
"""`trie.keccak256` and EELS `keccak256` return identical digests."""
from ethereum.crypto.hash import keccak256 as eels

from ...test_types.trie import keccak256 as trie

for buffer in (b"", b"hashme", bytes(range(256))):
assert bytes(trie(buffer)) == bytes(eels(buffer))
113 changes: 94 additions & 19 deletions packages/testing/src/execution_testing/client_clis/cli_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,8 @@ def validate(self) -> Alloc:
"""Validate the alloc."""
raise NotImplementedError("validate method not implemented.")

def get(self) -> Alloc:
"""Model validate the allocation and return it."""
def materialize(self) -> Alloc:
"""Materialize the allocation, validating it on first access."""
if self.alloc is None:
self.alloc = self.validate()
return self.alloc
Expand All @@ -438,6 +438,28 @@ def state_root(self) -> Hash:
"""Return state root of the allocation."""
return self._state_root

def serialize(self, **model_dump_config: Any) -> str:
"""
Serialize the allocation to a JSON string.

The default materializes the ``Alloc`` and dumps it. Subclasses
backed by already-serialized data override this to return their
cache directly and skip the round trip through ``Alloc``.
"""
return self.materialize().model_dump_json(**model_dump_config)

def serialize_to_file(
self, file_path: Path, **model_dump_config: Any
) -> None:
"""
Serialize the allocation to ``file_path`` as JSON.

Writes whatever :meth:`serialize` produces. ``LazyAllocFile``
overrides this with a byte-for-byte copy that avoids building
the JSON string at all.
"""
file_path.write_text(self.serialize(**model_dump_config))


JSONDict = Dict[str, Any]

Expand All @@ -453,6 +475,19 @@ def validate(self) -> Alloc:
"""Validate the alloc."""
return Alloc.model_validate(self.raw)

def serialize(self, **model_dump_config: Any) -> str:
"""
Dump the cached JSON dict without round-tripping through ``Alloc``.

Only ``indent`` applies; the dict is already-serialized data, so
pydantic options such as ``by_alias`` / ``exclude_none`` are moot.
"""
return json.dumps(
self.raw,
ensure_ascii=True,
indent=model_dump_config.get("indent"),
)


class LazyAllocStr(LazyAlloc[str]):
"""
Expand All @@ -465,6 +500,11 @@ def validate(self) -> Alloc:
"""Validate the alloc."""
return Alloc.model_validate_json(self.raw)

def serialize(self, **model_dump_config: Any) -> str:
"""Return the cached JSON string verbatim (no re-serialization)."""
del model_dump_config # raw already encodes its own formatting
return self.raw


@dataclass(kw_only=True)
class LazyAllocFile(LazyAlloc[Path]):
Expand All @@ -484,7 +524,7 @@ class LazyAllocFile(LazyAlloc[Path]):
LazyAllocFile is dropped. That lets a chained next-block t8n call
consume the alloc directly from disk (via ``--input.alloc=<path>`` for
geth, or ``shutil.copyfile`` for filesystem t8ns) without round-tripping
through ``Alloc.get().model_dump_json()`` in Python.
through ``LazyAlloc.materialize().model_dump_json()`` in Python.
"""

_keepalive: Optional[tempfile.TemporaryDirectory] = field(default=None)
Expand Down Expand Up @@ -514,6 +554,46 @@ def validate(self) -> Alloc:
)
return Alloc.model_validate(accumulated)

def serialize_to_file(
self, file_path: Path, **model_dump_config: Any
) -> None:
"""
Copy the backing file byte-for-byte, avoiding a parse/dump cycle.

If the backing temp dir was already cleaned up (e.g. a
chained-block t8n consumed it on the next block), fall back to
dumping the cached ``Alloc`` so debug output still captures the
input.
"""
if Path(self.raw).exists():
shutil.copyfile(self.raw, file_path)
else:
super().serialize_to_file(file_path, **model_dump_config)


@dataclass(kw_only=True)
class MaterializedAlloc(LazyAlloc[None]):
"""
Allocation already materialized in memory; ``get()`` is a no-op.

Used by in-process transition tools (EELS) whose ``Alloc`` never
exists in a serialized form — hence ``raw`` is ``None``. The
``alloc`` field must be provided at construction, so ``get()``
always short-circuits and ``validate()`` is unreachable.
"""

raw: None = None

def __post_init__(self) -> None:
"""Require the materialized alloc at construction."""
assert self.alloc is not None, (
"MaterializedAlloc requires `alloc` at construction"
)

def validate(self) -> Alloc:
"""Unreachable: ``alloc`` is always set at construction."""
raise AssertionError("unreachable: alloc is set at construction")


@dataclass
class TransitionToolInput:
Expand All @@ -534,16 +614,15 @@ def to_files(
For ``LazyAllocFile`` inputs whose backing file is still on disk
(chained-block handoff: previous t8n call's temp dir is pinned via
the keepalive field), the alloc is copied byte-for-byte rather than
round-tripped through ``Alloc.get().model_dump_json()``.
round-tripped through ``LazyAlloc.materialize().model_dump_json()``.
"""
alloc_path = directory_path / "alloc.json"
if (
isinstance(self.alloc, LazyAllocFile)
and Path(self.alloc.raw).exists()
):
shutil.copyfile(self.alloc.raw, alloc_path)
if isinstance(self.alloc, LazyAlloc):
self.alloc.serialize_to_file(alloc_path, **model_dump_config)
else:
alloc_path.write_text(self._serialize_alloc(**model_dump_config))
alloc_path.write_text(
self.alloc.model_dump_json(**model_dump_config)
)

env_contents = self.env.model_dump_json(**model_dump_config)
txs_contents = (
Expand All @@ -570,13 +649,9 @@ def to_files(

def _serialize_alloc(self, **model_dump_config: Any) -> str:
"""Serialize ``self.alloc`` to a JSON string."""
if isinstance(self.alloc, Alloc):
return self.alloc.model_dump_json(**model_dump_config)
if isinstance(self.alloc, LazyAllocStr):
return self.alloc.raw
if isinstance(self.alloc, LazyAllocFile):
return self.alloc.get().model_dump_json(**model_dump_config)
raise Exception(f"Invalid alloc type: {type(self.alloc)}")
if isinstance(self.alloc, LazyAlloc):
return self.alloc.serialize(**model_dump_config)
return self.alloc.model_dump_json(**model_dump_config)

def model_dump_json(
self, *, exclude_alloc: bool = False, **model_dump_config: Any
Expand Down Expand Up @@ -623,7 +698,7 @@ def model_dump(self, mode: str, **model_dump_config: Any) -> Any:
elif isinstance(self.alloc, LazyAllocJson):
alloc_contents = self.alloc.raw
elif isinstance(self.alloc, LazyAllocFile):
alloc_contents = self.alloc.get().model_dump(
alloc_contents = self.alloc.materialize().model_dump(
mode=mode, **model_dump_config
)
else:
Expand Down Expand Up @@ -681,7 +756,7 @@ def model_validate_files(
different JSON file.

`alloc.json` is referenced by path and parsed incrementally on
`.get()` via `LazyAllocFile`, so the full file is never held in
`.materialize()` via `LazyAllocFile`, so the full file is never held in
memory alongside the validated `Alloc`.
"""
result_data = (directory_path / "result.json").read_text()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def _evaluate(
dump_files_to_directory(
debug_output_path,
{
"output/alloc.json": output.alloc.raw,
"output/alloc.json": output.alloc,
"output/result.json": output.result.model_dump(
mode="json", **model_dump_config
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@
Ethereum Specs EVM Transition Tool Interface.
"""

import json
import tempfile
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional

from typing_extensions import override

from execution_testing.client_clis.cli_types import TransitionToolOutput
from execution_testing.client_clis.cli_types import (
OpcodeCount,
TransitionToolOutput,
)
from execution_testing.client_clis.file_utils import (
dump_files_to_directory,
)
Expand Down Expand Up @@ -92,84 +93,76 @@ def _evaluate(
profiler: Profiler,
) -> TransitionToolOutput:
"""
Evaluate using the EELS T8N entry point.
Evaluate using the EELS T8N entry point in-process.

``transition_tool_data`` is handed to ``T8N`` as-is — fork,
chain_id, reward, state_test, blob_schedule all flow through
— and ``T8N.run()`` returns the ``TransitionToolOutput``
directly.
"""
from ethereum_spec_tools.evm_tools import create_parser
from ethereum_spec_tools.evm_tools.t8n import T8N
from ethereum_spec_tools.evm_tools.t8n.evm_trace.count import (
CountTracer,
)
from ethereum_spec_tools.evm_tools.t8n.evm_trace.eip3155 import (
Eip3155Tracer,
)
from ethereum_spec_tools.evm_tools.t8n.evm_trace.group import (
GroupTracer,
)

del slow_request, profiler
request_data = transition_tool_data.get_request_data()
request_data_json = request_data.model_dump(
mode="json", **model_dump_config
)

temp_dir = tempfile.TemporaryDirectory()
t8n_args = [
"t8n",
"--input.alloc=stdin",
"--input.env=stdin",
"--input.txs=stdin",
"--output.result=stdout",
"--output.body=stdout",
"--output.alloc=stdout",
f"--output.basedir={temp_dir.name}",
f"--state.fork={request_data_json['state']['fork']}",
f"--state.chainid={request_data_json['state']['chainid']}",
f"--state.reward={request_data_json['state']['reward']}",
]

if transition_tool_data.state_test:
t8n_args.append("--state-test")

if transition_tool_data.blob_params:
fork = transition_tool_data.fork
if fork.bpo_fork() and fork != fork.non_bpo_ancestor():
# Only send this information for BPO forks.
# TODO: This should be optimized by the t8n tool instead.
t8n_args.append("--input.blobParams=stdin")

if self.supports_opcode_count:
t8n_args.append("--opcode.count=stdout")

tracers = None
if self.trace:
t8n_args.extend(
[
"--trace",
"--trace.memory",
"--trace.returndata",
]
# TODO: Eip3155 traces still round-trip through tempfile
# JSON — the tracer writes one ``trace-<i>.jsonl`` per tx
# to ``output_basedir`` and ``collect_traces`` reads them
# back. Same JSON round-trip we eliminated for alloc /
# result / body; a follow-up should wire the tracer
# output through memory like the rest of the in-process
# path.
tracers = GroupTracer()
tracers.add(
Eip3155Tracer(
trace_memory=True,
trace_stack=True,
trace_return_data=True,
output_basedir=temp_dir.name,
Comment thread
gurukamath marked this conversation as resolved.
)
)

parser = create_parser()
t8n_options = parser.parse_args(t8n_args)

out_stream = StringIO()

in_stream = StringIO(json.dumps(request_data_json["input"]))

t8n = T8N(t8n_options, out_stream, in_stream, self.fork_cache)
t8n.run()

output_dict = json.loads(out_stream.getvalue())
count_tracer = None
if self.supports_opcode_count:
count_tracer = CountTracer()
if tracers is None:
tracers = GroupTracer()
tracers.add(count_tracer)

t8n = T8N(
transition_tool_data,
cache=self.fork_cache,
tracers=tracers,
exception_mapper=self.exception_mapper,
)
output = t8n.run()

if "opcodeCount" in output_dict and "result" in output_dict:
output_dict["result"]["opcodeCount"] = output_dict.pop(
"opcodeCount"
if count_tracer is not None:
output.result.opcode_count = OpcodeCount.model_validate(
count_tracer.results()
)

output: TransitionToolOutput = TransitionToolOutput.model_validate(
output_dict, context={"exception_mapper": self.exception_mapper}
)

if debug_output_path:
dump_files_to_directory(
debug_output_path,
{
"input/alloc.json": request_data.input.alloc,
"input/env.json": request_data.input.env,
"input/alloc.json": transition_tool_data.alloc,
"input/env.json": transition_tool_data.env,
"input/txs.json": [
tx.model_dump(mode="json", **model_dump_config)
for tx in request_data.input.txs
for tx in transition_tool_data.txs
],
},
)
Expand Down
Loading
Loading