From 2dd38cfb5679875d1d7ef150674eeaa4f488865f Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 1 Apr 2026 10:58:20 +0200 Subject: [PATCH 1/4] fix(ci,test-benchmark): do not generate compute benchmarks (#2606) --- .github/configs/feature.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 9e9837f7141..22063a82cac 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -9,11 +9,11 @@ develop: benchmark: evm-type: benchmark - fill-params: --fork=Osaka --gas-benchmark-values 1,5,10,30,60,100,150 ./tests/benchmark --maxprocesses=30 --dist=worksteal + fill-params: --fork=Osaka --gas-benchmark-values 1,5,10,30,60,100,150 ./tests/benchmark/compute --maxprocesses=30 --dist=worksteal benchmark_fast: evm-type: benchmark - fill-params: --fork=Osaka --gas-benchmark-values 100 ./tests/benchmark + fill-params: --fork=Osaka --gas-benchmark-values 100 ./tests/benchmark/compute feature_only: true bal: From 3037ba4165faa4701e2a257c102b3a4eff30bc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:57:42 +0800 Subject: [PATCH 2/4] feat(test-benchmark): parametrize stateful benchmark stubs from `--address-stubs` (#2562) --- .../testing/src/execution_testing/__init__.py | 2 + .../execution_testing/benchmark/__init__.py | 2 + .../benchmark/stub_config.py | 52 ++++++++ .../benchmark/tests/__init__.py | 1 + .../benchmark/tests/test_stub_config.py | 118 ++++++++++++++++++ .../plugins/shared/benchmarking.py | 4 + pyproject.toml | 2 + tests/benchmark/conftest.py | 29 ++++- .../stateful/bloatnet/test_multi_opcode.py | 20 +-- .../stateful/bloatnet/test_single_opcode.py | 37 +++--- tests/benchmark/stateful/helpers.py | 41 ------ .../{bloatnet => stubs}/stubs_99.json | 0 .../{bloatnet => stubs}/stubs_bloatnet.json | 0 .../{bloatnet => stubs}/stubs_mainnet.json | 0 .../stateful/stubs/stubs_repricing.json | 22 ++++ 15 files changed, 253 insertions(+), 77 deletions(-) create mode 100644 packages/testing/src/execution_testing/benchmark/stub_config.py create mode 100644 packages/testing/src/execution_testing/benchmark/tests/__init__.py create mode 100644 packages/testing/src/execution_testing/benchmark/tests/test_stub_config.py rename tests/benchmark/stateful/{bloatnet => stubs}/stubs_99.json (100%) rename tests/benchmark/stateful/{bloatnet => stubs}/stubs_bloatnet.json (100%) rename tests/benchmark/stateful/{bloatnet => stubs}/stubs_mainnet.json (100%) create mode 100644 tests/benchmark/stateful/stubs/stubs_repricing.json diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index 1511dc0149e..e082013f43c 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -22,6 +22,7 @@ BenchmarkCodeGenerator, ExtCallGenerator, JumpLoopGenerator, + StubConfig, ) from .checklists import EIPChecklist from .exceptions import ( @@ -186,6 +187,7 @@ "StateTest", "StateTestFiller", "Storage", + "StubConfig", "Switch", "TestAddress", "TestAddress2", diff --git a/packages/testing/src/execution_testing/benchmark/__init__.py b/packages/testing/src/execution_testing/benchmark/__init__.py index fb8b71a207f..a2c9b1fe0c7 100644 --- a/packages/testing/src/execution_testing/benchmark/__init__.py +++ b/packages/testing/src/execution_testing/benchmark/__init__.py @@ -8,9 +8,11 @@ ExtCallGenerator, JumpLoopGenerator, ) +from .stub_config import StubConfig __all__ = ( "BenchmarkCodeGenerator", "ExtCallGenerator", "JumpLoopGenerator", + "StubConfig", ) diff --git a/packages/testing/src/execution_testing/benchmark/stub_config.py b/packages/testing/src/execution_testing/benchmark/stub_config.py new file mode 100644 index 00000000000..afca176ef48 --- /dev/null +++ b/packages/testing/src/execution_testing/benchmark/stub_config.py @@ -0,0 +1,52 @@ +"""Benchmark stub configuration model.""" + +import json +import warnings +from pathlib import Path + +from execution_testing.base_types import ( + Address, + EthereumTestBaseModel, +) + + +class StubConfig(EthereumTestBaseModel): + """ + Benchmark stub configuration with prefix-based token extraction. + + Build from an ``AddressStubs`` mapping (via ``--address-stubs``) + or from a JSON file. Use ``extract_tokens`` to derive parameter + lists for any prefix — no hardcoded categories required. + """ + + stubs: dict[str, Address] + + def extract_tokens(self, prefix: str) -> list[str]: + """Return stub keys matching *prefix*.""" + return [k for k in self.stubs if k.startswith(prefix)] + + def parametrize_args( + self, prefix: str, *, caller: str = "" + ) -> tuple[list[str], list[str]]: + """ + Return ``(values, ids)`` for ``metafunc.parametrize``. + + *values* are full stub keys matching *prefix*. + *ids* are the keys with the prefix stripped for clean test output. + Emits a warning when no stubs match. + """ + values = self.extract_tokens(prefix) + ids = [v.removeprefix(prefix) for v in values] + if not values: + label = f" for {caller}" if caller else "" + warnings.warn( + f"stub_parametrize: no stubs matched prefix " + f"'{prefix}'{label}; test will be skipped", + stacklevel=2, + ) + return values, ids + + @classmethod + def from_file(cls, path: Path) -> "StubConfig": + """Load stubs from a JSON file.""" + return cls(stubs=json.loads(path.read_text())) diff --git a/packages/testing/src/execution_testing/benchmark/tests/__init__.py b/packages/testing/src/execution_testing/benchmark/tests/__init__.py new file mode 100644 index 00000000000..d14fbc28f85 --- /dev/null +++ b/packages/testing/src/execution_testing/benchmark/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the benchmark module.""" diff --git a/packages/testing/src/execution_testing/benchmark/tests/test_stub_config.py b/packages/testing/src/execution_testing/benchmark/tests/test_stub_config.py new file mode 100644 index 00000000000..cec70449f68 --- /dev/null +++ b/packages/testing/src/execution_testing/benchmark/tests/test_stub_config.py @@ -0,0 +1,118 @@ +"""Tests for the StubConfig model.""" + +import json +from pathlib import Path + +import pytest + +from execution_testing.benchmark.stub_config import StubConfig + +ADDR = "0x398324972FcE0e89E048c2104f1298031d1931fc" + + +def test_extract_tokens_returns_full_keys() -> None: + """Return full keys matching the prefix.""" + stub_config = StubConfig( + stubs={ + "test_sload_empty_erc20_balanceof_XEN": ADDR, + "test_sload_empty_erc20_balanceof_USDC": ADDR, + "unrelated_key": ADDR, + } + ) + result = stub_config.extract_tokens("test_sload_empty_erc20_balanceof_") + assert result == [ + "test_sload_empty_erc20_balanceof_XEN", + "test_sload_empty_erc20_balanceof_USDC", + ] + + +def test_extract_tokens_no_match() -> None: + """Return empty list when no keys match the prefix.""" + stub_config = StubConfig(stubs={"test_sstore_erc20_approve_XEN": ADDR}) + assert ( + stub_config.extract_tokens("test_sload_empty_erc20_balanceof_") == [] + ) + + +def test_extract_tokens_empty_stubs() -> None: + """Return empty list for empty stubs.""" + stub_config = StubConfig(stubs={}) + assert stub_config.extract_tokens("any_prefix_") == [] + + +@pytest.mark.parametrize( + "prefix", + [ + "test_sload_empty_erc20_balanceof_", + "test_sstore_erc20_approve_", + "test_sstore_erc20_mint_", + "test_mixed_sload_sstore_", + "bloatnet_factory_", + ], +) +def test_extract_tokens_various_prefixes(prefix: str) -> None: + """Extract matching keys for each prefix.""" + stub_config = StubConfig( + stubs={ + f"{prefix}A": ADDR, + f"{prefix}B": ADDR, + "unrelated_key": ADDR, + } + ) + assert stub_config.extract_tokens(prefix) == [ + f"{prefix}A", + f"{prefix}B", + ] + + +def test_parametrize_args_values_and_ids() -> None: + """Return full keys as values and stripped names as ids.""" + stub_config = StubConfig( + stubs={ + "test_sload_empty_erc20_balanceof_XEN": ADDR, + "test_sload_empty_erc20_balanceof_USDC": ADDR, + } + ) + values, ids = stub_config.parametrize_args( + "test_sload_empty_erc20_balanceof_" + ) + assert values == [ + "test_sload_empty_erc20_balanceof_XEN", + "test_sload_empty_erc20_balanceof_USDC", + ] + assert ids == ["XEN", "USDC"] + + +def test_parametrize_args_empty_warns() -> None: + """Emit a warning when no stubs match the prefix.""" + stub_config = StubConfig(stubs={}) + with pytest.warns(UserWarning, match="no stubs matched prefix"): + values, ids = stub_config.parametrize_args( + "missing_prefix_", caller="test_foo" + ) + assert values == [] + assert ids == [] + + +def test_from_file(tmp_path: Path) -> None: + """Load stubs from a JSON file.""" + data = { + "test_sload_empty_erc20_balanceof_XEN": ADDR, + "bloatnet_factory_1kb": ADDR, + } + stub_file = tmp_path / "stubs.json" + stub_file.write_text(json.dumps(data)) + + stub_config = StubConfig.from_file(stub_file) + assert stub_config.extract_tokens("test_sload_empty_erc20_balanceof_") == [ + "test_sload_empty_erc20_balanceof_XEN" + ] + assert stub_config.extract_tokens("bloatnet_factory_") == [ + "bloatnet_factory_1kb" + ] + + +def test_from_file_not_found(tmp_path: Path) -> None: + """Raise FileNotFoundError for missing files.""" + with pytest.raises(FileNotFoundError): + StubConfig.from_file(tmp_path / "nonexistent.json") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/benchmarking.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/benchmarking.py index 4d45cf4655a..c0c86c54b9c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/benchmarking.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/shared/benchmarking.py @@ -80,6 +80,10 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "repricing: Mark test as reference test for gas repricing analysis", ) + config.addinivalue_line( + "markers", + "stub_parametrize(param, prefix): parametrize with matching stubs", + ) # Ensure mutual exclusivity gas_benchmark_values = GasBenchmarkValues.from_config(config) diff --git a/pyproject.toml b/pyproject.toml index 9fd03c6051b..dbe612b13a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -290,6 +290,8 @@ markers = [ "json_state_tests: marks tests as json_state_tests (deselect with '-m \"not json_state_tests\"')", "vm_test: marks tests as vm_test (deselect with '-m \"not vm_test\"')", "eels_base_coverage: Minimized subset selected to preserve high EELS line-coverage parity (select with '-m eels_base_coverage')", + "repricing: marks tests for gas repricing analysis", + "stub_parametrize: parametrize test from address stubs by prefix", ] [tool.coverage.run] diff --git a/tests/benchmark/conftest.py b/tests/benchmark/conftest.py index ab6d0e26c4c..7fd90f7005a 100755 --- a/tests/benchmark/conftest.py +++ b/tests/benchmark/conftest.py @@ -4,15 +4,26 @@ from typing import Any import pytest -from execution_testing import Fork +from execution_testing import Fork, StubConfig DEFAULT_BENCHMARK_FORK = "Prague" +_stub_config_key = pytest.StashKey[StubConfig]() + + +def pytest_configure(config: pytest.Config) -> None: + """Build StubConfig from ``--address-stubs``.""" + address_stubs = config.getoption("address_stubs", default=None) + stubs = address_stubs.root if address_stubs else {} + config.stash[_stub_config_key] = StubConfig(stubs=stubs) + def pytest_generate_tests(metafunc: Any) -> None: """ - Modify test generation to enforce default benchmark fork for benchmark - tests. + Modify test generation for benchmark tests. + + Enforce default benchmark fork and inject stub parametrization + for tests marked with ``@pytest.mark.stub_parametrize``. """ benchmark_dir = Path(__file__).parent test_file_path = Path(metafunc.definition.fspath) @@ -31,6 +42,18 @@ def pytest_generate_tests(metafunc: Any) -> None: benchmark_marker = pytest.mark.valid_from(DEFAULT_BENCHMARK_FORK) metafunc.definition.add_marker(benchmark_marker) + # Inject parametrization from StubConfig for stub_parametrize markers + stub_config = metafunc.config.stash.get(_stub_config_key, None) + if stub_config is not None: + for marker in metafunc.definition.iter_markers("stub_parametrize"): + param_name, prefix = marker.args + kwargs = dict(marker.kwargs) + values, ids = stub_config.parametrize_args( + prefix, caller=metafunc.function.__name__ + ) + kwargs.setdefault("ids", ids) + metafunc.parametrize(param_name, values, **kwargs) + def pytest_ignore_collect(collection_path: Path, config: Any) -> bool | None: """Skip benchmark directory unless explicitly targeted.""" diff --git a/tests/benchmark/stateful/bloatnet/test_multi_opcode.py b/tests/benchmark/stateful/bloatnet/test_multi_opcode.py index 2321541293a..050f3107eb8 100755 --- a/tests/benchmark/stateful/bloatnet/test_multi_opcode.py +++ b/tests/benchmark/stateful/bloatnet/test_multi_opcode.py @@ -25,8 +25,6 @@ APPROVE_SELECTOR, BALANCEOF_SELECTOR, DECREMENT_COUNTER_CONDITION, - FACTORY_STUBS, - MIXED_TOKENS, build_benchmark_txs, ) @@ -58,11 +56,7 @@ # 4. Attack rapidly accesses all contracts per factory stub -@pytest.mark.parametrize( - "factory_stub", - FACTORY_STUBS, - ids=lambda s: s.replace("bloatnet_factory_", "").upper(), -) +@pytest.mark.stub_parametrize("factory_stub", "bloatnet_factory_") @pytest.mark.parametrize( "second_opcode", [Op.EXTCODESIZE, Op.EXTCODECOPY, Op.EXTCODEHASH, Op.STATICCALL, Op.CALL], @@ -225,11 +219,7 @@ def test_bloatnet_balance_opcode( # stressing trie expansion through massive new account creation. -@pytest.mark.parametrize( - "factory_stub", - FACTORY_STUBS, - ids=lambda s: s.replace("bloatnet_factory_", "").upper(), -) +@pytest.mark.stub_parametrize("factory_stub", "bloatnet_factory_") def test_bloatnet_call_value_existing( benchmark_test: BenchmarkTestFiller, pre: Alloc, @@ -411,7 +401,7 @@ def test_bloatnet_call_value_new_account( ) -@pytest.mark.parametrize("token_name", MIXED_TOKENS) +@pytest.mark.stub_parametrize("erc20_stub", "test_mixed_sload_sstore_") @pytest.mark.parametrize( "sload_percent,sstore_percent", [ @@ -428,7 +418,7 @@ def test_mixed_sload_sstore( fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, - token_name: str, + erc20_stub: str, sload_percent: int, sstore_percent: int, ) -> None: @@ -452,7 +442,7 @@ def test_mixed_sload_sstore( # Stub Account erc20_address = pre.deploy_contract( code=Bytecode(), - stub=f"test_mixed_sload_sstore_{token_name}", + stub=erc20_stub, ) # Contract Construction diff --git a/tests/benchmark/stateful/bloatnet/test_single_opcode.py b/tests/benchmark/stateful/bloatnet/test_single_opcode.py index 2cc30d13ad9..060e1399fc4 100644 --- a/tests/benchmark/stateful/bloatnet/test_single_opcode.py +++ b/tests/benchmark/stateful/bloatnet/test_single_opcode.py @@ -42,9 +42,6 @@ BALANCEOF_SELECTOR, DECREMENT_COUNTER_CONDITION, MINT_SELECTOR, - SLOAD_TOKENS, - SSTORE_MINT_TOKENS, - SSTORE_TOKENS, CacheStrategy, build_cache_strategy_blocks, ) @@ -61,20 +58,22 @@ ) -@pytest.mark.parametrize("token_name", SLOAD_TOKENS) +@pytest.mark.stub_parametrize( + "erc20_stub", "test_sload_empty_erc20_balanceof_" +) def test_sload_erc20_generic( benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, - token_name: str, + erc20_stub: str, ) -> None: """Benchmark SLOAD using ERC20 balanceOf on bloatnet.""" # Stub Account erc20_address = pre.deploy_contract( code=Bytecode(), - stub=f"test_sload_empty_erc20_balanceof_{token_name}", + stub=erc20_stub, ) threshold = 100000 @@ -193,7 +192,9 @@ def test_sload_erc20_generic( @pytest.mark.repricing -@pytest.mark.parametrize("token_name", SLOAD_TOKENS) +@pytest.mark.stub_parametrize( + "erc20_stub", "test_sload_empty_erc20_balanceof_" +) @pytest.mark.parametrize("existing_slots", [False, True]) @pytest.mark.parametrize("cache_strategy", list(CacheStrategy)) def test_sload_erc20_balanceof( @@ -202,7 +203,7 @@ def test_sload_erc20_balanceof( fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, - token_name: str, + erc20_stub: str, existing_slots: bool, cache_strategy: CacheStrategy, ) -> None: @@ -210,7 +211,7 @@ def test_sload_erc20_balanceof( # Stub Account erc20_address = pre.deploy_contract( code=Bytecode(), - stub=f"test_sload_empty_erc20_balanceof_{token_name}", + stub=erc20_stub, ) # MEM[0] = function selector @@ -360,14 +361,14 @@ def test_sload_erc20_balanceof( benchmark_test(pre=pre, blocks=blocks, skip_gas_used_validation=True) -@pytest.mark.parametrize("token_name", SSTORE_TOKENS) +@pytest.mark.stub_parametrize("erc20_stub", "test_sstore_erc20_approve_") def test_sstore_erc20_generic( benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, - token_name: str, + erc20_stub: str, ) -> None: """Benchmark SSTORE using ERC20 approve.""" sender = pre.fund_eoa() @@ -377,7 +378,7 @@ def test_sstore_erc20_generic( # Stub Account erc20_address = pre.deploy_contract( code=Bytecode(), - stub=f"test_sstore_erc20_approve_{token_name}", + stub=erc20_stub, ) # MEM[0] = function selector @@ -453,7 +454,7 @@ def test_sstore_erc20_generic( @pytest.mark.repricing @pytest.mark.parametrize("cache_strategy", list(CacheStrategy)) -@pytest.mark.parametrize("token_name", SSTORE_TOKENS) +@pytest.mark.stub_parametrize("erc20_stub", "test_sstore_erc20_approve_") @pytest.mark.parametrize("write_new_value", [False, True]) @pytest.mark.parametrize("existing_slot", [True, False]) def test_sstore_erc20_approve( @@ -462,7 +463,7 @@ def test_sstore_erc20_approve( fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, - token_name: str, + erc20_stub: str, write_new_value: bool, existing_slot: bool, cache_strategy: CacheStrategy, @@ -481,7 +482,7 @@ def test_sstore_erc20_approve( # Stub Account erc20_address = pre.deploy_contract( code=Bytecode(), - stub=f"test_sstore_erc20_approve_{token_name}", + stub=erc20_stub, ) # MEM[0] = function selector @@ -805,7 +806,7 @@ def build_external_call( @pytest.mark.repricing -@pytest.mark.parametrize("token_name", SSTORE_MINT_TOKENS) +@pytest.mark.stub_parametrize("erc20_stub", "test_sstore_erc20_mint_") @pytest.mark.parametrize("existing_slots", [False, True]) @pytest.mark.parametrize("cache_strategy", list(CacheStrategy)) @pytest.mark.parametrize("no_change", [False, True]) @@ -815,7 +816,7 @@ def test_sstore_erc20_mint( fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, - token_name: str, + erc20_stub: str, existing_slots: bool, cache_strategy: CacheStrategy, no_change: bool, @@ -850,7 +851,7 @@ def test_sstore_erc20_mint( # Stub Account erc20_address = pre.deploy_contract( code=Bytecode(), - stub=f"test_sstore_erc20_mint_{token_name}", + stub=erc20_stub, ) mint_amount = 0 if no_change else 1 diff --git a/tests/benchmark/stateful/helpers.py b/tests/benchmark/stateful/helpers.py index 62c6f758ab0..4ed97cf7ecc 100644 --- a/tests/benchmark/stateful/helpers.py +++ b/tests/benchmark/stateful/helpers.py @@ -1,9 +1,7 @@ """Shared constants and helpers for stateful benchmark tests.""" -import json from collections.abc import Callable from enum import Enum -from pathlib import Path from execution_testing import ( AccessList, @@ -22,45 +20,6 @@ ALLOWANCE_SELECTOR = 0xDD62ED3E # allowance(address,address) MINT_SELECTOR = 0x40C10F19 # mint(address,uint256) -# Load token names from stubs_bloatnet.json for test parametrization -_STUBS_FILE = Path(__file__).parent / "bloatnet" / "stubs_bloatnet.json" -with open(_STUBS_FILE) as f: - _STUBS = json.load(f) - -# Extract unique token names for each test type -SLOAD_TOKENS = [ - k.replace("test_sload_empty_erc20_balanceof_", "") - for k in _STUBS.keys() - if k.startswith("test_sload_empty_erc20_balanceof_") -] -SSTORE_TOKENS = [ - k.replace("test_sstore_erc20_approve_", "") - for k in _STUBS.keys() - if k.startswith("test_sstore_erc20_approve_") -] -SSTORE_MINT_TOKENS = [ - k.replace("test_sstore_erc20_mint_", "") - for k in _STUBS.keys() - if k.startswith("test_sstore_erc20_mint_") -] -MIXED_TOKENS = [ - k.replace("test_mixed_sload_sstore_", "") - for k in _STUBS.keys() - if k.startswith("test_mixed_sload_sstore_") -] - -# Extract factory stub names for factory-based benchmarks, -# sorted by bytecode size -FACTORY_STUBS = sorted( - [k for k in _STUBS if k.startswith("bloatnet_factory_")], - key=lambda name: float( - name.replace("bloatnet_factory_", "") - .replace("kb", "") - .replace("_", ".") - ), -) -assert FACTORY_STUBS, "No factory stubs found matching 'bloatnet_factory_*'" - # Standard While-loop decrement-and-test condition. # # Expects the iteration counter on top of the stack: diff --git a/tests/benchmark/stateful/bloatnet/stubs_99.json b/tests/benchmark/stateful/stubs/stubs_99.json similarity index 100% rename from tests/benchmark/stateful/bloatnet/stubs_99.json rename to tests/benchmark/stateful/stubs/stubs_99.json diff --git a/tests/benchmark/stateful/bloatnet/stubs_bloatnet.json b/tests/benchmark/stateful/stubs/stubs_bloatnet.json similarity index 100% rename from tests/benchmark/stateful/bloatnet/stubs_bloatnet.json rename to tests/benchmark/stateful/stubs/stubs_bloatnet.json diff --git a/tests/benchmark/stateful/bloatnet/stubs_mainnet.json b/tests/benchmark/stateful/stubs/stubs_mainnet.json similarity index 100% rename from tests/benchmark/stateful/bloatnet/stubs_mainnet.json rename to tests/benchmark/stateful/stubs/stubs_mainnet.json diff --git a/tests/benchmark/stateful/stubs/stubs_repricing.json b/tests/benchmark/stateful/stubs/stubs_repricing.json new file mode 100644 index 00000000000..55813daa883 --- /dev/null +++ b/tests/benchmark/stateful/stubs/stubs_repricing.json @@ -0,0 +1,22 @@ +{ + "test_sload_empty_erc20_balanceof_50GB_ERC20": "0x398324972FcE0e89E048c2104f1298031d1931fc", + "test_sload_empty_erc20_balanceof_30GB_ERC20": "0x19fc17d87D946BBA47ca276f7b06Ee5737c4679C", + "test_sload_empty_erc20_balanceof_9_39GB_ERC20": "0xf7EfF64A1b7f3dB550A05fF2635Bb9744B8E21eb", + "test_sload_empty_erc20_balanceof_1GB_ERC20": "0x365cF1A2532919e1adf8650670B9e01e163a441D", + "test_sload_empty_erc20_balanceof_4_23MB_ERC20": "0xFdC419f77993E0E2E9Fed06299E367F6aEe909bE", + "test_sstore_erc20_mint_50GB_ERC20": "0x398324972FcE0e89E048c2104f1298031d1931fc", + "test_sstore_erc20_mint_30GB_ERC20": "0x19fc17d87D946BBA47ca276f7b06Ee5737c4679C", + "test_sstore_erc20_mint_9_39GB_ERC20": "0xf7EfF64A1b7f3dB550A05fF2635Bb9744B8E21eb", + "test_sstore_erc20_mint_1GB_ERC20": "0x365cF1A2532919e1adf8650670B9e01e163a441D", + "test_sstore_erc20_mint_4_23MB_ERC20": "0xFdC419f77993E0E2E9Fed06299E367F6aEe909bE", + "test_sstore_erc20_approve_50GB_ERC20": "0x398324972FcE0e89E048c2104f1298031d1931fc", + "test_sstore_erc20_approve_30GB_ERC20": "0x19fc17d87D946BBA47ca276f7b06Ee5737c4679C", + "test_sstore_erc20_approve_9_39GB_ERC20": "0xf7EfF64A1b7f3dB550A05fF2635Bb9744B8E21eb", + "test_sstore_erc20_approve_1GB_ERC20": "0x365cF1A2532919e1adf8650670B9e01e163a441D", + "test_sstore_erc20_approve_4_23MB_ERC20": "0xFdC419f77993E0E2E9Fed06299E367F6aEe909bE", + "test_mixed_sload_sstore_50GB_ERC20": "0x398324972FcE0e89E048c2104f1298031d1931fc", + "test_mixed_sload_sstore_30GB_ERC20": "0x19fc17d87D946BBA47ca276f7b06Ee5737c4679C", + "test_mixed_sload_sstore_9_39GB_ERC20": "0xf7EfF64A1b7f3dB550A05fF2635Bb9744B8E21eb", + "test_mixed_sload_sstore_1GB_ERC20": "0x365cF1A2532919e1adf8650670B9e01e163a441D", + "test_mixed_sload_sstore_4_23MB_ERC20": "0xFdC419f77993E0E2E9Fed06299E367F6aEe909bE" +} From 2df7916c6613a2eed5c6c3351d41791e2b6e8e8c Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 1 Apr 2026 15:05:10 +0200 Subject: [PATCH 3/4] fix(test-forks): exclude transition target fork from `--until` boundary (#2607) --- .../plugins/forks/tests/test_forks.py | 57 ++++++++++++++ .../src/execution_testing/forks/helpers.py | 12 +++ .../forks/tests/test_forks.py | 76 +++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py index ab05a4747a6..57697c6a8c1 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py @@ -7,12 +7,19 @@ ) from execution_testing.fixtures import LabeledFixtureFormat from execution_testing.forks import ( + BPO1, + BPO2, + Amsterdam, ArrowGlacier, Fork, forks_from_until, get_deployed_forks, get_forks, ) +from execution_testing.forks.forks.transition import ( + BPO2ToAmsterdamAtTime15k, + OsakaToBPO1AtTime15k, +) from execution_testing.specs import StateTest @@ -260,3 +267,53 @@ def test_all_forks({StateTest.pytest_parameter_name()}): skipped=0, errors=0, ) + + +def test_transition_fork_until_excludes_target( + pytester: pytest.Pytester, +) -> None: + """ + Test that `--until` with a transition fork excludes the + transition's target fork from the selected fork set. + + The "Generating fixtures for:" header printed by + `pytest_report_header` reflects `config.selected_fork_set`. + """ + pytester.makepyfile( + f""" + def test_fork_range({StateTest.pytest_parameter_name()}): + pass + """ + ) + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/" + "pytest_ini_files/pytest-fill.ini" + ) + result = pytester.runpytest( + "-c", + "pytest-fill.ini", + "-v", + "--from", + "OsakaToBPO1AtTime15k", + "--until", + "BPO2ToAmsterdamAtTime15k", + ) + stdout = "\n".join(result.stdout.lines) + # The header line lists the selected fork set; parse it. + assert "Generating fixtures for:" in stdout + header_line = [ + line + for line in result.stdout.lines + if "Generating fixtures for:" in line + ][0] + fork_names = [ + name.strip() + for name in header_line.split("Generating fixtures for:")[1].split(",") + ] + # Strip ANSI codes from the last element. + fork_names[-1] = fork_names[-1].split("\x1b")[0] + assert Amsterdam.name() not in fork_names + assert BPO1.name() in fork_names + assert BPO2.name() in fork_names + assert BPO2ToAmsterdamAtTime15k.name() in fork_names + assert OsakaToBPO1AtTime15k.name() in fork_names diff --git a/packages/testing/src/execution_testing/forks/helpers.py b/packages/testing/src/execution_testing/forks/helpers.py index 3866b8d654c..4ce107f27c5 100644 --- a/packages/testing/src/execution_testing/forks/helpers.py +++ b/packages/testing/src/execution_testing/forks/helpers.py @@ -221,6 +221,12 @@ def get_selected_fork_set( selected_fork_set |= get_from_until_fork_set( ALL_FORKS, forks_from, forks_until ) + # Transition fork comparison operators resolve to + # transitions_to(), so an --until transition fork boundary + # incorrectly includes its target in the normal fork set. + for fork_until in forks_until: + if issubclass(fork_until, TransitionBaseClass): + selected_fork_set.discard(fork_until.transitions_to()) selected_fork_set_with_transitions: Set[ Type[BaseFork | TransitionBaseClass] ] = set() | selected_fork_set @@ -228,6 +234,12 @@ def get_selected_fork_set( for normal_fork in list(selected_fork_set): transition_fork_set = transition_fork_to(normal_fork) selected_fork_set_with_transitions |= transition_fork_set + # Explicitly add transition fork boundaries whose target fork + # was removed above (transition_fork_to won't find them). + if not single_fork: + for fork in forks_from | forks_until: + if issubclass(fork, TransitionBaseClass): + selected_fork_set_with_transitions.add(fork) return selected_fork_set_with_transitions diff --git a/packages/testing/src/execution_testing/forks/tests/test_forks.py b/packages/testing/src/execution_testing/forks/tests/test_forks.py index 2216f68c081..25183a2a222 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/forks/tests/test_forks.py @@ -27,6 +27,7 @@ from ..forks.transition import ( BerlinToLondonAt5, BPO1ToBPO2AtTime15k, + BPO2ToAmsterdamAtTime15k, BPO2ToBPO3AtTime15k, BPO3ToBPO4AtTime15k, CancunToPragueAtTime15k, @@ -45,6 +46,7 @@ forks_from_until, get_deployed_forks, get_forks, + get_selected_fork_set, transition_fork_from_to, transition_fork_to, ) @@ -604,3 +606,77 @@ def test_fork_adapters() -> None: # noqa: D103 assert {Osaka} == ForkSetAdapter.validate_python("Osaka") assert {Osaka} == ForkSetAdapter.validate_python({Osaka}) assert set() == ForkSetAdapter.validate_python("") + + +class TestSelectedForkSetWithTransitionBoundaries: + """Test `get_selected_fork_set` with transition fork boundaries.""" + + @staticmethod + def _normal_forks(fork_set: set) -> set: + """Return the set of normal (non-transition) forks.""" + return {f for f in fork_set if not issubclass(f, TransitionBaseClass)} + + @staticmethod + def _transition_forks(fork_set: set) -> set: + """Return the set of transition forks.""" + return {f for f in fork_set if issubclass(f, TransitionBaseClass)} + + def test_transition_from_and_until(self) -> None: + """Test range with transition forks as both boundaries.""" + result = get_selected_fork_set( + single_fork=set(), + forks_from={OsakaToBPO1AtTime15k}, # type: ignore[arg-type] + forks_until={BPO2ToAmsterdamAtTime15k}, # type: ignore[arg-type] + ) + assert self._normal_forks(result) == {BPO1, BPO2} + assert self._transition_forks(result) == { + OsakaToBPO1AtTime15k, + BPO1ToBPO2AtTime15k, + BPO2ToAmsterdamAtTime15k, + } + + def test_transition_until_excludes_target(self) -> None: + """Transition fork `--until` must not include `transitions_to()`.""" + result = get_selected_fork_set( + single_fork=set(), + forks_from={OsakaToBPO1AtTime15k}, # type: ignore[arg-type] + forks_until={BPO2ToAmsterdamAtTime15k}, # type: ignore[arg-type] + ) + assert Amsterdam not in result + + def test_non_bpo_transition_boundaries(self) -> None: + """Test non-BPO transition fork boundaries.""" + result = get_selected_fork_set( + single_fork=set(), + forks_from={CancunToPragueAtTime15k}, # type: ignore[arg-type] + forks_until={PragueToOsakaAtTime15k}, # type: ignore[arg-type] + ) + assert self._normal_forks(result) == {Prague} + assert self._transition_forks(result) == { + CancunToPragueAtTime15k, + PragueToOsakaAtTime15k, + } + assert Osaka not in result + + def test_normal_boundaries_unchanged(self) -> None: + """Normal fork boundaries still work as before.""" + result = get_selected_fork_set( + single_fork=set(), + forks_from={Prague}, + forks_until={Osaka}, + ) + assert self._normal_forks(result) == {Prague, Osaka} + assert CancunToPragueAtTime15k in result + assert PragueToOsakaAtTime15k in result + + def test_transition_from_normal_until(self) -> None: + """Test transition `--from` with normal `--until`.""" + result = get_selected_fork_set( + single_fork=set(), + forks_from={OsakaToBPO1AtTime15k}, # type: ignore[arg-type] + forks_until={BPO2}, + ) + assert self._normal_forks(result) == {BPO1, BPO2} + assert OsakaToBPO1AtTime15k in result + assert BPO1ToBPO2AtTime15k in result + assert BPO2ToAmsterdamAtTime15k not in result From 65f9b8f792f8af177640ca75ef8d75673e3a8bdd Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Wed, 1 Apr 2026 15:49:47 +0200 Subject: [PATCH 4/4] feat(tests): Add more EIP-7928 test cases (#1903) * feat(planned-tests): Add EIP-7928 planned test cases * feat(test): Increase coverage for BAL static context and OOG scenarios * fix: remove planned test covered by new create_and_oog test * changes from comments on PR #1903 * chore: refactor now that blockchain_test is inside helper --------- Co-authored-by: fselmo --- .../test_block_access_lists_opcodes.py | 524 +++++++++++++++--- .../test_cases.md | 8 +- 2 files changed, 468 insertions(+), 64 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index ca48cf81564..68321d8e3b1 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -25,6 +25,7 @@ Alloc, BalAccountExpectation, BalBalanceChange, + BalCodeChange, BalNonceChange, BalStorageChange, BalStorageSlot, @@ -1888,35 +1889,32 @@ def test_bal_create_oog_code_deposit( ) +@pytest.mark.parametrize( + "original_value", [0, 0x42], ids=["zero_original", "nonzero_original"] +) def test_bal_sstore_static_context( pre: Alloc, blockchain_test: BlockchainTestFiller, + original_value: int, ) -> None: """ - Ensure BAL does not record storage reads when SSTORE fails in static - context. + SSTORE in static context must not leak storage reads into BAL. - Contract A makes STATICCALL to Contract B. Contract B attempts SSTORE, - which should fail immediately without recording any storage reads. + Contract A STATICCALLs Contract B which attempts SSTORE. Contract B + IS in BAL (accessed via STATICCALL) but MUST NOT have storage_reads + — the static check must fire before any implicit SLOAD. """ alice = pre.fund_eoa() - contract_b = pre.deploy_contract(code=Op.SSTORE(0, 5)) + contract_b = pre.deploy_contract( + code=Op.SSTORE(0, 5), + storage={0: original_value} if original_value else {}, + ) - # Contract A makes STATICCALL to Contract B - # The STATICCALL will fail because B tries SSTORE in static context - # But contract_a continues and writes to its own storage contract_a = pre.deploy_contract( - code=Op.STATICCALL( - gas=1_000_000, - address=contract_b, - args_offset=0, - args_size=0, - ret_offset=0, - ret_size=0, - ) - + Op.POP # pop the return value (0 = failure) - + Op.SSTORE(0, 1) # this should succeed (non-static context) + code=Op.SSTORE(0, Op.STATICCALL(gas=1_000_000, address=contract_b)) + + Op.SSTORE(1, 1), # proves execution continued + storage={0: 0xDEAD}, # non-zero so STATICCALL result (0) is detectable ) tx = Transaction( @@ -1943,6 +1941,16 @@ def test_bal_sstore_static_context( storage_changes=[ BalStorageSlot( slot=0x00, + slot_changes=[ + # STATICCALL returns 0 (inner SSTORE + # failed in static context) + BalStorageChange( + block_access_index=1, post_value=0 + ), + ], + ), + BalStorageSlot( + slot=0x01, slot_changes=[ BalStorageChange( block_access_index=1, post_value=1 @@ -1951,89 +1959,304 @@ def test_bal_sstore_static_context( ), ], ), + # Contract B is in BAL (accessed via STATICCALL) + # but MUST NOT have any state touches contract_b: BalAccountExpectation.empty(), } ), ) ], post={ - contract_a: Account(storage={0: 1}), - contract_b: Account(storage={0: 0}), # SSTORE failed + contract_a: Account(storage={0: 0, 1: 1}), + contract_b: Account( + storage={0: original_value} if original_value else {} + ), }, ) -def test_bal_call_with_value_in_static_context( +def blockchain_test_under_static_call( pre: Alloc, blockchain_test: BlockchainTestFiller, + *, + static_call_target: Address, + bal_expectations: Dict[Address, BalAccountExpectation | None], + post: Dict[Address, Account | None] | None = None, + tx_access_list: list[AccessList] | None = None, ) -> None: """ - Ensure BAL does NOT include target address when CALL with value fails - in static context. The static context check must happen BEFORE any - account access or BAL tracking. + Run a blockchain_test that STATICCALLs static_call_target and + verifies BAL expectations. Stores the STATICCALL result to detect + silent failures. """ alice = pre.fund_eoa() - target_starting_balance = 1022 - target = pre.fund_eoa(amount=target_starting_balance) - - caller_starting_balance = 10**18 - caller = pre.deploy_contract( - code=Op.CALL(gas=100_000, address=target, value=1) + Op.STOP, - balance=caller_starting_balance, - ) - - # makes STATICCALL to caller + # Slot 0: STATICCALL result, pre-set to non-zero so writes are + # detectable regardless of return value (0 or 1). static_caller = pre.deploy_contract( - code=Op.STATICCALL(gas=500_000, address=caller) - + Op.SSTORE(0, 1) # prove we continued after STATICCALL returned + code=Op.SSTORE( + 0, Op.STATICCALL(gas=1_000_000, address=static_call_target) + ) + + Op.SSTORE(1, 1), + storage={0: 0xDEAD}, ) tx = Transaction( sender=alice, to=static_caller, - gas_limit=1_000_000, + gas_limit=2_000_000, + access_list=tx_access_list, + ) + + # Inner call fails (returns 0) when forbidden opcodes are tested + # (None values in bal_expectations), succeeds (returns 1) otherwise. + staticcall_result = ( + 0 if any(v is None for v in bal_expectations.values()) else 1 ) + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + static_caller: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=staticcall_result, + ), + ], + ), + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=1), + ], + ), + ], + ), + static_call_target: BalAccountExpectation.empty(), + } + account_expectations.update(bal_expectations) + + _post: Dict[Address, Account | None] = { + static_caller: Account(storage={0: staticcall_result, 1: 1}), + } + if post: + _post.update(post) + blockchain_test( pre=pre, blocks=[ Block( txs=[tx], expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[ - BalNonceChange( - block_access_index=1, post_nonce=1 - ) - ], - ), - static_caller: BalAccountExpectation( - storage_changes=[ - BalStorageSlot( - slot=0x00, - slot_changes=[ - BalStorageChange( - block_access_index=1, post_value=1 - ), - ], - ), - ], - ), - caller: BalAccountExpectation.empty(), - target: None, # explicit check target is NOT in BAL - } + account_expectations=account_expectations ), ) ], + post=_post, + ) + + +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "target_has_code", [False, True], ids=["eoa_target", "contract_target"] +) +def test_bal_call_with_value_in_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + target_is_warm: bool, + target_has_code: bool, +) -> None: + """ + CALL with nonzero value in static context: target NOT in BAL. + + Static check must fire before account access (warm/cold lookup, + code loading). + """ + target_starting_balance = 1022 + if target_has_code: + target = pre.deploy_contract( + code=Op.STOP, balance=target_starting_balance + ) + else: + target = pre.fund_eoa(amount=target_starting_balance) + + caller_starting_balance = 10**18 + caller = pre.deploy_contract( + code=Op.CALL(gas=100_000, address=target, value=1) + Op.STOP, + balance=caller_starting_balance, + ) + + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None + ) + + blockchain_test_under_static_call( + pre, + blockchain_test, + static_call_target=caller, + bal_expectations={target: None}, post={ - # STATICCALL returned, continued - static_caller: Account(storage={0: 1}), - # no transfer occurred, balances unchanged caller: Account(balance=caller_starting_balance), target: Account(balance=target_starting_balance), }, + tx_access_list=access_list, + ) + + +@pytest.mark.parametrize("value", [0, 1], ids=["no_value", "with_value"]) +@pytest.mark.with_all_create_opcodes +def test_bal_create_in_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + create_opcode: Op, + value: int, +) -> None: + """ + CREATE/CREATE2 in static context: created address NOT in BAL. + + Static check must fire before balance check, address computation, + or nonce increment. + """ + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + caller = pre.deploy_contract( + code=Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + + create_opcode( + value=value, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + ) + + Op.STOP, + balance=value, + ) + + would_be_address = compute_create_address( + address=caller, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=create_opcode, + ) + + blockchain_test_under_static_call( + pre, + blockchain_test, + static_call_target=caller, + bal_expectations={would_be_address: None}, + post={ + caller: Account(nonce=1, balance=value), + would_be_address: Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize( + "beneficiary_is_warm", + [False, True], + ids=["cold_beneficiary", "warm_beneficiary"], +) +@pytest.mark.parametrize( + "caller_balance", [0, 100], ids=["no_balance", "with_balance"] +) +def test_bal_selfdestruct_in_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + beneficiary_is_warm: bool, + caller_balance: int, +) -> None: + """ + SELFDESTRUCT in static context: beneficiary NOT in BAL. + + Static check must fire before beneficiary access (warm/cold lookup) + or balance transfer. + """ + beneficiary_balance = 1 + beneficiary = pre.fund_eoa(amount=beneficiary_balance) + caller = pre.deploy_contract( + code=Op.SELFDESTRUCT(address=beneficiary), + balance=caller_balance, + ) + + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] + if beneficiary_is_warm + else None + ) + + blockchain_test_under_static_call( + pre, + blockchain_test, + static_call_target=caller, + bal_expectations={beneficiary: None}, + post={ + caller: Account(balance=caller_balance), + beneficiary: Account(balance=beneficiary_balance), + }, + tx_access_list=access_list, + ) + + +@pytest.mark.with_all_call_opcodes +def test_bal_call_opcode_succeeds_in_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Op, +) -> None: + """ + All call opcodes (without value) succeed in static context. + + Target IS in BAL. Ensures clients don't over-restrict call opcodes + beyond what EIP-214 forbids (only CALL with nonzero value). + """ + target = pre.deploy_contract(code=Op.STOP) + + caller = pre.deploy_contract( + code=call_opcode(address=target) + Op.STOP, + ) + + blockchain_test_under_static_call( + pre, + blockchain_test, + static_call_target=caller, + bal_expectations={ + target: BalAccountExpectation.empty(), + }, + ) + + +def test_bal_callcode_with_value_in_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + CALLCODE with nonzero value succeeds in static context. + + EIP-214 explicitly excludes CALLCODE from write-protection. + """ + target = pre.deploy_contract(code=Op.STOP) + + caller = pre.deploy_contract( + code=Op.CALLCODE(gas=100_000, address=target, value=1) + Op.STOP, + balance=10**18, + ) + + blockchain_test_under_static_call( + pre, + blockchain_test, + static_call_target=caller, + bal_expectations={ + target: BalAccountExpectation.empty(), + }, ) @@ -2549,6 +2772,181 @@ def test_bal_transient_storage_not_tracked( ) +@pytest.mark.parametrize( + "oog_boundary", + [ + OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS, + OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS, + OutOfGasBoundary.SUCCESS, + ], + ids=lambda x: x.value, +) +@pytest.mark.with_all_create_opcodes +def test_bal_create_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + create_opcode: Op, + oog_boundary: OutOfGasBoundary, +) -> None: + """ + CREATE/CREATE2 OOG boundary test at three gas levels. + + OOG_BEFORE_TARGET_ACCESS and OOG_AFTER_TARGET_ACCESS differ by + exactly 1 gas, proving the static cost boundary: below it the + created address is NOT in BAL, at it the address IS in BAL. + """ + alice = pre.fund_eoa() + + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + factory_mstore = Op.MSTORE( + 0, Op.PUSH32(init_code_bytes), new_memory_size=32 + ) + factory_create = create_opcode( + value=0, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + init_code_size=len(init_code_bytes), + ) + factory_sstore = Op.SSTORE(0x00, 1) + factory_code = factory_mstore + factory_create + factory_sstore + + factory = pre.deploy_contract( + code=factory_code, + storage={0x00: 0xDEAD}, + ) + + created_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=create_opcode, + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + create_static_cost = factory_mstore.gas_cost( + fork + ) + factory_create.gas_cost(fork) + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + # 1 gas short of CREATE static cost — no state access + gas_limit = intrinsic_cost + create_static_cost - 1 + elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: + # Exactly the CREATE static cost — address accessed, child + # frame gets 0 gas, CREATE fails, parent OOGs at next opcode + gas_limit = intrinsic_cost + create_static_cost + else: + # Full success: static cost + child frame (63/64 rule) + SSTORE + child_gas = init_code.gas_cost(fork) + remaining_needed = (child_gas * 64 + 62) // 63 + gas_limit = ( + intrinsic_cost + + create_static_cost + + remaining_needed + + factory_sstore.gas_cost(fork) + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=gas_limit, + ) + + account_expectations: Dict[Address, BalAccountExpectation | None] + post: Dict[Address, Account | None] + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + # Created address NOT in BAL — static check failed before access + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation.empty(), + created_address: None, + } + post = { + alice: Account(nonce=1), + factory: Account(nonce=1, storage={0x00: 0xDEAD}), + created_address: Account.NONEXISTENT, + } + elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: + # Created address IS in BAL (accessed during collision check), + # but tx OOGs so all state changes revert — only access is + # recorded, no nonce/code/storage changes. + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation.empty(), + created_address: BalAccountExpectation.empty(), + } + post = { + alice: Account(nonce=1), + factory: Account(nonce=1, storage={0x00: 0xDEAD}), + created_address: Account.NONEXISTENT, + } + else: + # SUCCESS: created address in BAL with nonce and code changes + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ) + ], + ) + ], + ), + created_address: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(Op.STOP), + ) + ], + ), + } + post = { + alice: Account(nonce=1), + factory: Account(nonce=2, storage={0x00: 1}), + created_address: Account(code=Op.STOP), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post=post, + ) + + def test_bal_create_early_failure( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 85e2020cac2..7221c867d23 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,7 +49,7 @@ | `test_bal_7702_double_auth_reset` | Ensure BAL tracks multiple 7702 nonce increments but filters net-zero code change | Single transaction contains two EIP-7702 authorizations for `Alice`: (1) first auth sets delegation `0xef0100\|\|Oracle`, (2) second auth clears delegation back to empty. Transaction sends 10 wei to `Bob`. Two variants: (a) Self-funded: `Alice` is tx sender (one tx nonce bump + two auth bumps → nonce 0→3). (b) Sponsored: `Relayer` is tx sender (`Alice` only in auths → nonce 0→2 for `Alice`, plus one nonce bump for `Relayer`). | Variant (a): BAL **MUST** include `Alice` with `nonce_changes` 0→3. Variant (b): BAL **MUST** include `Alice` with `nonce_changes` 0→2 and `Relayer` with its own `nonce_changes`. For both variants, BAL **MUST NOT** include `code_changes` for `Alice` (net code is empty), **MUST** include `Bob` with `balance_changes` (receives 10 wei), and `Oracle` **MUST NOT** appear in BAL. | ✅ Completed | | `test_bal_7702_double_auth_swap` | Ensure BAL captures final code when double auth swaps delegation targets | `Relayer` sends transaction with two authorizations for Alice: (1) First auth sets delegation to `CONTRACT_A` at nonce=0, (2) Second auth changes delegation to `CONTRACT_B` at nonce=1. Transaction sends 10 wei to Bob. Per EIP-7702, only the last authorization takes effect. | BAL **MUST** include Alice with `nonce_changes` (both auths increment nonce to 2) and `code_changes` (final code is delegation designation for `CONTRACT_B`, not `CONTRACT_A`). Bob: `balance_changes` (receives 10 wei). Relayer: `nonce_changes`. Neither `CONTRACT_A` nor `CONTRACT_B` appear in BAL during delegation setup (never accessed). This ensures BAL shows final state, not intermediate changes. | ✅ Completed | | `test_bal_sstore_and_oog` | Ensure BAL handles OOG during SSTORE execution at various gas boundaries (EIP-2200 stipend and implicit SLOAD) | Alice calls contract that attempts `SSTORE` to cold slot `0x01`. Parameterized: (1) OOG at EIP-2200 stipend check (2300 gas after PUSH opcodes) - fails before implicit SLOAD, (2) OOG at stipend + 1 (2301 gas) - passes stipend check but fails after implicit SLOAD, (3) OOG at exact gas - 1, (4) Successful SSTORE with exact gas. | For case (1): BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes` (fails before implicit SLOAD). For cases (2) and (3): BAL **MUST** include slot `0x01` in `storage_reads` (implicit SLOAD occurred) but **MUST NOT** include in `storage_changes` (write didn't complete). For case (4): BAL **MUST** include slot `0x01` in `storage_changes` only (successful write; read is filtered by builder). | ✅ Completed | -| `test_bal_sstore_static_context` | Ensure BAL does not capture spurious storage access when SSTORE fails in static context | Alice calls contract with `STATICCALL` which attempts `SSTORE` to slot `0x01`. SSTORE must fail before any storage access occurs. | BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes`. Static context check happens before storage access, preventing spurious reads. Alice has `nonce_changes` and `balance_changes` (gas cost). Target contract included with empty changes. | ✅ Completed | +| `test_bal_sstore_static_context` | SSTORE in static context must not leak storage reads into BAL | Contract A STATICCALLs Contract B which attempts `SSTORE`. Parametrized: `original_value` (0, nonzero) to catch clients that perform the implicit SLOAD before the static check. | Contract B IS in BAL (accessed via STATICCALL) but **MUST NOT** have `storage_reads`. | ✅ Completed | | `test_bal_sload_and_oog` | Ensure BAL handles OOG during SLOAD execution correctly | Alice calls contract that attempts `SLOAD` from cold slot `0x01`. Parameterized: (1) OOG at SLOAD opcode (insufficient gas), (2) Successful SLOAD execution. | For OOG case: BAL **MUST NOT** contain slot `0x01` in `storage_reads` since storage wasn't accessed. For success case: BAL **MUST** contain slot `0x01` in `storage_reads`. | ✅ Completed | | `test_bal_balance_and_oog` | Ensure BAL handles OOG during BALANCE opcode execution correctly | Alice calls contract that attempts `BALANCE` opcode on cold target account. Parameterized: (1) OOG at BALANCE opcode (insufficient gas), (2) Successful BALANCE execution. | For OOG case: BAL **MUST NOT** include target account (wasn't accessed). For success case: BAL **MUST** include target account in `account_changes`. | ✅ Completed | | `test_bal_extcodesize_and_oog` | Ensure BAL handles OOG during EXTCODESIZE opcode execution correctly | Alice calls contract that attempts `EXTCODESIZE` opcode on cold target contract. Parameterized: (1) OOG at EXTCODESIZE opcode (insufficient gas), (2) Successful EXTCODESIZE execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | @@ -134,4 +134,10 @@ | `test_bal_2935_selfdestruct_to_history_storage` | Ensure BAL captures `SELFDESTRUCT` to EIP-2935 history storage address | Single block: Transaction where Alice calls contract (pre-funded with 100 wei) that selfdestructs with `HISTORY_STORAGE_ADDRESS` as beneficiary. | BAL **MUST** include at `block_access_index=1`: Alice with `nonce_changes`, contract with `balance_changes` (100→0), `HISTORY_STORAGE_ADDRESS` with `balance_changes` (receives 100 wei). | ✅ Completed | | `test_bal_2935_invalid_calldata_size` | Ensure BAL correctly handles EIP-2935 queries with invalid calldata size (reverts before any storage access) | Parameterized test: Block 1 stores genesis hash via system call. Block 2: Oracle contract calls `HISTORY_STORAGE_ADDRESS` with invalid calldata sizes (0, 31, 33 bytes). EIP-2935 requires exactly 32 bytes calldata; any other size causes immediate revert before storage access. Optional value transfer (0 or 100 wei). | Block 2 BAL **MUST** include: `HISTORY_STORAGE_ADDRESS` with NO `storage_reads` (calldata size check fails before any SLOAD) and NO `balance_changes` (call reverts). Oracle with `storage_reads` [0] (implicit SLOAD from no-op SSTORE), NO `storage_changes`, and `balance_changes` if value > 0 (value stays in oracle on revert). Alice with `nonce_changes`. | ✅ Completed | | `test_bal_create2_selfdestruct_then_recreate_same_block` | Ensure BAL handles **(tx1) create+SELFDESTRUCT** then **(tx2) CREATE2 "resurrection"** of the *same address* in the same block | Parameterized: `@pytest.mark.with_all_create_opcodes` for Tx1 create opcode (CREATE or CREATE2), and whether **A** has a pre-existing balance or not. **Tx1:** `Factory` executes `create_opcode` to deploy contract at **A** (for CREATE2: fixed `salt`, fixed `initcode` → deterministic address). The created contract optionally does `SLOAD/SSTORE` (to prove state touches), then `SELFDESTRUCT(beneficiary=B)` **in the same tx** (so under EIP-6780 the account is actually deleted after Tx1). **Tx2:** `Factory` executes `CREATE2(salt, initcode)` to recreate **A** at the same deterministic address, and this time the runtime code persists (no SELFDESTRUCT). | BAL **MUST** include: **Tx1 (`block_access_index=1`)**: (1) `Factory` with `nonce_changes` (create opcode increments nonce), and `balance_changes` if it endows A. (2) **A** in `account_changes` (it was accessed/created) but **MUST NOT** have persistent `code_changes`, `nonce_changes`, or `storage_changes` (it ends Tx1 non-existent due to same-tx create+SELFDESTRUCT). Any attempted `SSTORE` in A before SELFDESTRUCT **MUST NOT** appear in `storage_changes` (ephemeral). If A had a pre-existing balance, it **MUST** have `balance_changes` reflecting the transfer to B. (3) `B` with `balance_changes` if A had balance transferred on SELFDESTRUCT. **Tx2 (`block_access_index=2`)**: (1) `Factory` with another `nonce_changes`. (2) **A** with `code_changes` (runtime bytecode present), `nonce_changes = 1`, plus any `storage_changes` performed in Tx2. (3) If Tx2 endows or transfers value, include corresponding `balance_changes` for involved accounts. | 🟡 Planned | +| `test_bal_call_with_value_in_static_context` | CALL with nonzero value in static context: target NOT in BAL | Parametrized: `target_is_warm` (cold/warm via access list), `target_has_code` (EOA/contract). Static check must fire before account access. | `target` **MUST NOT** appear in BAL. Balances unchanged. | ✅ Completed | +| `test_bal_create_in_static_context` | CREATE/CREATE2 in static context: created address NOT in BAL | Parametrized: `@pytest.mark.with_all_create_opcodes`, `value` (0/1). Static check must fire before balance check, address computation, or nonce increment. | Created address **MUST NOT** appear in BAL. Factory nonce unchanged. | ✅ Completed | +| `test_bal_selfdestruct_in_static_context` | SELFDESTRUCT in static context: beneficiary NOT in BAL | Parametrized: `beneficiary_is_warm` (cold/warm via access list), `caller_balance` (0/100). Static check must fire before beneficiary access or balance transfer. | `beneficiary` **MUST NOT** appear in BAL. Balances unchanged. | ✅ Completed | +| `test_bal_call_opcode_succeeds_in_static_context` | All call opcodes (no value) succeed in static context | Parametrized: `@pytest.mark.with_all_call_opcodes`. Caller invokes `call_opcode(target)` inside STATICCALL. | `target` **MUST** appear in BAL. Ensures clients don't over-restrict beyond EIP-214. | ✅ Completed | +| `test_bal_callcode_with_value_in_static_context` | CALLCODE with nonzero value succeeds in static context | EIP-214 explicitly excludes CALLCODE from write-protection. Caller invokes `CALLCODE(value=1, target)` inside STATICCALL. | `target` **MUST** appear in BAL. Ensures clients don't apply CALL-with-value restriction to CALLCODE. | ✅ Completed | +| `test_bal_create_and_oog` | CREATE/CREATE2 OOG boundary test at three gas levels | Parametrized: `@pytest.mark.with_all_create_opcodes`, `OutOfGasBoundary` (OOG_BEFORE_TARGET_ACCESS, OOG_AFTER_TARGET_ACCESS, SUCCESS). BEFORE and AFTER differ by 1 gas, proving the static cost boundary. | OOG_BEFORE: created address **MUST NOT** appear in BAL. OOG_AFTER: created address IS in BAL as `empty()` (accessed, state reverted). SUCCESS: created address in BAL with `nonce_changes`/`code_changes`. | ✅ Completed |