From b71a8067420893d67823a829a170ac389ca8d089 Mon Sep 17 00:00:00 2001 From: Felix H Date: Fri, 8 May 2026 17:00:36 +0200 Subject: [PATCH 1/2] fix: tracing consistency between input and output alloc formatting --- .../filler/tests/test_alloc_dump_canonical.py | 139 ++++++++++++++++++ .../client_clis/cli_types.py | 50 +++++++ .../client_clis/clis/execution_specs.py | 4 +- .../client_clis/transition_tool.py | 8 +- 4 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_alloc_dump_canonical.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_alloc_dump_canonical.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_alloc_dump_canonical.py new file mode 100644 index 00000000000..61a8da63252 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_alloc_dump_canonical.py @@ -0,0 +1,139 @@ +""" +Test that ``input/alloc.json`` written under ``--evm-dump-dir`` uses the +same canonical JSON format as the t8n-produced ``output/alloc.json``. + +When both files share the same conventions (32-byte-padded storage keys, +minimal-hex nonces, and omission of empty/default fields), a textual +diff between them surfaces only real state transitions instead of +formatting noise. Any account whose semantic state is unchanged between +pre and post must therefore serialize byte-identically in both files. +""" + +import json +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +CONTRACT_ADDR = "0x000000000000000000000000000000000000c0de" + +TEST_MODULE = textwrap.dedent( + f"""\ + import pytest + + from execution_testing import ( + Account, + Environment, + TestAddress, + Transaction, + ) + + CONTRACT = "{CONTRACT_ADDR}" + + @pytest.mark.valid_at("Cancun") + def test_alloc_canonical(state_test) -> None: + state_test( + env=Environment(), + pre={{ + TestAddress: Account(balance=10**18), + CONTRACT: Account( + nonce=1, + code=b"", + storage={{0x22: 0xabc}}, + ), + }}, + post={{}}, + tx=Transaction(), + ) + """ +) + + +def _load(path: Path) -> Any: + with path.open() as f: + return json.load(f) + + +def test_input_alloc_matches_output_alloc_format( + pytester: pytest.Pytester, tmp_path: Path +) -> None: + """ + Filling a one-tx test with ``--traces`` must produce an + ``input/alloc.json`` whose formatting matches the t8n's + ``output/alloc.json``: storage keys padded to 32 bytes, empty fields + omitted, nonces in minimal hex. The contract account at + ``CONTRACT_ADDR`` is untouched by the transaction, so its JSON + representation must be byte-identical between the two files. + """ + tests_dir = pytester.mkdir("tests") + cancun_dir = tests_dir / "cancun" + cancun_dir.mkdir() + case_dir = cancun_dir / "alloc_canonical" + case_dir.mkdir() + (case_dir / "test_alloc_canonical.py").write_text(TEST_MODULE) + + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + dump_dir = tmp_path / "dump" + + result = pytester.runpytest( + "-c", + "pytest-fill.ini", + "--fork", + "Cancun", + "tests/cancun/alloc_canonical/", + "--traces", + "--evm-dump-dir", + str(dump_dir), + "--clean", + "-q", + ) + assert result.ret == 0, f"fill failed: {result.outlines}" + + inputs = sorted(dump_dir.rglob("input/alloc.json")) + outputs = sorted(dump_dir.rglob("output/alloc.json")) + assert inputs and outputs, f"no alloc dumps under {dump_dir}" + + input_alloc = _load(inputs[0]) + output_alloc = _load(outputs[0]) + + # Format invariants on the input — all aligned with the output's + # canonical conventions. + for addr, account in input_alloc.items(): + # Empty storage / zero balance / empty code must be omitted. + assert "storage" not in account or account["storage"], ( + f"{addr}: empty storage dict not omitted" + ) + assert "balance" not in account or account["balance"] != "0x00", ( + f"{addr}: zero-padded balance not omitted" + ) + assert "code" not in account or account["code"] != "0x", ( + f"{addr}: empty code not omitted" + ) + # Storage keys must be 32-byte zero-padded. + for key in account.get("storage", {}): + assert len(key) == 66 and key.startswith("0x"), ( + f"{addr}: storage key not 32-byte-padded: {key!r}" + ) + # Nonce uses minimal hex (no leading zero unless value is 0). + if "nonce" in account: + n = account["nonce"] + assert n == "0x0" or not n.startswith("0x0"), ( + f"{addr}: nonce uses zero-padding: {n}" + ) + + # The contract is never touched by the transaction, so its + # representation must be identical in input and output. + assert CONTRACT_ADDR in input_alloc, ( + f"contract {CONTRACT_ADDR} missing from input alloc" + ) + assert CONTRACT_ADDR in output_alloc, ( + f"contract {CONTRACT_ADDR} missing from output alloc" + ) + assert input_alloc[CONTRACT_ADDR] == output_alloc[CONTRACT_ADDR], ( + "contract account differs between input and output:\n" + f" input: {input_alloc[CONTRACT_ADDR]}\n" + f" output: {output_alloc[CONTRACT_ADDR]}" + ) 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 8fec397ebc2..819c0f0d232 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -512,6 +512,39 @@ def validate(self) -> Alloc: return Alloc.model_validate(accumulated) +def _alloc_to_canonical_dict(alloc: Alloc) -> Dict[str, Dict[str, Any]]: + """ + Convert an ``Alloc`` to a dict in the canonical t8n + ``output/alloc.json`` form: empty/default fields (zero balance, + zero nonce, empty code, empty storage) are omitted, storage keys + are zero-padded to 32 bytes, and numeric values use minimal hex. + + Used when writing the debug ``input/alloc.json`` artifact so that + a textual diff against the t8n's ``output/alloc.json`` surfaces + only real state transitions instead of formatting noise. + """ + result: Dict[str, Dict[str, Any]] = {} + for address, account in alloc.root.items(): + if account is None: + continue + entry: Dict[str, Any] = {} + nonce_int = int(account.nonce) + if nonce_int != 0: + entry["nonce"] = hex(nonce_int) + balance_int = int(account.balance) + if balance_int != 0: + entry["balance"] = hex(balance_int) + if len(account.code) > 0: + entry["code"] = str(account.code) + if account.storage.root: + entry["storage"] = { + "0x" + format(int(k), "064x"): hex(int(v)) + for k, v in account.storage.root.items() + } + result[str(address)] = entry + return result + + @dataclass class TransitionToolInput: """Transition tool input.""" @@ -521,6 +554,23 @@ class TransitionToolInput: env: Environment blob_params: ForkBlobSchedule | None = None + def alloc_for_debug_dump( + self, + ) -> Dict[str, Dict[str, Any]] | LazyAlloc: + """ + Return the alloc in canonical form for the debug + ``input/alloc.json`` artifact. + + ``Alloc`` instances are converted to a dict matching the t8n's + ``output/alloc.json`` conventions. ``LazyAlloc`` inputs were + produced by a previous t8n call's output and are already + canonical, so they are returned unchanged for + ``dump_files_to_directory`` to dispatch on. + """ + if isinstance(self.alloc, Alloc): + return _alloc_to_canonical_dict(self.alloc) + return self.alloc + def to_files( self, directory_path: Path, **model_dump_config: Any ) -> Dict[str, str]: 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..262acbab256 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 @@ -133,7 +133,9 @@ def _evaluate( dump_files_to_directory( debug_output_path, { - "input/alloc.json": request_data.input.alloc, + "input/alloc.json": ( + request_data.input.alloc_for_debug_dump() + ), "input/env.json": request_data.input.env, "input/txs.json": [ tx.model_dump(mode="json", **model_dump_config) 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 93d1d7e17d5..6ed6d5b2e4c 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -618,10 +618,8 @@ def _evaluate_server( dump_files_to_directory( debug_output_path, { - "input/alloc.json": request_data.input.alloc.raw - if isinstance(request_data.input.alloc, LazyAlloc) - else request_data.input.alloc.model_dump( - mode="json", **model_dump_config + "input/alloc.json": ( + request_data.input.alloc_for_debug_dump() ), "input/env.json": request_data.input.env, "input/txs.json": [ @@ -903,7 +901,7 @@ def dump_debug_stream( debug_output_path, { "args.py": args, - "input/alloc.json": stdin.alloc, + "input/alloc.json": stdin.alloc_for_debug_dump(), "input/env.json": stdin.env, "input/txs.json": [ tx.model_dump(mode="json", **model_dump_config) From 602dc8e939dbff45712e30d5a74ba2a2de0e9208 Mon Sep 17 00:00:00 2001 From: Felix H Date: Mon, 11 May 2026 12:26:02 +0200 Subject: [PATCH 2/2] fix: preexisting bug exposed by this PR that led to ci failure --- .../evm_tools/t8n/__init__.py | 170 +++++++++--------- .../evm_tools/t8n/evm_trace/eip3155.py | 1 + 2 files changed, 91 insertions(+), 80 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 8175015e5b6..0a17e058c6e 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -333,7 +333,6 @@ def __init__( maybe_tracers: GroupTracer | None if tracers.tracers: - trace.set_evm_trace(tracers) maybe_tracers = tracers else: maybe_tracers = None @@ -558,89 +557,100 @@ def run_blockchain_test(self) -> None: def run(self) -> int: """Run the transition and provide the relevant outputs.""" - # Clear files that may have been created in a previous - # run of the t8n tool. - # Define the specific files and pattern to delete - files_to_delete = [ - self.options.output_result, - self.options.output_alloc, - self.options.output_body, - ] - pattern_to_delete = "trace-*.jsonl" - - # Iterate through the directory - for file in os.listdir(self.options.output_basedir): - file_path = os.path.join(self.options.output_basedir, file) - - # Check if the file matches the specific names or the pattern - if file in files_to_delete or fnmatch.fnmatch( - file, pattern_to_delete - ): - os.remove(file_path) + previous_tracer = trace.set_evm_trace( + self.tracers + if self.tracers is not None + else trace.discard_evm_trace + ) try: - if self.options.state_test: - self.run_state_test() - else: - self.run_blockchain_test() - except FatalError as e: - self.logger.error(str(e)) - return 1 - - json_state = self.alloc.to_json() - json_result = self.result.to_json() - - json_output: dict[str, object] = {} - - if self.options.output_body == "stdout": - txs_rlp = "0x" + rlp.encode(self.txs.all_txs).hex() - json_output["body"] = txs_rlp - elif self.options.output_body is not None: - txs_rlp_path = os.path.join( - self.options.output_basedir, + # Clear files that may have been created in a previous + # run of the t8n tool. + # Define the specific files and pattern to delete + files_to_delete = [ + self.options.output_result, + self.options.output_alloc, self.options.output_body, - ) - txs_rlp = "0x" + rlp.encode(self.txs.all_txs).hex() - with open(txs_rlp_path, "w") as f: - json.dump(txs_rlp, f) - self.logger.info(f"Wrote transaction rlp to {txs_rlp_path}") + ] + pattern_to_delete = "trace-*.jsonl" - if self.options.output_alloc == "stdout": - json_output["alloc"] = json_state - else: - alloc_output_path = os.path.join( - self.options.output_basedir, - self.options.output_alloc, - ) - with open(alloc_output_path, "w") as f: - json.dump(json_state, f, indent=4) - self.logger.info(f"Wrote alloc to {alloc_output_path}") + # Iterate through the directory + for file in os.listdir(self.options.output_basedir): + file_path = os.path.join(self.options.output_basedir, file) - if self.options.output_result == "stdout": - json_output["result"] = json_result - else: - result_output_path = os.path.join( - self.options.output_basedir, - self.options.output_result, - ) - with open(result_output_path, "w") as f: - json.dump(json_result, f, indent=4) - self.logger.info(f"Wrote result to {result_output_path}") - - if self.options.opcode_count == "stdout": - opcode_count_results = self._tracer(CountTracer).results() - json_output["opcodeCount"] = opcode_count_results - elif self.options.opcode_count is not None: - opcode_count_results = self._tracer(CountTracer).results() - result_output_path = os.path.join( - self.options.output_basedir, - self.options.opcode_count, - ) - with open(result_output_path, "w") as f: - json.dump(opcode_count_results, f, indent=4) - self.logger.info(f"Wrote opcode counts to {result_output_path}") + # Check if the file matches the specific names or the pattern + if file in files_to_delete or fnmatch.fnmatch( + file, pattern_to_delete + ): + os.remove(file_path) + + try: + if self.options.state_test: + self.run_state_test() + else: + self.run_blockchain_test() + except FatalError as e: + self.logger.error(str(e)) + return 1 + + json_state = self.alloc.to_json() + json_result = self.result.to_json() + + json_output: dict[str, object] = {} + + if self.options.output_body == "stdout": + txs_rlp = "0x" + rlp.encode(self.txs.all_txs).hex() + json_output["body"] = txs_rlp + elif self.options.output_body is not None: + txs_rlp_path = os.path.join( + self.options.output_basedir, + self.options.output_body, + ) + txs_rlp = "0x" + rlp.encode(self.txs.all_txs).hex() + with open(txs_rlp_path, "w") as f: + json.dump(txs_rlp, f) + self.logger.info(f"Wrote transaction rlp to {txs_rlp_path}") + + if self.options.output_alloc == "stdout": + json_output["alloc"] = json_state + else: + alloc_output_path = os.path.join( + self.options.output_basedir, + self.options.output_alloc, + ) + with open(alloc_output_path, "w") as f: + json.dump(json_state, f, indent=4) + self.logger.info(f"Wrote alloc to {alloc_output_path}") + + if self.options.output_result == "stdout": + json_output["result"] = json_result + else: + result_output_path = os.path.join( + self.options.output_basedir, + self.options.output_result, + ) + with open(result_output_path, "w") as f: + json.dump(json_result, f, indent=4) + self.logger.info(f"Wrote result to {result_output_path}") + + if self.options.opcode_count == "stdout": + opcode_count_results = self._tracer(CountTracer).results() + json_output["opcodeCount"] = opcode_count_results + elif self.options.opcode_count is not None: + opcode_count_results = self._tracer(CountTracer).results() + result_output_path = os.path.join( + self.options.output_basedir, + self.options.opcode_count, + ) + with open(result_output_path, "w") as f: + json.dump(opcode_count_results, f, indent=4) + self.logger.info( + f"Wrote opcode counts to {result_output_path}" + ) - if json_output: - json.dump(json_output, self.out_file, indent=4) + if json_output: + json.dump(json_output, self.out_file, indent=4) - return 0 + return 0 + finally: + trace.set_evm_trace(previous_tracer) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py b/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py index 9e89598532a..c42f3f1e629 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py @@ -321,6 +321,7 @@ def output_traces( output_path = os.path.join( output_basedir, f"trace-{index_in_block}-{tx_hash_str}.jsonl" ) + os.makedirs(output_basedir, exist_ok=True) json_file = open(output_path, "w") stack.push(json_file) else: