Skip to content

Commit 7f32108

Browse files
authored
feat(t8n): Support evmone's --opcode.count option (#1956)
* Temp: Gather opcode counts in fixtures * feat(cli): Add diff_opcode_counts command * fix: Tox * fix imports * fix comments
1 parent f8ed185 commit 7f32108

File tree

6 files changed

+270
-1
lines changed

6 files changed

+270
-1
lines changed

cli/diff_opcode_counts.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env python
2+
"""
3+
Compare opcode counts between two folders of JSON fixtures.
4+
5+
This script crawls two folders for JSON files, parses them using the Fixtures
6+
model, and compares the opcode_count field from the info section between
7+
fixtures with the same name.
8+
"""
9+
10+
import sys
11+
from pathlib import Path
12+
from typing import Dict, List, Optional
13+
14+
import click
15+
16+
from ethereum_clis.cli_types import OpcodeCount
17+
from ethereum_test_fixtures.file import Fixtures
18+
19+
20+
def find_json_files(directory: Path) -> List[Path]:
21+
"""Find all JSON files in a directory, excluding index.json files."""
22+
json_files = []
23+
if directory.is_dir():
24+
for file_path in directory.rglob("*.json"):
25+
if file_path.name != "index.json":
26+
json_files.append(file_path)
27+
return json_files
28+
29+
30+
def load_fixtures_from_file(
31+
file_path: Path, remove_from_fixture_names: List[str]
32+
) -> Optional[Fixtures]:
33+
"""Load fixtures from a JSON file using the Fixtures model."""
34+
try:
35+
fixtures = Fixtures.model_validate_json(file_path.read_text())
36+
renames = []
37+
for k in fixtures.root:
38+
new_name = None
39+
for s in remove_from_fixture_names:
40+
if s in k:
41+
if new_name is None:
42+
new_name = k.replace(s, "")
43+
else:
44+
new_name = new_name.replace(s, "")
45+
if new_name is not None:
46+
renames.append((k, new_name))
47+
for old_name, new_name in renames:
48+
fixtures.root[new_name] = fixtures.root.pop(old_name)
49+
return fixtures
50+
except Exception as e:
51+
print(f"Error loading {file_path}: {e}", file=sys.stderr)
52+
return None
53+
54+
55+
def extract_opcode_counts_from_fixtures(fixtures: Fixtures) -> Dict[str, OpcodeCount]:
56+
"""Extract opcode_count from info field for each fixture."""
57+
opcode_counts = {}
58+
for fixture_name, fixture in fixtures.items():
59+
if hasattr(fixture, "info") and fixture.info and "opcode_count" in fixture.info:
60+
try:
61+
opcode_count = OpcodeCount.model_validate(fixture.info["opcode_count"])
62+
opcode_counts[fixture_name] = opcode_count
63+
except Exception as e:
64+
print(f"Error parsing opcode_count for {fixture_name}: {e}", file=sys.stderr)
65+
return opcode_counts
66+
67+
68+
def load_all_opcode_counts(
69+
directory: Path, remove_from_fixture_names: List[str]
70+
) -> Dict[str, OpcodeCount]:
71+
"""Load all opcode counts from all JSON files in a directory."""
72+
all_opcode_counts = {}
73+
json_files = find_json_files(directory)
74+
75+
for json_file in json_files:
76+
fixtures = load_fixtures_from_file(
77+
json_file, remove_from_fixture_names=remove_from_fixture_names
78+
)
79+
if fixtures:
80+
file_opcode_counts = extract_opcode_counts_from_fixtures(fixtures)
81+
# Use fixture name as key, if there are conflicts, choose the last
82+
all_opcode_counts.update(file_opcode_counts)
83+
84+
return all_opcode_counts
85+
86+
87+
def compare_opcode_counts(count1: OpcodeCount, count2: OpcodeCount) -> Dict[str, int]:
88+
"""Compare two opcode counts and return the differences."""
89+
differences = {}
90+
91+
# Get all unique opcodes from both counts
92+
all_opcodes = set(count1.root.keys()) | set(count2.root.keys())
93+
94+
for opcode in all_opcodes:
95+
val1 = count1.root.get(opcode, 0)
96+
val2 = count2.root.get(opcode, 0)
97+
diff = val2 - val1
98+
if diff != 0:
99+
differences[str(opcode)] = diff
100+
101+
return differences
102+
103+
104+
@click.command()
105+
@click.argument("base", type=click.Path(exists=True, file_okay=False, path_type=Path))
106+
@click.argument("patch", type=click.Path(exists=True, file_okay=False, path_type=Path))
107+
@click.option(
108+
"--show-common",
109+
is_flag=True,
110+
help="Print fixtures that contain identical opcode counts.",
111+
)
112+
@click.option(
113+
"--show-missing",
114+
is_flag=True,
115+
help="Print fixtures only found in one of the folders.",
116+
)
117+
@click.option(
118+
"--remove-from-fixture-names",
119+
"-r",
120+
multiple=True,
121+
help="String to be removed from the fixture name, in case the fixture names have changed, "
122+
"in order to make the comparison easier. "
123+
"Can be specified multiple times.",
124+
)
125+
def main(
126+
base: Path,
127+
patch: Path,
128+
show_common: bool,
129+
show_missing: bool,
130+
remove_from_fixture_names: List[str],
131+
):
132+
"""Crawl two folders, compare and print the opcode count diffs."""
133+
print(f"Loading opcode counts from {base}...")
134+
opcode_counts1 = load_all_opcode_counts(base, remove_from_fixture_names)
135+
print(f"Found {len(opcode_counts1)} fixtures with opcode counts")
136+
137+
print(f"Loading opcode counts from {patch}...")
138+
opcode_counts2 = load_all_opcode_counts(patch, remove_from_fixture_names)
139+
print(f"Found {len(opcode_counts2)} fixtures with opcode counts")
140+
141+
# Find common fixture names
142+
common_names = set(opcode_counts1.keys()) & set(opcode_counts2.keys())
143+
only_in_1 = set(opcode_counts1.keys()) - set(opcode_counts2.keys())
144+
only_in_2 = set(opcode_counts2.keys()) - set(opcode_counts1.keys())
145+
146+
print("\nSummary:")
147+
print(f" Common fixtures: {len(common_names)}")
148+
print(f" Only in {base.name}: {len(only_in_1)}")
149+
print(f" Only in {patch.name}: {len(only_in_2)}")
150+
151+
# Show missing fixtures if requested
152+
if show_missing:
153+
if only_in_1:
154+
print(f"\nFixtures only in {base.name}:")
155+
for name in sorted(only_in_1):
156+
print(f" {name}")
157+
158+
if only_in_2:
159+
print(f"\nFixtures only in {patch.name}:")
160+
for name in sorted(only_in_2):
161+
print(f" {name}")
162+
163+
# Compare common fixtures
164+
differences_found = False
165+
common_with_same_counts = 0
166+
167+
for fixture_name in sorted(common_names):
168+
count1 = opcode_counts1[fixture_name]
169+
count2 = opcode_counts2[fixture_name]
170+
171+
differences = compare_opcode_counts(count1, count2)
172+
173+
if differences:
174+
differences_found = True
175+
print(f"\n{fixture_name}:")
176+
for opcode, diff in sorted(differences.items()):
177+
if diff > 0:
178+
print(f" +{diff} {opcode}")
179+
else:
180+
print(f" {diff} {opcode}")
181+
elif show_common:
182+
print(f"\n{fixture_name}: No differences")
183+
common_with_same_counts += 1
184+
185+
if not differences_found:
186+
print("\nNo differences found in opcode counts between common fixtures!")
187+
elif show_common:
188+
print(f"\n{common_with_same_counts} fixtures have identical opcode counts")
189+
190+
191+
if __name__ == "__main__":
192+
main()

ethereum_clis/cli_types.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
from typing import Annotated, Any, Dict, List, Self
66

7-
from pydantic import Field
7+
from pydantic import Field, PlainSerializer, PlainValidator
88

99
from ethereum_test_base_types import (
1010
BlobSchedule,
@@ -29,6 +29,7 @@
2929
Transaction,
3030
TransactionReceipt,
3131
)
32+
from ethereum_test_vm import Opcode, Opcodes
3233
from pytest_plugins.custom_logging import get_logger
3334

3435
logger = get_logger(__name__)
@@ -175,6 +176,40 @@ def print(self):
175176
tx.print()
176177

177178

179+
_opcode_synonyms = {
180+
"KECCAK256": "SHA3",
181+
}
182+
183+
184+
def validate_opcode(obj: Any) -> Opcodes | Opcode:
185+
"""Validate an opcode from a string."""
186+
if isinstance(obj, Opcode) or isinstance(obj, Opcodes):
187+
return obj
188+
if isinstance(obj, str):
189+
if obj in _opcode_synonyms:
190+
obj = _opcode_synonyms[obj]
191+
for op in Opcodes:
192+
if str(op) == obj:
193+
return op
194+
raise Exception(f"Unable to validate {obj} (type={type(obj)})")
195+
196+
197+
class OpcodeCount(EthereumTestRootModel):
198+
"""Opcode count returned from the evm tool."""
199+
200+
root: Dict[
201+
Annotated[Opcodes, PlainValidator(validate_opcode), PlainSerializer(lambda o: str(o))], int
202+
]
203+
204+
def __add__(self, other: Self) -> Self:
205+
"""Add two instances of opcode count dictionaries."""
206+
assert isinstance(other, OpcodeCount), f"Incompatible type {type(other)}"
207+
new_dict = self.model_dump() | other.model_dump()
208+
for match_key in self.root.keys() & other.root.keys():
209+
new_dict[match_key] = self.root[match_key] + other.root[match_key]
210+
return self.__class__(new_dict)
211+
212+
178213
class Result(CamelModel):
179214
"""Result of a transition tool output."""
180215

@@ -202,6 +237,7 @@ class Result(CamelModel):
202237
BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator
203238
] = None
204239
traces: Traces | None = None
240+
opcode_count: OpcodeCount | None = None
205241

206242

207243
class TransitionToolInput(CamelModel):

ethereum_clis/clis/evmone.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class EvmOneTransitionTool(TransitionTool):
3939
binary: Path
4040
cached_version: Optional[str] = None
4141
trace: bool
42+
supports_opcode_count: ClassVar[bool] = True
4243

4344
def __init__(
4445
self,

ethereum_clis/transition_tool.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ethereum_test_types import Alloc, Environment, Transaction
2626

2727
from .cli_types import (
28+
OpcodeCount,
2829
Traces,
2930
TransactionReceipt,
3031
TransactionTraces,
@@ -71,6 +72,7 @@ class TransitionTool(EthereumCLI):
7172
t8n_use_server: bool = False
7273
server_url: str | None = None
7374
process: Optional[subprocess.Popen] = None
75+
supports_opcode_count: ClassVar[bool] = False
7476

7577
supports_xdist: ClassVar[bool] = True
7678

@@ -248,6 +250,13 @@ def _evaluate_filesystem(
248250
"--state.chainid",
249251
str(t8n_data.chain_id),
250252
]
253+
if self.supports_opcode_count:
254+
args.extend(
255+
[
256+
"--opcode.count",
257+
"opcodes.json",
258+
]
259+
)
251260

252261
if self.trace:
253262
args.append("--trace")
@@ -308,6 +317,20 @@ def _evaluate_filesystem(
308317
output = TransitionToolOutput.model_validate(
309318
output_contents, context={"exception_mapper": self.exception_mapper}
310319
)
320+
if self.supports_opcode_count:
321+
opcode_count_file_path = Path(temp_dir.name) / "opcodes.json"
322+
if opcode_count_file_path.exists():
323+
opcode_count = OpcodeCount.model_validate_json(opcode_count_file_path.read_text())
324+
output.result.opcode_count = opcode_count
325+
326+
if debug_output_path:
327+
dump_files_to_directory(
328+
debug_output_path,
329+
{
330+
"opcodes.json": opcode_count.model_dump(),
331+
},
332+
)
333+
311334
if self.trace:
312335
output.result.traces = self.collect_traces(
313336
output.result.receipts, temp_dir, debug_output_path

ethereum_test_specs/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing_extensions import Self
1616

1717
from ethereum_clis import Result, TransitionTool
18+
from ethereum_clis.cli_types import OpcodeCount
1819
from ethereum_test_base_types import to_hex
1920
from ethereum_test_execution import BaseExecute, ExecuteFormat, LabeledExecuteFormat
2021
from ethereum_test_fixtures import (
@@ -75,6 +76,7 @@ class BaseTest(BaseModel):
7576
_operation_mode: OpMode | None = PrivateAttr(None)
7677
_gas_optimization: int | None = PrivateAttr(None)
7778
_gas_optimization_max_gas_limit: int | None = PrivateAttr(None)
79+
_opcode_count: OpcodeCount | None = PrivateAttr(None)
7880

7981
expected_benchmark_gas_used: int | None = None
8082
skip_gas_used_validation: bool = False
@@ -130,6 +132,7 @@ def from_test(
130132
)
131133
new_instance._request = base_test._request
132134
new_instance._operation_mode = base_test._operation_mode
135+
new_instance._opcode_count = base_test._opcode_count
133136
return new_instance
134137

135138
@classmethod

ethereum_test_specs/blockchain.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,12 @@ def generate_block_data(
531531
slow_request=self.is_tx_gas_heavy_test(),
532532
)
533533

534+
if transition_tool_output.result.opcode_count is not None:
535+
if self._opcode_count is None:
536+
self._opcode_count = transition_tool_output.result.opcode_count
537+
else:
538+
self._opcode_count += transition_tool_output.result.opcode_count
539+
534540
# One special case of the invalid transactions is the blob gas used,
535541
# since this value is not included in the transition tool result, but
536542
# it is included in the block header, and some clients check it before
@@ -746,6 +752,9 @@ def make_fixture(
746752
)
747753
self.check_exception_test(exception=invalid_blocks > 0)
748754
self.verify_post_state(t8n, t8n_state=alloc)
755+
info = {}
756+
if self._opcode_count is not None:
757+
info["opcode_count"] = self._opcode_count.model_dump()
749758
return BlockchainFixture(
750759
fork=fork,
751760
genesis=genesis.header,
@@ -760,6 +769,7 @@ def make_fixture(
760769
blob_schedule=FixtureBlobSchedule.from_blob_schedule(fork.blob_schedule()),
761770
chain_id=self.chain_id,
762771
),
772+
info=info,
763773
)
764774

765775
def make_hive_fixture(
@@ -812,6 +822,9 @@ def make_hive_fixture(
812822
self.verify_post_state(t8n, t8n_state=alloc)
813823

814824
# Create base fixture data, common to all fixture formats
825+
info = {}
826+
if self._opcode_count is not None:
827+
info["opcode_count"] = self._opcode_count.model_dump()
815828
fixture_data = {
816829
"fork": fork,
817830
"genesis": genesis.header,
@@ -825,6 +838,7 @@ def make_hive_fixture(
825838
chain_id=self.chain_id,
826839
blob_schedule=FixtureBlobSchedule.from_blob_schedule(fork.blob_schedule()),
827840
),
841+
"info": info,
828842
}
829843

830844
# Add format-specific fields

0 commit comments

Comments
 (0)