From efdf63a4d24731491c90d42000bc622d877cbc9b Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Thu, 31 Jul 2025 19:55:14 +0000 Subject: [PATCH 1/9] feat(clis): Trace types --- src/ethereum_clis/__init__.py | 2 + src/ethereum_clis/transition_tool.py | 37 +++++---- src/ethereum_clis/types.py | 119 ++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 17 deletions(-) diff --git a/src/ethereum_clis/__init__.py b/src/ethereum_clis/__init__.py index 9f807a8b517..f555cae91e0 100644 --- a/src/ethereum_clis/__init__.py +++ b/src/ethereum_clis/__init__.py @@ -13,6 +13,7 @@ from .types import ( BlockExceptionWithMessage, Result, + Traces, TransactionExceptionWithMessage, TransitionToolOutput, ) @@ -35,6 +36,7 @@ "NethtestFixtureConsumer", "NimbusTransitionTool", "Result", + "Traces", "TransactionExceptionWithMessage", "TransitionTool", "TransitionToolOutput", diff --git a/src/ethereum_clis/transition_tool.py b/src/ethereum_clis/transition_tool.py index 0541fa81ed1..84de1c412b4 100644 --- a/src/ethereum_clis/transition_tool.py +++ b/src/ethereum_clis/transition_tool.py @@ -27,7 +27,9 @@ from .ethereum_cli import EthereumCLI from .file_utils import dump_files_to_directory, write_json_file from .types import ( + Traces, TransactionReceipt, + TransactionTraces, TransitionToolContext, TransitionToolInput, TransitionToolOutput, @@ -54,7 +56,7 @@ class TransitionTool(EthereumCLI): implementations. """ - traces: List[List[List[Dict]]] | None = None + traces: List[Traces] | None = None registered_tools: List[Type["TransitionTool"]] = [] default_tool: Optional[Type["TransitionTool"]] = None @@ -107,13 +109,13 @@ def reset_traces(self): """Reset the internal trace storage for a new test to begin.""" self.traces = None - def append_traces(self, new_traces: List[List[Dict]]): + def append_traces(self, new_traces: Traces): """Append a list of traces of a state transition to the current list.""" if self.traces is None: self.traces = [] self.traces.append(new_traces) - def get_traces(self) -> List[List[List[Dict]]] | None: + def get_traces(self) -> List[Traces] | None: """Return the accumulated traces.""" return self.traces @@ -122,22 +124,21 @@ def collect_traces( receipts: List[TransactionReceipt], temp_dir: tempfile.TemporaryDirectory, debug_output_path: str = "", - ) -> None: + ) -> Traces: """Collect the traces from the t8n tool output and store them in the traces list.""" - traces: List[List[Dict]] = [] + traces: Traces = Traces(root=[]) + temp_dir_path = Path(temp_dir.name) for i, r in enumerate(receipts): trace_file_name = f"trace-{i}-{r.transaction_hash}.jsonl" + trace_file_path = temp_dir_path / trace_file_name if debug_output_path: shutil.copy( - os.path.join(temp_dir.name, trace_file_name), - os.path.join(debug_output_path, trace_file_name), + trace_file_path, + Path(debug_output_path) / trace_file_name, ) - with open(os.path.join(temp_dir.name, trace_file_name), "r") as trace_file: - tx_traces: List[Dict] = [] - for trace_line in trace_file.readlines(): - tx_traces.append(json.loads(trace_line)) - traces.append(tx_traces) + traces.append(TransactionTraces.from_file(trace_file_path)) self.append_traces(traces) + return traces @dataclass class TransitionToolData: @@ -292,7 +293,9 @@ def _evaluate_filesystem( output_contents, context={"exception_mapper": self.exception_mapper} ) if self.trace: - self.collect_traces(output.result.receipts, temp_dir, debug_output_path) + output.result.traces = self.collect_traces( + output.result.receipts, temp_dir, debug_output_path + ) temp_dir.cleanup() @@ -390,7 +393,9 @@ def _evaluate_server( ) if self.trace: - self.collect_traces(output.result.receipts, temp_dir, debug_output_path) + output.result.traces = self.collect_traces( + output.result.receipts, temp_dir, debug_output_path + ) temp_dir.cleanup() if debug_output_path: @@ -450,7 +455,9 @@ def _evaluate_stream( ) if self.trace: - self.collect_traces(output.result.receipts, temp_dir, debug_output_path) + output.result.traces = self.collect_traces( + output.result.receipts, temp_dir, debug_output_path + ) temp_dir.cleanup() return output diff --git a/src/ethereum_clis/types.py b/src/ethereum_clis/types.py index a30e55aed4f..01562f92cf4 100644 --- a/src/ethereum_clis/types.py +++ b/src/ethereum_clis/types.py @@ -1,10 +1,20 @@ """Types used in the transition tool interactions.""" -from typing import Annotated, List +import json +from pathlib import Path +from typing import Annotated, Any, Dict, List, Self from pydantic import Field -from ethereum_test_base_types import BlobSchedule, Bloom, Bytes, CamelModel, Hash, HexNumber +from ethereum_test_base_types import ( + BlobSchedule, + Bloom, + Bytes, + CamelModel, + EthereumTestRootModel, + Hash, + HexNumber, +) from ethereum_test_exceptions import ( BlockException, ExceptionMapperValidator, @@ -36,6 +46,110 @@ class RejectedTransaction(CamelModel): ] +class TraceLine(CamelModel): + """Single trace line contained in the traces output.""" + + pc: int + op: int + gas: HexNumber + gas_cost: HexNumber | None = None + mem_size: int + stack: List[HexNumber | None] + depth: int + refund: int + op_name: str + error: str | None = None + + def are_equivalent(self, other: Self) -> bool: + """Return True if the only difference is the gas counter.""" + return self.model_dump(mode="python", exclude={"gas", "gas_cost"}) == other.model_dump( + mode="python", exclude={"gas", "gas_cost"} + ) + + +class TransactionTraces(CamelModel): + """Traces of a single transaction.""" + + traces: List[TraceLine] + output: str | None = None + gas_used: HexNumber | None = None + + @classmethod + def from_file(cls, trace_file_path: Path) -> Self: + """Read a single transaction's traces from a .jsonl file.""" + trace_lines = trace_file_path.read_text().splitlines() + trace_dict: Dict[str, Any] = {} + if "gasUsed" in trace_lines[-1] and "output" in trace_lines[-1]: + trace_dict |= json.loads(trace_lines.pop()) + trace_dict["traces"] = [TraceLine.model_validate_json(line) for line in trace_lines] + return cls.model_validate(trace_dict) + + @staticmethod + def remove_gas(traces: List[TraceLine]): + """ + Remove the GAS operation opcode result from the stack to make comparison possible + even if the gas has been pushed to the stack. + """ + for i in range(1, len(traces)): + trace = traces[i] + previous_trace = traces[i - 1] + if previous_trace.op_name == "GAS" and trace.depth == previous_trace.depth: + # Remove the result of calling `Op.GAS` from the stack. + trace.stack[-1] = None + + def are_equivalent(self, other: Self, enable_post_processing: bool) -> bool: + """Return True if the only difference is the gas counter.""" + if len(self.traces) != len(other.traces): + return False + if self.output != other.output: + return False + if self.gas_used != other.gas_used and not enable_post_processing: + return False + own_traces = self.traces.copy() + other_traces = other.traces.copy() + if enable_post_processing: + TransactionTraces.remove_gas(own_traces) + TransactionTraces.remove_gas(other_traces) + for i in range(len(self.traces)): + if not own_traces[i].are_equivalent(other_traces[i]): + return False + return True + + def print(self): + """Print the traces in a readable format.""" + for exec_step, trace in enumerate(self.traces): + print(f"Step {exec_step}:") + print(trace.model_dump_json(indent=2)) + print() + + +class Traces(EthereumTestRootModel): + """Traces returned from the transition tool for all transactions executed.""" + + root: List[TransactionTraces] + + def append(self, item: TransactionTraces): + """Append the transaction traces to the current list.""" + self.root.append(item) + + def are_equivalent(self, other: Self | None, enable_post_processing: bool) -> bool: + """Return True if the only difference is the gas counter.""" + if other is None: + return False + if len(self.root) != len(other.root): + return False + for i in range(len(self.root)): + if not self.root[i].are_equivalent(other.root[i], enable_post_processing): + return False + return True + + def print(self): + """Print the traces in a readable format.""" + for tx_number, tx in enumerate(self.root): + print(f"Transaction {tx_number}:") + tx.print() + + class Result(CamelModel): """Result of a transition tool output.""" @@ -60,6 +174,7 @@ class Result(CamelModel): block_exception: Annotated[ BlockExceptionWithMessage | UndefinedException | None, ExceptionMapperValidator ] = None + traces: Traces | None = None class TransitionToolInput(CamelModel): From 7b1d0bdc6125af6838e46b9a7cc4b3c00679d14e Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Thu, 31 Jul 2025 19:56:30 +0000 Subject: [PATCH 2/9] feat(specs): Add gas optimization to state tests --- src/ethereum_test_specs/base.py | 3 + src/ethereum_test_specs/debugging.py | 15 ++-- src/ethereum_test_specs/state.py | 121 ++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/ethereum_test_specs/base.py b/src/ethereum_test_specs/base.py index c8c1306bacd..1d5f6a8c0c0 100644 --- a/src/ethereum_test_specs/base.py +++ b/src/ethereum_test_specs/base.py @@ -56,6 +56,8 @@ class OpMode(StrEnum): CONSENSUS = "consensus" BENCHMARKING = "benchmarking" + OPTIMIZE_GAS = "optimize-gas" + OPTIMIZE_GAS_POST_PROCESSING = "optimize-gas-post-processing" class BaseTest(BaseModel): @@ -65,6 +67,7 @@ class BaseTest(BaseModel): _request: pytest.FixtureRequest | None = PrivateAttr(None) _operation_mode: OpMode | None = PrivateAttr(None) + _gas_optimization: int | None = PrivateAttr(None) expected_benchmark_gas_used: int | None = None diff --git a/src/ethereum_test_specs/debugging.py b/src/ethereum_test_specs/debugging.py index 979f32620f0..a647aa472e4 100644 --- a/src/ethereum_test_specs/debugging.py +++ b/src/ethereum_test_specs/debugging.py @@ -1,21 +1,16 @@ """Test spec debugging tools.""" -import pprint -from typing import Dict, List +from typing import List +from ethereum_clis import Traces -def print_traces(traces: List[List[List[Dict]]] | None): + +def print_traces(traces: List[Traces] | None): """Print the traces from the transition tool for debugging.""" if traces is None: print("Traces not collected. Use `--traces` to see detailed execution information.") return print("Printing traces for debugging purposes:") - pp = pprint.PrettyPrinter(indent=2) for block_number, block in enumerate(traces): print(f"Block {block_number}:") - for tx_number, tx in enumerate(block): - print(f"Transaction {tx_number}:") - for exec_step, trace in enumerate(tx): - print(f"Step {exec_step}:") - pp.pprint(trace) - print() + block.print() diff --git a/src/ethereum_test_specs/state.py b/src/ethereum_test_specs/state.py index 7eae27a89c5..7052faf751e 100644 --- a/src/ethereum_test_specs/state.py +++ b/src/ethereum_test_specs/state.py @@ -6,7 +6,7 @@ import pytest from pydantic import Field -from ethereum_clis import TransitionTool +from ethereum_clis import TransitionTool, TransitionToolOutput from ethereum_test_base_types import HexNumber from ethereum_test_exceptions import BlockException, EngineAPIError, TransactionException from ethereum_test_execution import ( @@ -74,6 +74,73 @@ class StateTest(BaseTest): "state_test_only": "Only generate a state test fixture", } + def verify_modified_gas_limit( + self, + *, + t8n: TransitionTool, + base_tool_output: TransitionToolOutput, + fork: Fork, + current_gas_limit: int, + pre_alloc: Alloc, + env: Environment, + enable_post_processing: bool, + ) -> bool: + """Verify a new lower gas limit yields the same transaction outcome.""" + base_traces = base_tool_output.result.traces + assert base_traces is not None, "Traces not collected for gas optimization" + new_tx = self.tx.copy(gas_limit=current_gas_limit).with_signature_and_sender() + modified_tool_output = t8n.evaluate( + transition_tool_data=TransitionTool.TransitionToolData( + alloc=pre_alloc, + txs=[new_tx], + env=env, + fork=fork, + chain_id=self.chain_id, + reward=0, # Reward on state tests is always zero + blob_schedule=fork.blob_schedule(), + state_test=True, + ), + debug_output_path=self.get_next_transition_tool_output_path(), + slow_request=self.is_tx_gas_heavy_test(), + ) + modified_traces = modified_tool_output.result.traces + assert modified_traces is not None, "Traces not collected for gas optimization" + if not base_traces.are_equivalent( + modified_tool_output.result.traces, + enable_post_processing, + ): + return False + try: + self.post.verify_post_alloc(modified_tool_output.alloc) + except Exception: + return False + try: + verify_transactions( + txs=[new_tx], + result=modified_tool_output.result, + transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, + ) + except Exception: + return False + if len(base_tool_output.alloc.root) != len(modified_tool_output.alloc.root): + return False + if modified_tool_output.alloc.root.keys() != modified_tool_output.alloc.root.keys(): + return False + for k in base_tool_output.alloc.root.keys(): + if k not in modified_tool_output.alloc: + return False + base_account = base_tool_output.alloc[k] + modified_account = modified_tool_output.alloc[k] + if (modified_account is None) != (base_account is None): + return False + if ( + modified_account is not None + and base_account is not None + and base_account.nonce != modified_account.nonce + ): + return False + return True + @classmethod def discard_fixture_format_by_marks( cls, @@ -213,6 +280,58 @@ def make_state_test_fixture( pprint(transition_tool_output.alloc) raise e + if ( + self._operation_mode == OpMode.OPTIMIZE_GAS + or self._operation_mode == OpMode.OPTIMIZE_GAS_POST_PROCESSING + ): + enable_post_processing = self._operation_mode == OpMode.OPTIMIZE_GAS_POST_PROCESSING + base_tool_output = transition_tool_output + + assert base_tool_output.result.traces is not None, "Traces not found." + + # First try reducing the gas limit only by one, if the validation fails, it means + # that the traces change even with the slightest modification to the gas. + if self.verify_modified_gas_limit( + t8n=t8n, + base_tool_output=base_tool_output, + fork=fork, + current_gas_limit=self.tx.gas_limit - 1, + pre_alloc=pre_alloc, + env=env, + enable_post_processing=enable_post_processing, + ): + minimum_gas_limit = 0 + maximum_gas_limit = int(self.tx.gas_limit) + while minimum_gas_limit < maximum_gas_limit: + current_gas_limit = (maximum_gas_limit + minimum_gas_limit) // 2 + if self.verify_modified_gas_limit( + t8n=t8n, + base_tool_output=base_tool_output, + fork=fork, + current_gas_limit=current_gas_limit, + pre_alloc=pre_alloc, + env=env, + enable_post_processing=enable_post_processing, + ): + maximum_gas_limit = current_gas_limit + else: + if current_gas_limit > 16_777_216: + raise Exception("Requires more than the minimum 16_777_216 wanted.") + minimum_gas_limit = current_gas_limit + 1 + + assert self.verify_modified_gas_limit( + t8n=t8n, + base_tool_output=base_tool_output, + fork=fork, + current_gas_limit=minimum_gas_limit, + pre_alloc=pre_alloc, + env=env, + enable_post_processing=enable_post_processing, + ) + self._gas_optimization = current_gas_limit + else: + raise Exception("Impossible to compare.") + if self._operation_mode == OpMode.BENCHMARKING: expected_benchmark_gas_used = self.expected_benchmark_gas_used gas_used = int(transition_tool_output.result.gas_used) From d178bdbe67d49666574df5e54bf2779a2e8437bb Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Thu, 31 Jul 2025 19:58:15 +0000 Subject: [PATCH 3/9] feat(filler): Add gas optimization flags --- src/pytest_plugins/filler/filler.py | 96 +++++++++++++++++++---- src/pytest_plugins/shared/execute_fill.py | 11 +-- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/pytest_plugins/filler/filler.py b/src/pytest_plugins/filler/filler.py index a3715d13ba2..8c05d680d08 100644 --- a/src/pytest_plugins/filler/filler.py +++ b/src/pytest_plugins/filler/filler.py @@ -8,6 +8,7 @@ import configparser import datetime +import json import os import warnings from enum import Enum @@ -18,6 +19,7 @@ import xdist from _pytest.compat import NotSetType from _pytest.terminal import TerminalReporter +from filelock import FileLock from pytest_metadata.plugin import metadata_key # type: ignore from cli.gen_index import generate_fixtures_index @@ -36,6 +38,7 @@ ) from ethereum_test_forks import Fork, get_transition_fork_predecessor, get_transition_forks from ethereum_test_specs import BaseTest +from ethereum_test_specs.base import OpMode from ethereum_test_tools.utility.versioning import ( generate_github_url, get_current_commit_hash_or_tag, @@ -280,6 +283,44 @@ def pytest_addoption(parser: pytest.Parser): ), ) + optimize_gas_group = parser.getgroup( + "optimize gas", + "Arguments defining test gas optimization behavior.", + ) + optimize_gas_group.addoption( + "--optimize-gas", + action="store_true", + dest="optimize_gas", + default=False, + help=( + "Attempt to optimize the gas used in every transaction for the filled tests, " + "then print the minimum amount of gas at which the test still produces a correct " + "post state and the exact same trace." + ), + ) + optimize_gas_group.addoption( + "--optimize-gas-output", + action="store", + dest="optimize_gas_output", + default=Path("optimize-gas-output.json"), + type=Path, + help=( + "Path to the JSON file that is output to the gas optimization. " + "Requires `--optimize-gas`." + ), + ) + optimize_gas_group.addoption( + "--optimize-gas-post-processing", + action="store_true", + dest="optimize_gas_post_processing", + default=False, + help=( + "Post process the traces during gas optimization in order to Account for " + "opcodes that put the current gas in the stack, in order to remove " + "remaining-gas from the comparison." + ), + ) + debug_group = parser.getgroup("debug", "Arguments defining debug behavior") debug_group.addoption( "--evm-dump-dir", @@ -359,16 +400,25 @@ def pytest_configure(config): ): config.option.htmlpath = config.fixture_output.directory / default_html_report_file_path() + config.gas_optimized_tests = {} + if config.getoption("optimize_gas", False): + if config.getoption("optimize_gas_post_processing"): + config.op_mode = OpMode.OPTIMIZE_GAS_POST_PROCESSING + else: + config.op_mode = OpMode.OPTIMIZE_GAS + + config.collect_traces = config.getoption("evm_collect_traces") or config.getoption( + "optimize_gas", False + ) + # Instantiate the transition tool here to check that the binary path/trace option is valid. # This ensures we only raise an error once, if appropriate, instead of for every test. evm_bin = config.getoption("evm_bin") if evm_bin is None: assert TransitionTool.default_tool is not None, "No default transition tool found" - t8n = TransitionTool.default_tool(trace=config.getoption("evm_collect_traces")) + t8n = TransitionTool.default_tool(trace=config.collect_traces) else: - t8n = TransitionTool.from_binary_path( - binary_path=evm_bin, trace=config.getoption("evm_collect_traces") - ) + t8n = TransitionTool.from_binary_path(binary_path=evm_bin, trace=config.collect_traces) if ( isinstance(config.getoption("numprocesses"), int) and config.getoption("numprocesses") > 0 @@ -584,9 +634,7 @@ def t8n( request: pytest.FixtureRequest, evm_bin: Path | None, t8n_server_url: str | None ) -> Generator[TransitionTool, None, None]: """Return configured transition tool.""" - kwargs = { - "trace": request.config.getoption("evm_collect_traces"), - } + kwargs = {"trace": request.config.collect_traces} # type: ignore[attr-defined] if t8n_server_url is not None: kwargs["server_url"] = t8n_server_url if evm_bin is None: @@ -645,7 +693,7 @@ def evm_fixture_verification( try: evm_fixture_verification = FixtureConsumerTool.from_binary_path( binary_path=Path(verify_fixtures_bin), - trace=request.config.getoption("evm_collect_traces"), + trace=request.config.collect_traces, # type: ignore[attr-defined] ) except Exception: if reused_evm_bin: @@ -958,12 +1006,22 @@ def __init__(self, *args, **kwargs): ) group: PreAllocGroup = request.config.pre_alloc_groups[pre_alloc_hash] # type: ignore[annotation-unchecked] self.pre = group.pre - - fixture = self.generate( - t8n=t8n, - fork=fork, - fixture_format=fixture_format, - ) + try: + fixture = self.generate( + t8n=t8n, + fork=fork, + fixture_format=fixture_format, + ) + finally: + if ( + request.config.op_mode == OpMode.OPTIMIZE_GAS + or request.config.op_mode == OpMode.OPTIMIZE_GAS_POST_PROCESSING + ): + gas_optimized_tests = request.config.gas_optimized_tests + assert gas_optimized_tests is not None + # Force adding something to the list, even if it's None, + # to keep track of failed tests in the output file. + gas_optimized_tests[request.node.nodeid] = self._gas_optimization # Post-process for Engine X format (add pre_hash and state diff) if ( @@ -1180,6 +1238,16 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int): session.config.pre_alloc_groups.to_folder(pre_alloc_groups_folder) return + if session.config.getoption("optimize_gas", False): # type: ignore[attr-defined] + output_file = Path(session.config.getoption("optimize_gas_output")) + lock_file_path = output_file.with_suffix(".lock") + assert hasattr(session.config, "gas_optimized_tests") + gas_optimized_tests: Dict[str, int] = session.config.gas_optimized_tests + with FileLock(lock_file_path): + if output_file.exists(): + gas_optimized_tests = json.loads(output_file.read_text()) | gas_optimized_tests + output_file.write_text(json.dumps(gas_optimized_tests, indent=2, sort_keys=True)) + if xdist.is_xdist_worker(session): return diff --git a/src/pytest_plugins/shared/execute_fill.py b/src/pytest_plugins/shared/execute_fill.py index d928ffb5d6f..6427b6164f6 100644 --- a/src/pytest_plugins/shared/execute_fill.py +++ b/src/pytest_plugins/shared/execute_fill.py @@ -1,6 +1,5 @@ """Shared pytest fixtures and hooks for EEST generation modes (fill and execute).""" -from enum import StrEnum, unique from typing import List import pytest @@ -8,19 +7,11 @@ from ethereum_test_execution import BaseExecute, LabeledExecuteFormat from ethereum_test_fixtures import BaseFixture, LabeledFixtureFormat from ethereum_test_specs import BaseTest +from ethereum_test_specs.base import OpMode from ethereum_test_types import EOA, Alloc from ..spec_version_checker.spec_version_checker import EIPSpecTestItem - -@unique -class OpMode(StrEnum): - """Operation mode for the fill and execute.""" - - CONSENSUS = "consensus" - BENCHMARKING = "benchmarking" - - ALL_FIXTURE_PARAMETERS = { "genesis_environment", "env", From dc4c5fca9659f8ecf80cf34e339e01c6cd651bce Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Thu, 31 Jul 2025 19:59:20 +0000 Subject: [PATCH 4/9] feat(command): src/cli/modify_static_test_gas_limits.py --- pyproject.toml | 1 + src/cli/modify_static_test_gas_limits.py | 204 +++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 src/cli/modify_static_test_gas_limits.py diff --git a/pyproject.toml b/pyproject.toml index 188cfc81ac5..ba175d8bc61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ fillerconvert = "cli.fillerconvert.fillerconvert:main" groupstats = "cli.show_pre_alloc_group_stats:main" extract_config = "cli.extract_config:extract_config" compare_fixtures = "cli.compare_fixtures:main" +modify_static_test_gas_limits = "cli.modify_static_test_gas_limits:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/cli/modify_static_test_gas_limits.py b/src/cli/modify_static_test_gas_limits.py new file mode 100644 index 00000000000..025419ab7ff --- /dev/null +++ b/src/cli/modify_static_test_gas_limits.py @@ -0,0 +1,204 @@ +""" +Command to scan and overwrite the static tests' gas limits to new optimized value given in the +input file. +""" + +import json +import re +from pathlib import Path +from typing import Dict, List, Set + +import click +import yaml + +from ethereum_test_base_types import EthereumTestRootModel, HexNumber, ZeroPaddedHexNumber +from ethereum_test_specs import StateStaticTest +from pytest_plugins.filler.static_filler import NoIntResolver + + +class GasLimitDict(EthereumTestRootModel): + """Formatted JSON file with new gas limits in each test.""" + + root: Dict[str, int | None] + + def unique_files(self) -> Set[Path]: + """Return a list of unique test files.""" + files = set() + for test in self.root: + filename, _ = test.split("::") + files.add(Path(filename)) + return files + + def get_tests_by_file_path(self, file: Path | str) -> Set[str]: + """Return a list of all tests that belong to a given file path.""" + tests = set() + for test in self.root: + current_file, _ = test.split("::") + if current_file == str(file): + tests.add(test) + return tests + + +class StaticTestFile(EthereumTestRootModel): + """A static test file.""" + + root: Dict[str, StateStaticTest] + + +def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: bool, verbose: bool): + """Perform some checks on the fixtures contained in the specified directory.""" + test_dict = GasLimitDict.model_validate_json(input_path.read_text()) + for test_file in test_dict.unique_files(): + tests = test_dict.get_tests_by_file_path(test_file) + test_file_contents = test_file.read_text() + if test_file.suffix == ".yml" or test_file.suffix == ".yaml": + loaded_yaml = yaml.load(test_file.read_text(), Loader=NoIntResolver) + try: + parsed_test_file = StaticTestFile.model_validate(loaded_yaml) + except Exception as e: + raise Exception( + f"Unable to parse file {test_file}: {json.dumps(loaded_yaml, indent=2)}" + ) from e + else: + parsed_test_file = StaticTestFile.model_validate_json(test_file_contents) + assert len(parsed_test_file.root) == 1, f"File {test_file} contains more than one test." + _, parsed_test = parsed_test_file.root.popitem() + if len(parsed_test.transaction.gas_limit) != 1: + if dry_run or verbose: + print( + f"Test file {test_file} contains more than one test (after parsing), skipping." + ) + continue + current_gas_limit = int(parsed_test.transaction.gas_limit[0]) + if max_gas_limit is not None and current_gas_limit <= max_gas_limit: + # Nothing to do, finished + for test in tests: + test_dict.root.pop(test) + continue + gas_values: List[int] = [] + for gas_value in [test_dict.root[test] for test in tests]: + if gas_value is None: + if dry_run or verbose: + print( + f"Test file {test_file} contains at least one test that cannot " + "be updated, skipping." + ) + continue + else: + gas_values.append(gas_value) + new_gas_limit = max(gas_values) + modified_new_gas_limit = ((new_gas_limit // 100000) + 1) * 100000 + if verbose: + print( + f"Changing exact new gas limit ({new_gas_limit}) to " + f"rounded ({modified_new_gas_limit})" + ) + new_gas_limit = modified_new_gas_limit + if max_gas_limit is not None and new_gas_limit > max_gas_limit: + if dry_run or verbose: + print(f"New gas limit ({new_gas_limit}) exceeds max ({max_gas_limit})") + continue + + if dry_run or verbose: + print(f"Test file {test_file} requires modification ({new_gas_limit})") + + potential_types = [int, HexNumber, ZeroPaddedHexNumber] + substitute_pattern = None + substitute_string = None + + attempted_patterns = [] + + for current_type in potential_types: + potential_substitute_pattern = rf"\b{current_type(current_gas_limit)}\b" + potential_substitute_string = f"{current_type(new_gas_limit)}" + if ( + re.search( + potential_substitute_pattern, test_file_contents, flags=re.RegexFlag.MULTILINE + ) + is not None + ): + substitute_pattern = potential_substitute_pattern + substitute_string = potential_substitute_string + break + + attempted_patterns.append(potential_substitute_pattern) + + assert substitute_pattern is not None, ( + f"Current gas limit ({attempted_patterns}) not found in {test_file}" + ) + assert substitute_string is not None + + new_test_file_contents = re.sub(substitute_pattern, substitute_string, test_file_contents) + + assert test_file_contents != new_test_file_contents, "Could not modify test file" + + if dry_run: + continue + + test_file.write_text(new_test_file_contents) + for test in tests: + test_dict.root.pop(test) + + # Modify the test + if dry_run: + return + + # Write changes to the input file + input_path.write_text(test_dict.model_dump_json(indent=2)) + + +MAX_GAS_LIMIT = 16_777_216 + + +@click.command() +@click.option( + "--input", + "-i", + "input_str", + type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True), + required=True, + help="The input json file or directory containing json listing the new gas limits for the " + "static test files files.", +) +@click.option( + "--max-gas-limit", + default=MAX_GAS_LIMIT, + expose_value=True, + help="Gas limit that triggers a test modification, and also the maximum value that a test " + "should have after modification.", +) +@click.option( + "--dry-run", + "-d", + "dry_run", + is_flag=True, + default=False, + expose_value=True, + help="Don't modify any files, simply print operations to be performed.", +) +@click.option( + "--verbose", + "-v", + "verbose", + is_flag=True, + default=False, + expose_value=True, + help="Print extra information.", +) +def main(input_str: str, max_gas_limit, dry_run: bool, verbose: bool): + """Perform some checks on the fixtures contained in the specified directory.""" + input_path = Path(input_str) + if not dry_run: + # Always dry-run first before actually modifying + _check_fixtures( + input_path=input_path, + max_gas_limit=max_gas_limit, + dry_run=True, + verbose=False, + ) + _check_fixtures( + input_path=input_path, + max_gas_limit=max_gas_limit, + dry_run=dry_run, + verbose=verbose, + ) From 9299ce9240c6306f0cf4cc918354f7ee929989cf Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Thu, 31 Jul 2025 21:20:22 +0000 Subject: [PATCH 5/9] docs: Document new feature --- docs/navigation.md | 1 + docs/writing_tests/gas_optimization.md | 93 ++++++++++++++++++++++++++ docs/writing_tests/index.md | 1 + 3 files changed, 95 insertions(+) create mode 100644 docs/writing_tests/gas_optimization.md diff --git a/docs/navigation.md b/docs/navigation.md index 9fc4c9d49cc..928c079c28c 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -20,6 +20,7 @@ * [Code Standards](writing_tests/code_standards.md) * [Exception Tests](writing_tests/exception_tests.md) * [Using and Extending Fork Methods](writing_tests/fork_methods.md) + * [Gas Optimization](writing_tests/gas_optimization.md) * [Referencing an EIP Spec Version](writing_tests/reference_specification.md) * [EIP Checklist Generation](writing_tests/eip_checklist.md) * [Testing Checklist Templates](writing_tests/checklist_templates/index.md) diff --git a/docs/writing_tests/gas_optimization.md b/docs/writing_tests/gas_optimization.md new file mode 100644 index 00000000000..1458d62aa30 --- /dev/null +++ b/docs/writing_tests/gas_optimization.md @@ -0,0 +1,93 @@ +# Gas Optimization + +The `--optimize-gas` feature helps find the minimum gas limit required for transactions to execute correctly while maintaining the same execution trace and post-state. This is useful for creating more efficient test cases and understanding the exact gas requirements of specific operations. + +## Basic Usage + +Enable gas optimization for all tests: + +```bash +uv run fill --optimize-gas +``` + +## Output Configuration + +Specify a custom output file for gas optimization results: + +```bash +uv run fill --optimize-gas --optimize-gas-output=my_gas_results.json path/to/some/test/to/optimize +``` + +## Post-Processing Mode + +Enable post-processing to handle opcodes that put the current gas in the stack (like `GAS` opcode): + +```bash +uv run fill --optimize-gas --optimize-gas-post-processing +``` + +## How It Works + +The gas optimization algorithm uses a binary search approach: + +1. **Initial Validation**: First tries reducing the gas limit by 1 to verify when even minimal changes affect the execution trace +2. **Binary Search**: Uses binary search between 0 and the original gas limit to find the minimum viable gas limit +3. **Verification**: For each candidate gas limit, it verifies: + - Execution traces are equivalent (with optional post-processing) + - Post-state allocation matches the expected result + - Transaction validation passes + - Account states remain consistent +4. **Result**: Outputs the minimum gas limit that still produces correct execution + +## Output Format + +The optimization results are saved to a JSON file (default: `optimize-gas-output.json`) containing: + +- Test identifiers as keys of the JSON object +- Optimized gas limits in each value or `null` if the optimization failed. + +## Use Cases + +- **Test Efficiency**: Create tests with minimal gas requirements +- **Gas Analysis**: Understand exact gas costs for specific operations +- **Regression Testing**: Ensure gas optimizations don't break test correctness +- **Performance Testing**: Benchmark gas usage across different scenarios + +## Limitations + +- Only works with state tests (not blockchain tests) +- Requires trace collection to be enabled +- May significantly increase test execution time due to multiple trial runs +- Some tests may not be optimizable if they require the exact original gas limit + +## Integration with Test Writing + +When writing tests, you can use gas optimization to: + +1. **Optimize Existing Tests**: Run `--optimize-gas` on your test suite to find more efficient gas limits +2. **Validate Gas Requirements**: Ensure your tests use the minimum necessary gas +3. **Create Efficient Test Cases**: Use the optimized gas limits in your test specifications +4. **Benchmark Changes**: Compare gas usage before and after modifications + +## Example Workflow + +```bash +# 1. Write your test +# 2. Run with gas optimization +uv run fill --optimize-gas --optimize-gas-output=optimization_results.json + +# 3. Review the results +cat optimization_results.json + +# 4. Update your test with optimized gas limits if desired +# 5. Re-run to verify correctness +uv run fill +``` + +## Best Practices + +### Leave a Buffer for Future Forks + +When using the optimized gas limits in your tests, it's recommended to add a small buffer (typically 5-10%) above the exact value outputted by the gas optimization. This accounts for potential gas cost changes in future Ethereum forks that might increase the gas requirements for the same operations. + +For example, if the optimization outputs a gas limit of 100,000, consider using 105,000 or 110,000 in your test specification to ensure compatibility with future protocol changes. diff --git a/docs/writing_tests/index.md b/docs/writing_tests/index.md index ebdf6c0d0cc..8f446b47cd2 100644 --- a/docs/writing_tests/index.md +++ b/docs/writing_tests/index.md @@ -25,6 +25,7 @@ For help deciding which test format to select, see [Types of Tests](./types_of_t - [Adding a New Test](./adding_a_new_test.md) - Step-by-step guide to adding new tests - [Writing a New Test](./writing_a_new_test.md) - Detailed guide on writing different test types - [Using and Extending Fork Methods](./fork_methods.md) - How to use fork methods to write fork-adaptive tests +- [Gas Optimization](./gas_optimization.md) - Optimize gas limits in your tests for efficiency and compatibility with future forks. - [Porting tests](./porting_legacy_tests.md): A guide to porting @ethereum/tests to EEST. Please check that your code adheres to the repo's coding standards and read the other pages in this section for more background and an explanation of how to implement state transition and blockchain tests. From 2f5a8d10500da926bf40fef834a8a96a70c7109a Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Thu, 31 Jul 2025 21:22:24 +0000 Subject: [PATCH 6/9] docs: Changelog --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 93807e1d3ba..a1b114ae9fd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -78,6 +78,7 @@ Users can select any of the artifacts depending on their testing needs for their - 🔀 Disabled writing debugging information to the EVM "dump directory" to improve performance. To obtain debug output, the `--evm-dump-dir` flag must now be explicitly set. As a consequence, the now redundant `--skip-evm-dump` option was removed ([#1874](https://github.com/ethereum/execution-spec-tests/pull/1874)). - ✨ Generate unique addresses with Python for compatible static tests, instead of using hard-coded addresses from legacy static test fillers ([#1781](https://github.com/ethereum/execution-spec-tests/pull/1781)). - ✨ Added support for the `--benchmark-gas-values` flag in the `fill` command, allowing a single genesis file to be used across different gas limit settings when generating fixtures. ([#1895](https://github.com/ethereum/execution-spec-tests/pull/1895)). +- ✨ Added `--optimize-gas` flag that allows to binary search the minimum gas limit value for a transaction in a test that still yields the same test result ([#1979](https://github.com/ethereum/execution-spec-tests/pull/1979)). #### `consume` From 1da044d12c90b87c5996d3d1fbfaaf24209aa7aa Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 5 Aug 2025 19:54:53 +0000 Subject: [PATCH 7/9] fix(cli): Improve `modify_static_test_gas_limits` comments --- src/cli/modify_static_test_gas_limits.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/cli/modify_static_test_gas_limits.py b/src/cli/modify_static_test_gas_limits.py index 025419ab7ff..17f0b5137c4 100644 --- a/src/cli/modify_static_test_gas_limits.py +++ b/src/cli/modify_static_test_gas_limits.py @@ -47,10 +47,15 @@ class StaticTestFile(EthereumTestRootModel): def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: bool, verbose: bool): """Perform some checks on the fixtures contained in the specified directory.""" + # Load the test dictionary from the input JSON file test_dict = GasLimitDict.model_validate_json(input_path.read_text()) + + # Iterate through each unique test file that needs modification for test_file in test_dict.unique_files(): tests = test_dict.get_tests_by_file_path(test_file) test_file_contents = test_file.read_text() + + # Parse the test file based on its format (YAML or JSON) if test_file.suffix == ".yml" or test_file.suffix == ".yaml": loaded_yaml = yaml.load(test_file.read_text(), Loader=NoIntResolver) try: @@ -61,20 +66,28 @@ def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: boo ) from e else: parsed_test_file = StaticTestFile.model_validate_json(test_file_contents) + + # Validate that the file contains exactly one test assert len(parsed_test_file.root) == 1, f"File {test_file} contains more than one test." _, parsed_test = parsed_test_file.root.popitem() + + # Skip files with multiple gas limit values if len(parsed_test.transaction.gas_limit) != 1: if dry_run or verbose: print( f"Test file {test_file} contains more than one test (after parsing), skipping." ) continue + + # Get the current gas limit and check if modification is needed current_gas_limit = int(parsed_test.transaction.gas_limit[0]) if max_gas_limit is not None and current_gas_limit <= max_gas_limit: # Nothing to do, finished for test in tests: test_dict.root.pop(test) continue + + # Collect valid gas values for this test file gas_values: List[int] = [] for gas_value in [test_dict.root[test] for test in tests]: if gas_value is None: @@ -86,6 +99,8 @@ def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: boo continue else: gas_values.append(gas_value) + + # Calculate the new gas limit (rounded up to nearest 100,000) new_gas_limit = max(gas_values) modified_new_gas_limit = ((new_gas_limit // 100000) + 1) * 100000 if verbose: @@ -94,6 +109,8 @@ def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: boo f"rounded ({modified_new_gas_limit})" ) new_gas_limit = modified_new_gas_limit + + # Check if the new gas limit exceeds the maximum allowed if max_gas_limit is not None and new_gas_limit > max_gas_limit: if dry_run or verbose: print(f"New gas limit ({new_gas_limit}) exceeds max ({max_gas_limit})") @@ -102,6 +119,7 @@ def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: boo if dry_run or verbose: print(f"Test file {test_file} requires modification ({new_gas_limit})") + # Find the appropriate pattern to replace the current gas limit potential_types = [int, HexNumber, ZeroPaddedHexNumber] substitute_pattern = None substitute_string = None @@ -123,23 +141,26 @@ def _check_fixtures(*, input_path: Path, max_gas_limit: int | None, dry_run: boo attempted_patterns.append(potential_substitute_pattern) + # Validate that a replacement pattern was found assert substitute_pattern is not None, ( f"Current gas limit ({attempted_patterns}) not found in {test_file}" ) assert substitute_string is not None + # Perform the replacement in the test file content new_test_file_contents = re.sub(substitute_pattern, substitute_string, test_file_contents) assert test_file_contents != new_test_file_contents, "Could not modify test file" + # Skip writing changes if this is a dry run if dry_run: continue + # Write the modified content back to the test file test_file.write_text(new_test_file_contents) for test in tests: test_dict.root.pop(test) - # Modify the test if dry_run: return From 5405355911a57465d363332dbc28daa67c861b9a Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 5 Aug 2025 19:55:54 +0000 Subject: [PATCH 8/9] fix(clis,specs): Improve logging --- src/ethereum_clis/types.py | 23 ++++++++++++++++++++--- src/ethereum_test_specs/state.py | 18 ++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/ethereum_clis/types.py b/src/ethereum_clis/types.py index 01562f92cf4..4a0815540dd 100644 --- a/src/ethereum_clis/types.py +++ b/src/ethereum_clis/types.py @@ -23,6 +23,9 @@ UndefinedException, ) from ethereum_test_types import Alloc, Environment, Transaction, TransactionReceipt +from pytest_plugins.logging import get_logger + +logger = get_logger(__name__) class TransactionExceptionWithMessage(ExceptionWithMessage[TransactionException]): @@ -62,9 +65,12 @@ class TraceLine(CamelModel): def are_equivalent(self, other: Self) -> bool: """Return True if the only difference is the gas counter.""" - return self.model_dump(mode="python", exclude={"gas", "gas_cost"}) == other.model_dump( - mode="python", exclude={"gas", "gas_cost"} - ) + self_dict = self.model_dump(mode="python", exclude={"gas", "gas_cost"}) + other_dict = other.model_dump(mode="python", exclude={"gas", "gas_cost"}) + if self_dict != other_dict: + logger.debug(f"Trace lines are not equivalent: {self_dict} != {other_dict}.") + return False + return True class TransactionTraces(CamelModel): @@ -100,18 +106,25 @@ def remove_gas(traces: List[TraceLine]): def are_equivalent(self, other: Self, enable_post_processing: bool) -> bool: """Return True if the only difference is the gas counter.""" if len(self.traces) != len(other.traces): + logger.debug( + f"Traces have different lengths: {len(self.traces)} != {len(other.traces)}." + ) return False if self.output != other.output: + logger.debug(f"Traces have different outputs: {self.output} != {other.output}.") return False if self.gas_used != other.gas_used and not enable_post_processing: + logger.debug(f"Traces have different gas used: {self.gas_used} != {other.gas_used}.") return False own_traces = self.traces.copy() other_traces = other.traces.copy() if enable_post_processing: + logger.debug("Removing gas from traces (enable_post_processing=True).") TransactionTraces.remove_gas(own_traces) TransactionTraces.remove_gas(other_traces) for i in range(len(self.traces)): if not own_traces[i].are_equivalent(other_traces[i]): + logger.debug(f"Trace line {i} is not equivalent.") return False return True @@ -140,7 +153,11 @@ def are_equivalent(self, other: Self | None, enable_post_processing: bool) -> bo return False for i in range(len(self.root)): if not self.root[i].are_equivalent(other.root[i], enable_post_processing): + logger.debug(f"Trace file {i} is not equivalent.") return False + else: + logger.debug(f"Trace file {i} is equivalent.") + logger.debug("All traces are equivalent.") return True def print(self): diff --git a/src/ethereum_test_specs/state.py b/src/ethereum_test_specs/state.py index 7052faf751e..1f08817e383 100644 --- a/src/ethereum_test_specs/state.py +++ b/src/ethereum_test_specs/state.py @@ -30,12 +30,15 @@ ) from ethereum_test_forks import Fork from ethereum_test_types import Alloc, Environment, Transaction +from pytest_plugins.logging import get_logger from .base import BaseTest, OpMode from .blockchain import Block, BlockchainTest, Header from .debugging import print_traces from .helpers import verify_transactions +logger = get_logger(__name__) + class StateTest(BaseTest): """Filler type that tests transactions over the period of a single block.""" @@ -109,10 +112,13 @@ def verify_modified_gas_limit( modified_tool_output.result.traces, enable_post_processing, ): + logger.debug(f"Traces are not equivalent (gas_limit={current_gas_limit})") return False try: self.post.verify_post_alloc(modified_tool_output.alloc) - except Exception: + except Exception as e: + logger.debug(f"Post alloc is not equivalent (gas_limit={current_gas_limit})") + logger.debug(e) return False try: verify_transactions( @@ -120,25 +126,33 @@ def verify_modified_gas_limit( result=modified_tool_output.result, transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, ) - except Exception: + except Exception as e: + logger.debug(f"Transactions are not equivalent (gas_limit={current_gas_limit})") + logger.debug(e) return False if len(base_tool_output.alloc.root) != len(modified_tool_output.alloc.root): + logger.debug(f"Post alloc is not equivalent (gas_limit={current_gas_limit})") return False if modified_tool_output.alloc.root.keys() != modified_tool_output.alloc.root.keys(): + logger.debug(f"Post alloc is not equivalent (gas_limit={current_gas_limit})") return False for k in base_tool_output.alloc.root.keys(): if k not in modified_tool_output.alloc: + logger.debug(f"Post alloc is not equivalent (gas_limit={current_gas_limit})") return False base_account = base_tool_output.alloc[k] modified_account = modified_tool_output.alloc[k] if (modified_account is None) != (base_account is None): + logger.debug(f"Post alloc is not equivalent (gas_limit={current_gas_limit})") return False if ( modified_account is not None and base_account is not None and base_account.nonce != modified_account.nonce ): + logger.debug(f"Post alloc is not equivalent (gas_limit={current_gas_limit})") return False + logger.debug(f"Gas limit is equivalent (gas_limit={current_gas_limit})") return True @classmethod From 735214d7ea99c84e46d6e47540b41ab7e51b8bff Mon Sep 17 00:00:00 2001 From: Mario Vega Date: Tue, 5 Aug 2025 19:58:18 +0000 Subject: [PATCH 9/9] feat(filler): Add `--optimize-gas-max-gas-limit` flag --- src/ethereum_test_specs/base.py | 1 + src/ethereum_test_specs/state.py | 10 ++++++++-- src/pytest_plugins/filler/filler.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/ethereum_test_specs/base.py b/src/ethereum_test_specs/base.py index 1d5f6a8c0c0..bd9539b7524 100644 --- a/src/ethereum_test_specs/base.py +++ b/src/ethereum_test_specs/base.py @@ -68,6 +68,7 @@ class BaseTest(BaseModel): _request: pytest.FixtureRequest | None = PrivateAttr(None) _operation_mode: OpMode | None = PrivateAttr(None) _gas_optimization: int | None = PrivateAttr(None) + _gas_optimization_max_gas_limit: int | None = PrivateAttr(None) expected_benchmark_gas_used: int | None = None diff --git a/src/ethereum_test_specs/state.py b/src/ethereum_test_specs/state.py index 1f08817e383..4f2a8a1abce 100644 --- a/src/ethereum_test_specs/state.py +++ b/src/ethereum_test_specs/state.py @@ -329,9 +329,15 @@ def make_state_test_fixture( ): maximum_gas_limit = current_gas_limit else: - if current_gas_limit > 16_777_216: - raise Exception("Requires more than the minimum 16_777_216 wanted.") minimum_gas_limit = current_gas_limit + 1 + if ( + self._gas_optimization_max_gas_limit is not None + and minimum_gas_limit > self._gas_optimization_max_gas_limit + ): + raise Exception( + "Requires more than the minimum " + f"{self._gas_optimization_max_gas_limit} wanted." + ) assert self.verify_modified_gas_limit( t8n=t8n, diff --git a/src/pytest_plugins/filler/filler.py b/src/pytest_plugins/filler/filler.py index 8c05d680d08..2c5d1beb381 100644 --- a/src/pytest_plugins/filler/filler.py +++ b/src/pytest_plugins/filler/filler.py @@ -309,6 +309,17 @@ def pytest_addoption(parser: pytest.Parser): "Requires `--optimize-gas`." ), ) + optimize_gas_group.addoption( + "--optimize-gas-max-gas-limit", + action="store", + dest="optimize_gas_max_gas_limit", + default=None, + type=int, + help=( + "Maximum gas limit for gas optimization, if reached the search will stop and " + "fail for that given test. Requires `--optimize-gas`." + ), + ) optimize_gas_group.addoption( "--optimize-gas-post-processing", action="store_true", @@ -976,6 +987,13 @@ def __init__(self, *args, **kwargs): super(BaseTestWrapper, self).__init__(*args, **kwargs) self._request = request self._operation_mode = request.config.op_mode + if ( + self._operation_mode == OpMode.OPTIMIZE_GAS + or self._operation_mode == OpMode.OPTIMIZE_GAS_POST_PROCESSING + ): + self._gas_optimization_max_gas_limit = request.config.getoption( + "optimize_gas_max_gas_limit", None + ) # Phase 1: Generate pre-allocation groups if fixture_format is BlockchainEngineXFixture and request.config.getoption(