diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bfe963e079..a703a9b485 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -29,6 +29,8 @@ Test fixtures for use by clients are available for each release on the [Github r ### ๐Ÿ“‹ Misc +- โœจ Implement EIP-7928 Block-Level Access Lists ([#1719](https://github.com/ethereum/execution-specs/pull/1719)). + ### ๐Ÿงช Test Cases - โœจ Add missing fuzzy-compute benchmark configurations for `KECCAK256`, `CODECOPY`, `CALLDATACOPY`, `RETURNDATACOPY`, `MLOAD`, `MSTORE`, `MSTORE8`, `MCOPY`, `LOG*`, `CALLDATASIZE`, `CALLDATALOAD`, and `RETURNDATASIZE` opcodes ([#1956](https://github.com/ethereum/execution-specs/pull/1956)). @@ -57,6 +59,7 @@ Test fixtures for use by clients are available for each release on the [Github r ### ๐Ÿ“‹ Misc - ๐Ÿž WELDed the EEST tox environments relevant to producing documentation into EELS, and added a tool to cleanly add codespell whitelist entries. ([#1695](https://github.com/ethereum/execution-specs/pull/1659)). +- ๐Ÿž Fix duplicate storage write issues for block access lists EIP-7928 implementation ([#1743](https://github.com/ethereum/execution-specs/pull/1743)). ### ๐Ÿงช Test Cases @@ -76,6 +79,7 @@ Test fixtures for use by clients are available for each release on the [Github r - โœจ Add tests that EIP-1559 and EIP-2930 typed txs are invalid and void before their fork ([#1754](https://github.com/ethereum/execution-specs/pull/1754)). - โœจ Add tests for an old validation rule for gas limit above 5000 ([#1731](https://github.com/ethereum/execution-specs/pull/1731)). - โœจ Add tests for OOG in EXP, LOG and others ([#1686](https://github.com/ethereum/execution-specs/pull/1686)). +- โœจ Make EIP-7934 tests more dynamic and able to handle new header fields added in future forks ([#2022](https://github.com/ethereum/execution-specs/pull/2022)). ## [v5.3.0](https://github.com/ethereum/execution-spec-tests/releases/tag/v5.3.0) - 2025-10-09 diff --git a/packages/testing/src/execution_testing/base_types/__init__.py b/packages/testing/src/execution_testing/base_types/__init__.py index 9655d43ea5..7221fc1799 100644 --- a/packages/testing/src/execution_testing/base_types/__init__.py +++ b/packages/testing/src/execution_testing/base_types/__init__.py @@ -44,6 +44,7 @@ from .pydantic import CamelModel, EthereumTestBaseModel, EthereumTestRootModel from .reference_spec import ReferenceSpec from .serialization import RLPSerializable, SignableRLPSerializable +from .typing_utils import unwrap_annotation __all__ = ( "AccessList", @@ -88,4 +89,5 @@ "to_bytes", "to_hex", "to_json", + "unwrap_annotation", ) diff --git a/packages/testing/src/execution_testing/base_types/typing_utils.py b/packages/testing/src/execution_testing/base_types/typing_utils.py new file mode 100644 index 0000000000..18412663a3 --- /dev/null +++ b/packages/testing/src/execution_testing/base_types/typing_utils.py @@ -0,0 +1,33 @@ +"""Utilities for working with Python type annotations.""" + +from typing import Any, get_args + + +def unwrap_annotation(hint: Any) -> Any: + """ + Recursively unwrap Annotated and Union types to find the actual type. + + This function is useful for introspecting complex type annotations like: + - `Annotated[int, ...]` -> `int` + - `int | None` -> `int` + - `Annotated[int, ...] | None` -> `int` + + Args: + hint: Type annotation to unwrap + + Returns: + The unwrapped base type + """ + type_args = get_args(hint) + if not type_args: + # Base case: simple type with no parameters + return hint + + # For Union types (including Optional), find the first non-None type + for arg in type_args: + if arg is not type(None): + # Recursively unwrap (handles nested Annotated/Union) + return unwrap_annotation(arg) + + # All args were None (shouldn't happen in practice) + return hint diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml index 4b067f50bd..ce92de2959 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/networks.yml @@ -5,7 +5,7 @@ Mainnet: Frontier: 0 Homestead: 1150000 DAOFork: 1920000 - Tangerine: 2463000 + TangerineWhistle: 2463000 SpuriousDragon: 2675000 Byzantium: 4370000 Constantinople: 7280000 diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py index 0f2f0826bb..2e47ecbfdf 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/eth_config/tests/test_execute_eth_config.py @@ -293,7 +293,7 @@ Frontier: 0 Homestead: 1150000 DAOFork: 1920000 - Tangerine: 2463000 + TangerineWhistle: 2463000 SpuriousDragon: 2675000 Byzantium: 4370000 Constantinople: 7280000 diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 1c929709ec..6544826705 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -1208,3 +1208,54 @@ def parametrize_fork( metafunc.parametrize( param_names, param_values, scope="function", indirect=indirect ) + + +def pytest_collection_modifyitems( + config: pytest.Config, items: List[pytest.Item] +) -> None: + """ + Filter tests based on param-level validity markers. + + The pytest_generate_tests hook only considers function-level validity markers. + This hook runs after parametrization and can access all markers including + param-level ones, allowing us to properly filter tests based on param-level + valid_from/valid_until markers. + """ + items_to_remove = [] + + for i, item in enumerate(items): + # Get fork from params if available + params = None + if hasattr(item, "callspec"): + params = item.callspec.params + elif hasattr(item, "params"): + params = item.params + + if not params or "fork" not in params or params["fork"] is None: + continue + + fork: Fork = params["fork"] + + # Get all markers including param-level ones + markers = item.iter_markers() + + # Calculate valid fork set from all markers + # If this raises (e.g., duplicate markers from combining function-level + # and param-level), exit immediately with error + try: + valid_fork_set = ValidityMarker.get_test_fork_set_from_markers( + markers + ) + except Exception as e: + pytest.exit( + f"Error in test '{item.name}': {e}", + returncode=pytest.ExitCode.USAGE_ERROR, + ) + + # If the fork is not in the valid set, mark for removal + if fork not in valid_fork_set: + items_to_remove.append(i) + + # Remove items in reverse order to maintain indices + for i in reversed(items_to_remove): + del items[i] diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py index 6749591d54..4e8aa2d4be 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_bad_validity_markers.py @@ -236,3 +236,73 @@ def test_invalid_validity_markers( errors=1, ) assert error_string in "\n".join(result.stdout.lines) + + +# --- Tests for param-level marker errors --- # + + +param_level_marker_error_test_cases = ( + ( + "param_level_valid_from_with_function_level_valid_from", + ( + """ + import pytest + @pytest.mark.parametrize( + "value", + [ + pytest.param(True, marks=pytest.mark.valid_from("Paris")), + ], + ) + @pytest.mark.valid_from("Berlin") + def test_case(state_test, value): + assert 1 + """, + "Too many 'valid_from' markers applied to test", + ), + ), + ( + "param_level_valid_until_with_function_level_valid_until", + ( + """ + import pytest + @pytest.mark.parametrize( + "value", + [ + pytest.param(True, marks=pytest.mark.valid_until("Cancun")), + ], + ) + @pytest.mark.valid_until("Prague") + def test_case(state_test, value): + assert 1 + """, + "Too many 'valid_until' markers applied to test", + ), + ), +) + + +@pytest.mark.parametrize( + "test_function, error_string", + [test_case for _, test_case in param_level_marker_error_test_cases], + ids=[test_id for test_id, _ in param_level_marker_error_test_cases], +) +def test_param_level_marker_errors( + pytester: pytest.Pytester, error_string: str, test_function: str +) -> None: + """ + Test that combining function-level and param-level validity markers + of the same type produces an error. + + Unlike function-level errors (caught during test generation), param-level + errors are caught during collection and cause pytest to exit immediately. + """ + pytester.makepyfile(test_function) + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + result = pytester.runpytest("-c", "pytest-fill.ini") + + # pytest.exit() causes the run to terminate with no test outcomes + assert result.ret != 0, "Expected non-zero exit code" + stdout = "\n".join(result.stdout.lines) + assert error_string in stdout, f"Expected '{error_string}' in output" 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 7aa4d0cd68..4fe7c3d44f 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 @@ -45,11 +45,14 @@ def test_all_forks({StateTest.pytest_parameter_name()}): ] expected_skipped = 2 # eels doesn't support Constantinople expected_passed = ( - len(forks_under_test) * len(StateTest.supported_fixture_formats) + len([f for f in forks_under_test if not f.ignore()]) + * len(StateTest.supported_fixture_formats) - expected_skipped ) stdout = "\n".join(result.stdout.lines) for test_fork in forks_under_test: + if test_fork.ignore(): + continue for fixture_format in StateTest.supported_fixture_formats: if isinstance(fixture_format, LabeledFixtureFormat): fixture_format_label = fixture_format.label diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py index fbbc1b3123..9379d90b40 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_markers.py @@ -222,3 +222,182 @@ def test_fork_markers( *pytest_args, ) result.assert_outcomes(**outcomes) + + +# --- Tests for param-level validity markers --- # + + +def generate_param_level_marker_test() -> str: + """Generate a test function with param-level fork validity markers.""" + return """ +import pytest + +@pytest.mark.parametrize( + "value", + [ + pytest.param( + True, + id="from_tangerine", + marks=pytest.mark.valid_from("TangerineWhistle"), + ), + pytest.param( + False, + id="from_paris", + marks=pytest.mark.valid_from("Paris"), + ), + ], +) +@pytest.mark.state_test_only +def test_param_level_valid_from(state_test, value): + pass +""" + + +def generate_param_level_valid_until_test() -> str: + """Generate a test function with param-level valid_until markers.""" + return """ +import pytest + +@pytest.mark.parametrize( + "value", + [ + pytest.param( + True, + id="until_cancun", + marks=pytest.mark.valid_until("Cancun"), + ), + pytest.param( + False, + id="until_paris", + marks=pytest.mark.valid_until("Paris"), + ), + ], +) +@pytest.mark.state_test_only +def test_param_level_valid_until(state_test, value): + pass +""" + + +def generate_param_level_mixed_test() -> str: + """Generate a test with both function-level and param-level markers.""" + return """ +import pytest + +@pytest.mark.parametrize( + "value", + [ + pytest.param( + True, + id="all_forks", + marks=pytest.mark.valid_from("TangerineWhistle"), + ), + pytest.param( + False, + id="paris_only", + marks=pytest.mark.valid_from("Paris"), + ), + ], +) +@pytest.mark.valid_until("Cancun") +@pytest.mark.state_test_only +def test_mixed_function_and_param_markers(state_test, value): + pass +""" + + +@pytest.mark.parametrize( + "test_function,pytest_args,outcomes", + [ + pytest.param( + generate_param_level_marker_test(), + ["--from=Paris", "--until=Cancun"], + # from_tangerine: Paris, Shanghai, Cancun = 3 forks + # from_paris: Paris, Shanghai, Cancun = 3 forks + # Total: 6 tests + {"passed": 6, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_from_paris_to_cancun", + ), + pytest.param( + generate_param_level_marker_test(), + ["--from=Berlin", "--until=Shanghai"], + # from_tangerine: Berlin, London, Paris, Shanghai = 4 forks + # from_paris: Paris, Shanghai = 2 forks + # Total: 6 tests + {"passed": 6, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_from_berlin_to_shanghai", + ), + pytest.param( + generate_param_level_marker_test(), + ["--from=Berlin", "--until=London"], + # from_tangerine: Berlin, London = 2 forks + # from_paris: none (Paris > London) + # Total: 2 tests + {"passed": 2, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_from_berlin_to_london", + ), + pytest.param( + generate_param_level_valid_until_test(), + ["--from=Paris", "--until=Prague"], + # until_cancun: Paris, Shanghai, Cancun = 3 forks + # until_paris: Paris = 1 fork + # Total: 4 tests + {"passed": 4, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_until_paris_to_prague", + ), + pytest.param( + generate_param_level_valid_until_test(), + ["--from=Shanghai", "--until=Prague"], + # until_cancun: Shanghai, Cancun = 2 forks + # until_paris: none (Shanghai > Paris) + # Total: 2 tests + {"passed": 2, "failed": 0, "skipped": 0, "errors": 0}, + id="param_level_valid_until_shanghai_to_prague", + ), + pytest.param( + generate_param_level_mixed_test(), + ["--from=Berlin", "--until=Prague"], + # Function marker: valid_until("Cancun") limits to <= Cancun + # all_forks (TangerineWhistle): Berlin, London, Paris, Shanghai, Cancun = 5 + # paris_only: Paris, Shanghai, Cancun = 3 + # Total: 8 tests + {"passed": 8, "failed": 0, "skipped": 0, "errors": 0}, + id="mixed_markers_berlin_to_prague", + ), + pytest.param( + generate_param_level_mixed_test(), + ["--from=Paris", "--until=Shanghai"], + # Function marker: valid_until("Cancun") limits to <= Cancun + # Command line: --until=Shanghai further limits to <= Shanghai + # all_forks: Paris, Shanghai = 2 forks + # paris_only: Paris, Shanghai = 2 forks + # Total: 4 tests + {"passed": 4, "failed": 0, "skipped": 0, "errors": 0}, + id="mixed_markers_paris_to_shanghai", + ), + ], +) +def test_param_level_validity_markers( + pytester: pytest.Pytester, + test_function: str, + outcomes: dict, + pytest_args: List[str], +) -> None: + """ + Test param-level validity markers (valid_from, valid_until on pytest.param). + + The pytest_collection_modifyitems hook filters tests based on param-level + markers after parametrization, allowing different parameter values to have + different fork validity ranges. + """ + pytester.makepyfile(test_function) + pytester.copy_example( + name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini" + ) + result = pytester.runpytest( + "-c", + "pytest-fill.ini", + "-v", + *pytest_args, + ) + result.assert_outcomes(**outcomes) diff --git a/packages/testing/src/execution_testing/client_clis/clis/erigon.py b/packages/testing/src/execution_testing/client_clis/clis/erigon.py index f99462d1d0..8e1f51b851 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/erigon.py +++ b/packages/testing/src/execution_testing/client_clis/clis/erigon.py @@ -57,6 +57,7 @@ class ErigonExceptionMapper(ExceptionMapper): BlockException.INVALID_LOG_BLOOM: "invalid bloom", } mapping_regex = { + BlockException.INVALID_BLOCK_ACCESS_LIST: r"invalid block access list|block access list mismatch", TransactionException.GAS_LIMIT_EXCEEDS_MAXIMUM: ( r"invalid block, txnIdx=\d+,.*gas limit too high" ), diff --git a/packages/testing/src/execution_testing/client_clis/clis/evmone.py b/packages/testing/src/execution_testing/client_clis/clis/evmone.py index bd762c4af3..6e2d404ccb 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/evmone.py +++ b/packages/testing/src/execution_testing/client_clis/clis/evmone.py @@ -47,6 +47,12 @@ class EvmOneTransitionTool(TransitionTool): supports_opcode_count: ClassVar[bool] = True supports_blob_params: ClassVar[bool] = True + # evmone uses space-separated fork names for some forks + fork_name_map: ClassVar[Dict[str, str]] = { + "TangerineWhistle": "Tangerine Whistle", + "SpuriousDragon": "Spurious Dragon", + } + def __init__( self, *, diff --git a/packages/testing/src/execution_testing/client_clis/transition_tool.py b/packages/testing/src/execution_testing/client_clis/transition_tool.py index 149b0b837d..1ac1b4cffe 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -145,6 +145,7 @@ class TransitionTool(EthereumCLI): supports_xdist: ClassVar[bool] = True supports_blob_params: ClassVar[bool] = False + fork_name_map: ClassVar[Dict[str, str]] = {} @abstractmethod def __init__( @@ -326,13 +327,19 @@ def _evaluate_filesystem( } output_paths["body"] = os.path.join("output", "txs.rlp") + # Get fork name and apply any tool-specific mapping + fork_name = ( + t8n_data.fork_name_if_supports_blob_params + if self.supports_blob_params + else t8n_data.fork_name + ) + fork_name = self.fork_name_map.get(fork_name, fork_name) + # Construct args for evmone-t8n binary args = [ str(self.binary), "--state.fork", - t8n_data.fork_name_if_supports_blob_params - if self.supports_blob_params - else t8n_data.fork_name, + fork_name, "--input.alloc", input_paths["alloc"], "--input.env", diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 77c58d984e..786d6dd34e 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -28,6 +28,7 @@ computed_field, model_validator, ) +from pydantic_core import PydanticUndefined from execution_testing.base_types import ( Address, @@ -42,6 +43,7 @@ HexNumber, Number, ZeroPaddedHexNumber, + unwrap_annotation, ) from execution_testing.exceptions import ( EngineAPIError, @@ -203,6 +205,9 @@ class FixtureHeader(CamelModel): requests_hash: ( Annotated[Hash, HeaderForkRequirement("requests")] | None ) = Field(None) + block_access_list_hash: ( + Annotated[Hash, HeaderForkRequirement("bal_hash")] | None + ) = Field(None, alias="blockAccessListHash") fork: Fork | None = Field(None, exclude=True) @@ -271,6 +276,76 @@ def block_hash(self) -> Hash: """Compute the RLP of the header.""" return self.rlp.keccak256() + @classmethod + def get_default_from_annotation( + cls, + fork: Fork, + field_name: str, + field_hint: Any, + block_number: int = 0, + timestamp: int = 0, + ) -> Any: + """ + Get appropriate default value for a header field based on its type hint. + + This method handles: + 1. Fork requirement checking - only returns a default if the fork requires the field + 2. Model-defined defaults - uses the field's default value if available + 3. Type-based defaults - constructs defaults based on the field type + + Args: + fork: Fork to check requirements against + field_name: Name of the field + field_hint: Type annotation of the field + block_number: Block number for fork requirement checking (default: 0) + timestamp: Timestamp for fork requirement checking (default: 0) + + Returns: + Default value appropriate for the field type, or None if + the field is not required by the fork + + Raises: + TypeError: If the field type is not supported and no default value + is defined in the model. This indicates that support for the type + needs to be added or an explicit default must be provided. + """ + # Check if this field has a HeaderForkRequirement annotation + header_fork_requirement = HeaderForkRequirement.get_from_annotation( + field_hint + ) + if header_fork_requirement is not None: + # Only provide a default if the fork requires this field + if not header_fork_requirement.required(fork, block_number, timestamp): + return None + + # Check if the field has a default value defined in the model + if field_name in cls.model_fields: + field_info = cls.model_fields[field_name] + if field_info.default is not None and field_info.default is not PydanticUndefined: + return field_info.default + if field_info.default_factory is not None: + return field_info.default_factory() # type: ignore[call-arg] + + # Unwrap type annotations to get the actual type + actual_type = unwrap_annotation(field_hint) + + # Construct default based on type + if actual_type == ZeroPaddedHexNumber: + return ZeroPaddedHexNumber(0) + elif actual_type == Hash: + return Hash(0) + elif actual_type == Address: + return Address(0) + elif actual_type == Bytes: + return Bytes(b"") + else: + # Unsupported type - raise an error to catch this during development + raise TypeError( + f"Cannot generate default value for field '{field_name}' " + f"with unsupported type '{actual_type}'. " + f"Add support for this type or provide a default value explicitly." + ) + @classmethod def genesis(cls, fork: Fork, env: Environment, state_root: Hash) -> Self: """Get the genesis header for the given fork.""" @@ -287,6 +362,11 @@ def genesis(cls, fork: Fork, env: Environment, state_root: Hash) -> Self: "requests_hash": Requests() if fork.header_requests_required(block_number=0, timestamp=0) else None, + "block_access_list_hash": ( + BlockAccessList().rlp_hash + if fork.header_bal_hash_required(block_number=0, timestamp=0) + else None + ), "fork": fork, } return cls(**environment_values, **extras) @@ -416,6 +496,14 @@ def from_fixture_header( "Invalid header for engine_newPayload" ) + if fork.engine_execution_payload_block_access_list( + block_number=header.number, timestamp=header.timestamp + ): + if block_access_list is None: + raise ValueError( + f"`block_access_list` is required in engine `ExecutionPayload` for >={fork}." + ) + execution_payload = FixtureExecutionPayload.from_fixture_header( header=header, transactions=transactions, @@ -538,9 +626,6 @@ def strip_block_number_computed_field(cls, data: Any) -> Any: ) withdrawals: List[FixtureWithdrawal] | None = None execution_witness: WitnessChunk | None = None - block_access_list: BlockAccessList | None = Field( - None, description="EIP-7928 Block Access List" - ) fork: Fork | None = Field(None, exclude=True) @computed_field(alias="blocknumber") # type: ignore[prop-decorator] @@ -562,9 +647,6 @@ def with_rlp(self, txs: List[Transaction]) -> "FixtureBlock": if self.withdrawals is not None: block.append([w.to_serializable_list() for w in self.withdrawals]) - if self.block_access_list is not None: - block.append(self.block_access_list.to_list()) - return FixtureBlock( **self.model_dump(), rlp=eth_rlp.encode(block), diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 009d13837f..fb5e9b4c76 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -25,6 +25,8 @@ Paris, Prague, Shanghai, + SpuriousDragon, + TangerineWhistle, ) from .forks.transition import ( BerlinToLondonAt5, @@ -97,6 +99,8 @@ "Frontier", "GrayGlacier", "Homestead", + "TangerineWhistle", + "SpuriousDragon", "InvalidForkError", "Istanbul", "London", diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index a061497b25..584f92ba5d 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -2,6 +2,7 @@ from abc import ABCMeta, abstractmethod from typing import ( + TYPE_CHECKING, Any, Callable, ClassVar, @@ -18,6 +19,9 @@ Union, ) +if TYPE_CHECKING: + from execution_testing.fixtures.blockchain import FixtureHeader + from execution_testing.base_types import ( AccessList, Address, @@ -351,6 +355,14 @@ def header_requests_required( """Return true if the header must contain beacon chain requests.""" pass + @classmethod + @abstractmethod + def header_bal_hash_required( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """Return true if the header must contain block access list hash.""" + pass + # Gas related abstract methods @classmethod @@ -589,6 +601,12 @@ def get_reward(cls, *, block_number: int = 0, timestamp: int = 0) -> int: # Transaction related abstract methods + @classmethod + @abstractmethod + def supports_protected_txs(cls) -> bool: + """Return whether the fork implements EIP-155 transaction protection""" + pass + @classmethod @abstractmethod def tx_types( @@ -743,6 +761,17 @@ def engine_new_payload_target_blobs_per_block( """ pass + @classmethod + @abstractmethod + def engine_execution_payload_block_access_list( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """ + Return `True` if the engine api version requires execution payload to + include a `block_access_list`. + """ + pass + @classmethod @abstractmethod def engine_payload_attribute_target_blobs_per_block( @@ -953,3 +982,13 @@ def non_bpo_ancestor(cls) -> Type["BaseFork"]: def children(cls) -> Set[Type["BaseFork"]]: """Return the children forks.""" return set(cls._children) + + @classmethod + @abstractmethod + def build_default_block_header( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> "FixtureHeader": + """ + Build a default block header for this fork with the given attributes. + """ + pass diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 62c108c65f..3c8bb9976f 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1,10 +1,13 @@ """All Ethereum fork class definitions.""" +from __future__ import annotations + from dataclasses import replace from hashlib import sha256 from os.path import realpath from pathlib import Path from typing import ( + TYPE_CHECKING, Callable, Dict, List, @@ -15,12 +18,17 @@ Tuple, ) +if TYPE_CHECKING: + from execution_testing.fixtures.blockchain import FixtureHeader + + from execution_testing.base_types import ( AccessList, Address, BlobSchedule, Bytes, ForkBlobSchedule, + ZeroPaddedHexNumber, ) from execution_testing.base_types.conversions import BytesConvertible from execution_testing.vm import ( @@ -947,6 +955,14 @@ def header_requests_required( del block_number, timestamp return False + @classmethod + def header_bal_hash_required( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """At genesis, header must not contain block access list hash.""" + del block_number, timestamp + return False + @classmethod def engine_new_payload_version( cls, *, block_number: int = 0, timestamp: int = 0 @@ -987,6 +1003,14 @@ def engine_new_payload_requests( del block_number, timestamp return False + @classmethod + def engine_execution_payload_block_access_list( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """At genesis, payloads do not have block access list.""" + del block_number, timestamp + return False + @classmethod def engine_new_payload_target_blobs_per_block( cls, @@ -1056,6 +1080,13 @@ def get_reward(cls, *, block_number: int = 0, timestamp: int = 0) -> int: del block_number, timestamp return 5_000_000_000_000_000_000 + @classmethod + def supports_protected_txs(cls) -> bool: + """ + At Genesis, fork does not have support for EIP-155 protected transactions. + """ + return False + @classmethod def tx_types( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1352,6 +1383,54 @@ def pre_allocation_blockchain( del block_number, timestamp return {} + @classmethod + def build_default_block_header( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> FixtureHeader: + """ + Build a default block header for this fork with the given attributes. + + This method automatically detects which header fields are required by the fork + and assigns appropriate default values. It introspects the FixtureHeader model + to find fields with HeaderForkRequirement annotations and automatically includes + them if the fork requires them. + + Args: + block_number: The block number + timestamp: The block timestamp + + Returns: + FixtureHeader instance with default values applied based on fork requirements + + Raises: + TypeError: If the overrides don't have the correct type. + """ + from execution_testing.fixtures.blockchain import FixtureHeader + + defaults = { + "number": ZeroPaddedHexNumber(block_number), + "timestamp": ZeroPaddedHexNumber(timestamp), + "fork": cls, + } + + # Iterate through FixtureHeader fields to populate defaults + for field_name, field_info in FixtureHeader.model_fields.items(): + if field_name in defaults: + continue + + # Get default value, checking fork requirements and model defaults + default_value = FixtureHeader.get_default_from_annotation( + fork=cls, + field_name=field_name, + field_hint=field_info.annotation, + block_number=int(block_number), + timestamp=int(timestamp), + ) + if default_value is not None: + defaults[field_name] = default_value + + return FixtureHeader(**defaults) + class Homestead(Frontier): """Homestead fork.""" @@ -1456,14 +1535,14 @@ class DAOFork(Homestead, ignore=True): pass -class Tangerine(DAOFork, ignore=True): - """Tangerine fork (EIP-150).""" +class TangerineWhistle(DAOFork, ignore=True): + """TangerineWhistle fork (EIP-150).""" pass -class SpuriousDragon(Tangerine, ignore=True): - """SpuriousDragon fork (EIP-155, EIP-158).""" +class SpuriousDragon(TangerineWhistle, ignore=True): + """SpuriousDragon fork.""" @classmethod def _calculate_call_gas( @@ -1489,6 +1568,13 @@ def _calculate_call_gas( return base_cost + @classmethod + def supports_protected_txs(cls) -> bool: + """ + At Genesis, supports EIP-155 protected transactions. + """ + return True + class Byzantium(SpuriousDragon): """Byzantium fork.""" @@ -3264,6 +3350,16 @@ class Amsterdam(BPO2): # related Amsterdam specs change over time, and before Amsterdam is # live on mainnet. + @classmethod + def header_bal_hash_required( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """ + From Amsterdam, header must contain block access list hash (EIP-7928). + """ + del block_number, timestamp + return True + @classmethod def is_deployed(cls) -> bool: """Return True if this fork is deployed.""" @@ -3277,6 +3373,17 @@ def engine_new_payload_version( del block_number, timestamp return 5 + @classmethod + def engine_execution_payload_block_access_list( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """ + From Amsterdam, engine execution payload includes `block_access_list` + as a parameter. + """ + del block_number, timestamp + return True + class EOFv1(Prague, solc_name="cancun"): """EOF fork.""" diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 0bfa1eb419..06d6bebfc9 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -380,9 +380,6 @@ def get_fixture_block(self) -> FixtureBlock | InvalidFixtureBlock: if self.withdrawals is not None else None ), - block_access_list=self.block_access_list - if self.block_access_list - else None, fork=self.fork, ).with_rlp(txs=self.txs) @@ -699,6 +696,24 @@ def generate_block_data( ) requests_list = block.requests + if self.fork.header_bal_hash_required( + block_number=header.number, timestamp=header.timestamp + ): + assert ( + transition_tool_output.result.block_access_list is not None + ), ( + "Block access list is required for this block but was not provided " + "by the transition tool" + ) + + rlp = transition_tool_output.result.block_access_list.rlp + computed_bal_hash = Hash(rlp.keccak256()) + assert computed_bal_hash == header.block_access_list_hash, ( + "Block access list hash in header does not match the " + f"computed hash from BAL: {header.block_access_list_hash} " + f"!= {computed_bal_hash}" + ) + if block.rlp_modifier is not None: # Modify any parameter specified in the `rlp_modifier` after # transition tool processing. @@ -707,6 +722,29 @@ def generate_block_data( self.fork ) # Deleted during `apply` because `exclude=True` + # Process block access list - apply transformer if present for invalid + # tests + t8n_bal = transition_tool_output.result.block_access_list + bal = t8n_bal + + # Always validate BAL structural integrity (ordering, duplicates) if present + if t8n_bal is not None: + t8n_bal.validate_structure() + + # If expected BAL is defined, verify against it + if ( + block.expected_block_access_list is not None + and t8n_bal is not None + ): + block.expected_block_access_list.verify_against(t8n_bal) + + bal = block.expected_block_access_list.modify_if_invalid_test( + t8n_bal + ) + if bal != t8n_bal: + # If the BAL was modified, update the header hash + header.block_access_list_hash = Hash(bal.rlp.keccak256()) + built_block = BuiltBlock( header=header, alloc=transition_tool_output.alloc, @@ -720,7 +758,7 @@ def generate_block_data( expected_exception=block.exception, engine_api_error_code=block.engine_api_error_code, fork=self.fork, - block_access_list=None, + block_access_list=bal, ) try: diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/account_absent_values.py b/packages/testing/src/execution_testing/test_types/block_access_list/account_absent_values.py index 735a56e036..aca89076ac 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/account_absent_values.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/account_absent_values.py @@ -52,14 +52,14 @@ class BalAccountAbsentValues(CamelModel): absent_values = BalAccountAbsentValues( nonce_changes=[ # Forbid exact nonce change at this tx - BalNonceChange(tx_index=1, post_nonce=5), + BalNonceChange(block_access_index=1, post_nonce=5), ], storage_changes=[ BalStorageSlot( slot=0x42, slot_changes=[ # Forbid exact storage change at this slot and tx - BalStorageChange(tx_index=2, post_value=0x99) + BalStorageChange(block_access_index=2, post_value=0x99) ], ) ], @@ -171,22 +171,23 @@ def validate_against(self, account: BalAccountChange) -> None: self._validate_forbidden_changes( account.nonce_changes, self.nonce_changes, - lambda a, f: a.tx_index == f.tx_index + lambda a, f: a.block_access_index == f.block_access_index and a.post_nonce == f.post_nonce, - lambda a: f"Unexpected nonce change found at tx {a.tx_index}", + lambda a: f"Unexpected nonce change found at tx {a.block_access_index}", ) self._validate_forbidden_changes( account.balance_changes, self.balance_changes, - lambda a, f: a.tx_index == f.tx_index + lambda a, f: a.block_access_index == f.block_access_index and a.post_balance == f.post_balance, - lambda a: f"Unexpected balance change found at tx {a.tx_index}", + lambda a: f"Unexpected balance change found at tx {a.block_access_index}", ) self._validate_forbidden_changes( account.code_changes, self.code_changes, - lambda a, f: a.tx_index == f.tx_index and a.new_code == f.new_code, - lambda a: f"Unexpected code change found at tx {a.tx_index}", + lambda a, f: a.block_access_index == f.block_access_index + and a.new_code == f.new_code, + lambda a: f"Unexpected code change found at tx {a.block_access_index}", ) for forbidden_storage_slot in self.storage_changes: @@ -197,11 +198,11 @@ def validate_against(self, account: BalAccountChange) -> None: actual_storage_slot.slot_changes, forbidden_storage_slot.slot_changes, lambda a, f: ( - a.tx_index == f.tx_index + a.block_access_index == f.block_access_index and a.post_value == f.post_value ), lambda a, slot=slot_id: ( - f"Unexpected storage change found at slot {slot} in tx {a.tx_index}" + f"Unexpected storage change found at slot {slot} in tx {a.block_access_index}" ), ) diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py b/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py index 330731fd10..5bf4461457 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/account_changes.py @@ -13,70 +13,69 @@ Address, Bytes, CamelModel, - HexNumber, RLPSerializable, - StorageKey, + ZeroPaddedHexNumber, ) class BalNonceChange(CamelModel, RLPSerializable): """Represents a nonce change in the block access list.""" - tx_index: HexNumber = Field( - HexNumber(1), + block_access_index: ZeroPaddedHexNumber = Field( + ZeroPaddedHexNumber(1), description="Transaction index where the change occurred", ) - post_nonce: HexNumber = Field( + post_nonce: ZeroPaddedHexNumber = Field( ..., description="Nonce value after the transaction" ) - rlp_fields: ClassVar[List[str]] = ["tx_index", "post_nonce"] + rlp_fields: ClassVar[List[str]] = ["block_access_index", "post_nonce"] class BalBalanceChange(CamelModel, RLPSerializable): """Represents a balance change in the block access list.""" - tx_index: HexNumber = Field( - HexNumber(1), + block_access_index: ZeroPaddedHexNumber = Field( + ZeroPaddedHexNumber(1), description="Transaction index where the change occurred", ) - post_balance: HexNumber = Field( + post_balance: ZeroPaddedHexNumber = Field( ..., description="Balance after the transaction" ) - rlp_fields: ClassVar[List[str]] = ["tx_index", "post_balance"] + rlp_fields: ClassVar[List[str]] = ["block_access_index", "post_balance"] class BalCodeChange(CamelModel, RLPSerializable): """Represents a code change in the block access list.""" - tx_index: HexNumber = Field( - HexNumber(1), + block_access_index: ZeroPaddedHexNumber = Field( + ZeroPaddedHexNumber(1), description="Transaction index where the change occurred", ) new_code: Bytes = Field(..., description="New code bytes") - rlp_fields: ClassVar[List[str]] = ["tx_index", "new_code"] + rlp_fields: ClassVar[List[str]] = ["block_access_index", "new_code"] class BalStorageChange(CamelModel, RLPSerializable): """Represents a change to a specific storage slot.""" - tx_index: HexNumber = Field( - HexNumber(1), + block_access_index: ZeroPaddedHexNumber = Field( + ZeroPaddedHexNumber(1), description="Transaction index where the change occurred", ) - post_value: StorageKey = Field( + post_value: ZeroPaddedHexNumber = Field( ..., description="Value after the transaction" ) - rlp_fields: ClassVar[List[str]] = ["tx_index", "post_value"] + rlp_fields: ClassVar[List[str]] = ["block_access_index", "post_value"] class BalStorageSlot(CamelModel, RLPSerializable): """Represents all changes to a specific storage slot.""" - slot: StorageKey = Field(..., description="Storage slot key") + slot: ZeroPaddedHexNumber = Field(..., description="Storage slot key") slot_changes: List[BalStorageChange] = Field( default_factory=list, description="List of changes to this slot" ) @@ -100,7 +99,7 @@ class BalAccountChange(CamelModel, RLPSerializable): storage_changes: List[BalStorageSlot] = Field( default_factory=list, description="List of storage changes" ) - storage_reads: List[StorageKey] = Field( + storage_reads: List[ZeroPaddedHexNumber] = Field( default_factory=list, description="List of storage slots that were read", ) diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py index 080aafc661..c700eec77b 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py @@ -18,7 +18,6 @@ BalCodeChange, BalNonceChange, BalStorageSlot, - BlockAccessListChangeLists, ) from .exceptions import BlockAccessListValidationError from .t8n import BlockAccessList @@ -109,7 +108,7 @@ class BlockAccessListExpectation(CamelModel): expected_block_access_list = BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)] ), bob: None, # Bob should NOT be in the BAL } @@ -175,9 +174,8 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: Verify that the actual BAL from the client matches this expected BAL. Validation steps: - 1. Validate actual BAL conforms to EIP-7928 ordering requirements - 2. Verify address expectations - presence or explicit absence - 3. Verify expected changes within accounts match actual changes + 1. Verify address expectations - presence or explicit absence + 2. Verify expected changes within accounts match actual changes Args: actual_bal: The BlockAccessList model from the client @@ -186,9 +184,6 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: BlockAccessListValidationError: If verification fails """ - # validate the actual BAL structure follows EIP-7928 ordering - self._validate_bal_ordering(actual_bal) - actual_accounts_by_addr = {acc.address: acc for acc in actual_bal.root} for address, expectation in self.account_expectations.items(): if expectation is None: @@ -232,111 +227,6 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: f"Account {address}: {str(e)}" ) from e - @staticmethod - def _validate_bal_ordering(bal: "BlockAccessList") -> None: - """ - Validate BAL ordering follows EIP-7928 requirements. - - Args: - bal: The BlockAccessList to validate - - Raises: - BlockAccessListValidationError: If ordering is invalid - - """ - # Check address ordering (ascending) - for i in range(1, len(bal.root)): - if bal.root[i - 1].address >= bal.root[i].address: - raise BlockAccessListValidationError( - f"BAL addresses are not in lexicographic order: " - f"{bal.root[i - 1].address} >= {bal.root[i].address}" - ) - - # Check transaction index ordering and uniqueness within accounts - for account in bal.root: - changes_to_check: List[tuple[str, BlockAccessListChangeLists]] = [ - ("nonce_changes", account.nonce_changes), - ("balance_changes", account.balance_changes), - ("code_changes", account.code_changes), - ] - - for field_name, change_list in changes_to_check: - if not change_list: - continue - - tx_indices = [c.tx_index for c in change_list] - - # Check both ordering and duplicates - if tx_indices != sorted(tx_indices): - raise BlockAccessListValidationError( - f"Transaction indices not in ascending order in {field_name} of account " - f"{account.address}. Got: {tx_indices}, Expected: {sorted(tx_indices)}" - ) - - if len(tx_indices) != len(set(tx_indices)): - duplicates = sorted( - { - idx - for idx in tx_indices - if tx_indices.count(idx) > 1 - } - ) - raise BlockAccessListValidationError( - f"Duplicate transaction indices in {field_name} of account " - f"{account.address}. Duplicates: {duplicates}" - ) - - # Check storage slot ordering - for i in range(1, len(account.storage_changes)): - if ( - account.storage_changes[i - 1].slot - >= account.storage_changes[i].slot - ): - raise BlockAccessListValidationError( - f"Storage slots not in ascending order in account " - f"{account.address}: {account.storage_changes[i - 1].slot} >= " - f"{account.storage_changes[i].slot}" - ) - - # Check transaction index ordering and uniqueness within storage - # slots - for storage_slot in account.storage_changes: - if not storage_slot.slot_changes: - continue - - tx_indices = [c.tx_index for c in storage_slot.slot_changes] - - # Check both ordering and duplicates - if tx_indices != sorted(tx_indices): - raise BlockAccessListValidationError( - f"Transaction indices not in ascending order in storage slot " - f"{storage_slot.slot} of account {account.address}. " - f"Got: {tx_indices}, Expected: {sorted(tx_indices)}" - ) - - if len(tx_indices) != len(set(tx_indices)): - duplicates = sorted( - { - idx - for idx in tx_indices - if tx_indices.count(idx) > 1 - } - ) - raise BlockAccessListValidationError( - f"Duplicate transaction indices in storage slot " - f"{storage_slot.slot} of account {account.address}. " - f"Duplicates: {duplicates}" - ) - - # Check storage reads ordering - for i in range(1, len(account.storage_reads)): - if account.storage_reads[i - 1] >= account.storage_reads[i]: - raise BlockAccessListValidationError( - f"Storage reads not in ascending order in account " - f"{account.address}: {account.storage_reads[i - 1]} >= " - f"{account.storage_reads[i]}" - ) - @staticmethod def _compare_account_expectations( expected: BalAccountExpectation, actual: BalAccountChange @@ -432,8 +322,8 @@ def _compare_account_expectations( slot_actual_idx ] if ( - actual_change.tx_index - == expected_change.tx_index + actual_change.block_access_index + == expected_change.block_access_index and actual_change.post_value == expected_change.post_value ): @@ -467,27 +357,32 @@ def _compare_account_expectations( # Create tuples for comparison (ordering already validated) if field_name == "nonce_changes": expected_tuples = [ - (c.tx_index, c.post_nonce) for c in expected_list + (c.block_access_index, c.post_nonce) + for c in expected_list ] actual_tuples = [ - (c.tx_index, c.post_nonce) for c in actual_list + (c.block_access_index, c.post_nonce) + for c in actual_list ] item_type = "nonce" elif field_name == "balance_changes": expected_tuples = [ - (c.tx_index, int(c.post_balance)) + (c.block_access_index, int(c.post_balance)) for c in expected_list ] actual_tuples = [ - (c.tx_index, int(c.post_balance)) for c in actual_list + (c.block_access_index, int(c.post_balance)) + for c in actual_list ] item_type = "balance" elif field_name == "code_changes": expected_tuples = [ - (c.tx_index, bytes(c.new_code)) for c in expected_list + (c.block_access_index, bytes(c.new_code)) + for c in expected_list ] actual_tuples = [ - (c.tx_index, bytes(c.new_code)) for c in actual_list + (c.block_access_index, bytes(c.new_code)) + for c in actual_list ] item_type = "code" else: diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py index 1763970631..ddad9605d4 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py @@ -8,7 +8,10 @@ from typing import Any, Callable, List, Optional -from execution_testing.base_types import Address, HexNumber +from execution_testing.base_types import ( + Address, + ZeroPaddedHexNumber, +) from .. import BalCodeChange from . import ( @@ -54,7 +57,7 @@ def transform(bal: BlockAccessList) -> BlockAccessList: def _modify_field_value( address: Address, - tx_index: int, + block_access_index: int, field_name: str, change_class: type, new_value: Any, @@ -85,9 +88,12 @@ def transform(bal: BlockAccessList) -> BlockAccessList: for j, change in enumerate( storage_slot.slot_changes ): - if change.tx_index == tx_index: + if ( + change.block_access_index + == block_access_index + ): kwargs = { - "tx_index": tx_index, + "block_access_index": block_access_index, value_field: new_value, } storage_slot.slot_changes[j] = ( @@ -98,9 +104,9 @@ def transform(bal: BlockAccessList) -> BlockAccessList: else: # flat structure (nonce, balance, code) for i, change in enumerate(changes): - if change.tx_index == tx_index: + if change.block_access_index == block_access_index: kwargs = { - "tx_index": tx_index, + "block_access_index": block_access_index, value_field: new_value, } changes[i] = change_class(**kwargs) @@ -172,23 +178,28 @@ def remove_code( def modify_nonce( - address: Address, tx_index: int, nonce: int + address: Address, block_access_index: int, nonce: int ) -> Callable[[BlockAccessList], BlockAccessList]: """Set an incorrect nonce value for a specific account and transaction.""" return _modify_field_value( - address, tx_index, "nonce_changes", BalNonceChange, nonce, "post_nonce" + address, + block_access_index, + "nonce_changes", + BalNonceChange, + nonce, + "post_nonce", ) def modify_balance( - address: Address, tx_index: int, balance: int + address: Address, block_access_index: int, balance: int ) -> Callable[[BlockAccessList], BlockAccessList]: """ Set an incorrect balance value for a specific account and transaction. """ return _modify_field_value( address, - tx_index, + block_access_index, "balance_changes", BalBalanceChange, balance, @@ -197,7 +208,7 @@ def modify_balance( def modify_storage( - address: Address, tx_index: int, slot: int, value: int + address: Address, block_access_index: int, slot: int, value: int ) -> Callable[[BlockAccessList], BlockAccessList]: """ Set an incorrect storage value for a specific account, transaction, and @@ -205,7 +216,7 @@ def modify_storage( """ return _modify_field_value( address, - tx_index, + block_access_index, "storage_changes", BalStorageChange, value, @@ -216,19 +227,24 @@ def modify_storage( def modify_code( - address: Address, tx_index: int, code: bytes + address: Address, block_access_index: int, code: bytes ) -> Callable[[BlockAccessList], BlockAccessList]: """Set an incorrect code value for a specific account and transaction.""" return _modify_field_value( - address, tx_index, "code_changes", BalCodeChange, code, "post_code" + address, + block_access_index, + "code_changes", + BalCodeChange, + code, + "new_code", ) -def swap_tx_indices( - tx1: int, tx2: int +def swap_bal_indices( + idx1: int, idx2: int ) -> Callable[[BlockAccessList], BlockAccessList]: - """Swap transaction indices throughout the BAL, modifying tx ordering.""" - nonce_indices = {tx1: False, tx2: False} + """Swap block access indices throughout the BAL, modifying ordering.""" + nonce_indices = {idx1: False, idx2: False} balance_indices = nonce_indices.copy() storage_indices = nonce_indices.copy() code_indices = nonce_indices.copy() @@ -242,49 +258,88 @@ def transform(bal: BlockAccessList) -> BlockAccessList: # Swap in nonce changes if new_account.nonce_changes: for nonce_change in new_account.nonce_changes: - if nonce_change.tx_index == tx1: - nonce_indices[tx1] = True - nonce_change.tx_index = HexNumber(tx2) - elif nonce_change.tx_index == tx2: - nonce_indices[tx2] = True - nonce_change.tx_index = HexNumber(tx1) + if nonce_change.block_access_index == idx1: + nonce_indices[idx1] = True + nonce_change.block_access_index = ZeroPaddedHexNumber( + idx2 + ) + elif nonce_change.block_access_index == idx2: + nonce_indices[idx2] = True + nonce_change.block_access_index = ZeroPaddedHexNumber( + idx1 + ) # Swap in balance changes if new_account.balance_changes: for balance_change in new_account.balance_changes: - if balance_change.tx_index == tx1: - balance_indices[tx1] = True - balance_change.tx_index = HexNumber(tx2) - elif balance_change.tx_index == tx2: - balance_indices[tx2] = True - balance_change.tx_index = HexNumber(tx1) + if balance_change.block_access_index == idx1: + balance_indices[idx1] = True + balance_change.block_access_index = ( + ZeroPaddedHexNumber(idx2) + ) + elif balance_change.block_access_index == idx2: + balance_indices[idx2] = True + balance_change.block_access_index = ( + ZeroPaddedHexNumber(idx1) + ) # Swap in storage changes (nested structure) if new_account.storage_changes: for storage_slot in new_account.storage_changes: for storage_change in storage_slot.slot_changes: - if storage_change.tx_index == tx1: - balance_indices[tx1] = True - storage_change.tx_index = HexNumber(tx2) - elif storage_change.tx_index == tx2: - balance_indices[tx2] = True - storage_change.tx_index = HexNumber(tx1) - - # Note: storage_reads is just a list of StorageKey, no tx_index to + if storage_change.block_access_index == idx1: + storage_indices[idx1] = True + storage_change.block_access_index = ( + ZeroPaddedHexNumber(idx2) + ) + elif storage_change.block_access_index == idx2: + storage_indices[idx2] = True + storage_change.block_access_index = ( + ZeroPaddedHexNumber(idx1) + ) + + # Note: storage_reads is just a list of StorageKey, no block_access_index to # swap # Swap in code changes if new_account.code_changes: for code_change in new_account.code_changes: - if code_change.tx_index == tx1: - code_indices[tx1] = True - code_change.tx_index = HexNumber(tx2) - elif code_change.tx_index == tx2: - code_indices[tx2] = True - code_change.tx_index = HexNumber(tx1) + if code_change.block_access_index == idx1: + code_indices[idx1] = True + code_change.block_access_index = ZeroPaddedHexNumber( + idx2 + ) + elif code_change.block_access_index == idx2: + code_indices[idx2] = True + code_change.block_access_index = ZeroPaddedHexNumber( + idx1 + ) new_root.append(new_account) + # Validate that at least one swap occurred for each index across all change types + idx1_found = ( + nonce_indices[idx1] + or balance_indices[idx1] + or storage_indices[idx1] + or code_indices[idx1] + ) + idx2_found = ( + nonce_indices[idx2] + or balance_indices[idx2] + or storage_indices[idx2] + or code_indices[idx2] + ) + + if not idx1_found: + raise ValueError( + f"Block access index {idx1} not found in any BAL changes to swap" + ) + if not idx2_found: + raise ValueError( + f"Block access index {idx2} not found in any BAL changes to swap" + ) + return BlockAccessList(root=new_root) return transform @@ -303,6 +358,112 @@ def transform(bal: BlockAccessList) -> BlockAccessList: return transform +def append_change( + account: Address, + change: BalNonceChange | BalBalanceChange | BalCodeChange, +) -> Callable[[BlockAccessList], BlockAccessList]: + """ + Append a change to an account's field list. + + Generic function to add extraneous entries to nonce_changes, balance_changes, + or code_changes fields. The field is inferred from the change type. + """ + # Infer field name from change type + if isinstance(change, BalNonceChange): + field = "nonce_changes" + elif isinstance(change, BalBalanceChange): + field = "balance_changes" + elif isinstance(change, BalCodeChange): + field = "code_changes" + else: + raise TypeError(f"Unsupported change type: {type(change)}") + + found_address = False + + def transform(bal: BlockAccessList) -> BlockAccessList: + nonlocal found_address + new_root = [] + for account_change in bal.root: + if account_change.address == account: + found_address = True + new_account = account_change.model_copy(deep=True) + # Get the field list and append the change + field_list = getattr(new_account, field) + field_list.append(change) + new_root.append(new_account) + else: + new_root.append(account_change) + + if not found_address: + raise ValueError( + f"Address {account} not found in BAL to append change to {field}" + ) + + return BlockAccessList(root=new_root) + + return transform + + +def append_storage( + address: Address, + slot: int, + change: Optional[BalStorageChange] = None, + read: bool = False, +) -> Callable[[BlockAccessList], BlockAccessList]: + """ + Append storage-related entries to an account. + + Generic function for all storage operations: + - If read=True: appends to storage_reads + - If change provided and slot exists: appends to existing slot's slot_changes + - If change provided and slot new: creates new BalStorageSlot + """ + found_address = False + + def transform(bal: BlockAccessList) -> BlockAccessList: + nonlocal found_address + new_root = [] + for account_change in bal.root: + if account_change.address == address: + found_address = True + new_account = account_change.model_copy(deep=True) + + if read: + # Append to storage_reads + new_account.storage_reads.append(ZeroPaddedHexNumber(slot)) + elif change is not None: + # Find if slot already exists + slot_found = False + for storage_slot in new_account.storage_changes: + if storage_slot.slot == slot: + # Append to existing slot's slot_changes + storage_slot.slot_changes.append(change) + slot_found = True + break + + if not slot_found: + # Create new BalStorageSlot + from . import BalStorageSlot + + new_storage_slot = BalStorageSlot( + slot=slot, slot_changes=[change] + ) + new_account.storage_changes.append(new_storage_slot) + + new_root.append(new_account) + else: + new_root.append(account_change) + + if not found_address: + raise ValueError( + f"Address {address} not found in BAL to append storage entry" + ) + + return BlockAccessList(root=new_root) + + return transform + + def duplicate_account( address: Address, ) -> Callable[[BlockAccessList], BlockAccessList]: @@ -401,6 +562,8 @@ def transform(bal: BlockAccessList) -> BlockAccessList: # Account-level modifiers "remove_accounts", "append_account", + "append_change", + "append_storage", "duplicate_account", "reverse_accounts", "keep_only", @@ -415,6 +578,6 @@ def transform(bal: BlockAccessList) -> BlockAccessList: "modify_balance", "modify_storage", "modify_code", - # Transaction index modifiers - "swap_tx_indices", + # Block access index modifiers + "swap_bal_indices", ] diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py index 9a9ba84508..19733a240f 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py @@ -12,6 +12,7 @@ ) from .account_changes import BalAccountChange +from .exceptions import BlockAccessListValidationError class BlockAccessList(EthereumTestRootModel[List[BalAccountChange]]): @@ -49,3 +50,110 @@ def rlp(self) -> Bytes: def rlp_hash(self) -> Bytes: """Return the hash of the RLP encoded block access list.""" return self.rlp.keccak256() + + def validate_structure(self) -> None: + """ + Validate BAL structure follows EIP-7928 requirements. + + Checks: + - Addresses are in lexicographic (ascending) order + - Transaction indices are sorted and unique within each change list + - Storage slots are in ascending order + - Storage reads are in ascending order + + Raises: + BlockAccessListValidationError: If validation fails + """ + # Check address ordering (ascending) + for i in range(1, len(self.root)): + if self.root[i - 1].address >= self.root[i].address: + raise BlockAccessListValidationError( + f"BAL addresses are not in lexicographic order: " + f"{self.root[i - 1].address} >= {self.root[i].address}" + ) + + # Check transaction index ordering and uniqueness within accounts + for account in self.root: + changes_to_check: List[tuple[str, List[Any]]] = [ + ("nonce_changes", account.nonce_changes), + ("balance_changes", account.balance_changes), + ("code_changes", account.code_changes), + ] + + for field_name, change_list in changes_to_check: + if not change_list: + continue + + bal_indices = [c.block_access_index for c in change_list] + + # Check both ordering and duplicates + if bal_indices != sorted(bal_indices): + raise BlockAccessListValidationError( + f"Block access indices not in ascending order in {field_name} of account " + f"{account.address}. Got: {bal_indices}, Expected: {sorted(bal_indices)}" + ) + + if len(bal_indices) != len(set(bal_indices)): + duplicates = sorted( + { + idx + for idx in bal_indices + if bal_indices.count(idx) > 1 + } + ) + raise BlockAccessListValidationError( + f"Duplicate transaction indices in {field_name} of account " + f"{account.address}. Duplicates: {duplicates}" + ) + + # Check storage slot ordering + for i in range(1, len(account.storage_changes)): + if ( + account.storage_changes[i - 1].slot + >= account.storage_changes[i].slot + ): + raise BlockAccessListValidationError( + f"Storage slots not in ascending order in account " + f"{account.address}: {account.storage_changes[i - 1].slot} >= " + f"{account.storage_changes[i].slot}" + ) + + # Check bal index ordering and uniqueness within storage slots + for storage_slot in account.storage_changes: + if not storage_slot.slot_changes: + continue + + bal_indices = [ + c.block_access_index for c in storage_slot.slot_changes + ] + + # Check both ordering and duplicates + if bal_indices != sorted(bal_indices): + raise BlockAccessListValidationError( + f"Transaction indices not in ascending order in storage slot " + f"{storage_slot.slot} of account {account.address}. " + f"Got: {bal_indices}, Expected: {sorted(bal_indices)}" + ) + + if len(bal_indices) != len(set(bal_indices)): + duplicates = sorted( + { + idx + for idx in bal_indices + if bal_indices.count(idx) > 1 + } + ) + raise BlockAccessListValidationError( + f"Duplicate transaction indices in storage slot " + f"{storage_slot.slot} of account {account.address}. " + f"Duplicates: {duplicates}" + ) + + # Check storage reads ordering + for i in range(1, len(account.storage_reads)): + if account.storage_reads[i - 1] >= account.storage_reads[i]: + raise BlockAccessListValidationError( + f"Storage reads not in ascending order in account " + f"{account.address}: {account.storage_reads[i - 1]} >= " + f"{account.storage_reads[i]}" + ) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_lists.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py similarity index 71% rename from packages/testing/src/execution_testing/test_types/tests/test_block_access_lists.py rename to packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index 8effee3688..ae495e9bd7 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_lists.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py @@ -29,7 +29,9 @@ def test_address_exclusion_validation_passes() -> None: [ BalAccountChange( address=alice, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), ] ) @@ -37,7 +39,9 @@ def test_address_exclusion_validation_passes() -> None: expectation = BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] ), bob: None, # expect Bob is not in BAL (correctly) } @@ -55,12 +59,14 @@ def test_address_exclusion_validation_raises_when_address_is_present() -> None: [ BalAccountChange( address=alice, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), BalAccountChange( address=bob, balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) + BalBalanceChange(block_access_index=1, post_balance=100) ], ), ] @@ -103,7 +109,9 @@ def test_empty_account_changes_definitions( [ BalAccountChange( address=alice, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), ] ) @@ -153,14 +161,22 @@ def test_empty_list_validation() -> None: @pytest.mark.parametrize( "field,value", [ - ["nonce_changes", BalNonceChange(tx_index=1, post_nonce=1)], - ["balance_changes", BalBalanceChange(tx_index=1, post_balance=100)], - ["code_changes", BalCodeChange(tx_index=1, new_code=b"code")], + ["nonce_changes", BalNonceChange(block_access_index=1, post_nonce=1)], + [ + "balance_changes", + BalBalanceChange(block_access_index=1, post_balance=100), + ], + [ + "code_changes", + BalCodeChange(block_access_index=1, new_code=b"code"), + ], [ "storage_changes", BalStorageSlot( slot=0x01, - slot_changes=[BalStorageChange(tx_index=1, post_value=0x42)], + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0x42) + ], ), ], ["storage_reads", 0x01], @@ -179,7 +195,7 @@ def test_empty_list_validation_fails(field: str, value: Any) -> None: alice_acct_change.storage_reads = [value] # set another field to non-empty to avoid all-empty account change alice_acct_change.nonce_changes = [ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange(block_access_index=1, post_nonce=1) ] else: @@ -194,7 +210,7 @@ def test_empty_list_validation_fails(field: str, value: Any) -> None: # match the filled field in actual to avoid all-empty # account expectation alice_acct_expectation.nonce_changes = [ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange(block_access_index=1, post_nonce=1) ] else: setattr(alice_acct_expectation, field, []) @@ -219,9 +235,11 @@ def test_partial_validation() -> None: [ BalAccountChange( address=alice, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) + BalBalanceChange(block_access_index=1, post_balance=100) ], storage_reads=[0x01, 0x02], ), @@ -232,7 +250,9 @@ def test_partial_validation() -> None: expectation = BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], # balance_changes and storage_reads not set and won't be # validated ), @@ -255,7 +275,9 @@ def test_storage_changes_validation() -> None: BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -271,7 +293,9 @@ def test_storage_changes_validation() -> None: BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -291,7 +315,9 @@ def test_missing_expected_address() -> None: [ BalAccountChange( address=alice, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), ] ) @@ -300,7 +326,9 @@ def test_missing_expected_address() -> None: account_expectations={ # wrongly expect Bob to be present bob: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), } ) @@ -312,231 +340,6 @@ def test_missing_expected_address() -> None: expectation.verify_against(actual_bal) -@pytest.mark.parametrize( - "addresses,error_message", - [ - ( - [ - Address(0xB), - Address(0xA), # should come first - ], - "BAL addresses are not in lexicographic order", - ), - ( - [ - Address(0x1), - Address(0x3), - Address(0x2), - ], - "BAL addresses are not in lexicographic order", - ), - ], -) -def test_actual_bal_address_ordering_validation( - addresses: Any, error_message: str -) -> None: - """Test that actual BAL must have addresses in lexicographic order.""" - # Create BAL with addresses in the given order - actual_bal = BlockAccessList( - [ - BalAccountChange(address=addr, nonce_changes=[]) - for addr in addresses - ] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises(BlockAccessListValidationError, match=error_message): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "storage_slots,error_message", - [ - ( - [StorageKey(0x02), StorageKey(0x01)], # 0x02 before 0x01 - "Storage slots not in ascending order", - ), - ( - [StorageKey(0x01), StorageKey(0x03), StorageKey(0x02)], - "Storage slots not in ascending order", - ), - ], -) -def test_actual_bal_storage_slot_ordering( - storage_slots: Any, error_message: str -) -> None: - """Test that actual BAL must have storage slots in lexicographic order.""" - addr = Address(0xA) - - actual_bal = BlockAccessList( - [ - BalAccountChange( - address=addr, - storage_changes=[ - BalStorageSlot(slot=slot, slot_changes=[]) - for slot in storage_slots - ], - ) - ] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises(BlockAccessListValidationError, match=error_message): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "storage_reads,error_message", - [ - ( - [StorageKey(0x02), StorageKey(0x01)], - "Storage reads not in ascending order", - ), - ( - [StorageKey(0x01), StorageKey(0x03), StorageKey(0x02)], - "Storage reads not in ascending order", - ), - ], -) -def test_actual_bal_storage_reads_ordering( - storage_reads: Any, error_message: str -) -> None: - """Test that actual BAL must have storage reads in lexicographic order.""" - addr = Address(0xA) - - actual_bal = BlockAccessList( - [BalAccountChange(address=addr, storage_reads=storage_reads)] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises(BlockAccessListValidationError, match=error_message): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "field_name", - ["nonce_changes", "balance_changes", "code_changes"], -) -def test_actual_bal_tx_indices_ordering(field_name: str) -> None: - """Test that actual BAL must have tx indices in ascending order.""" - addr = Address(0xA) - - tx_indices = [2, 3, 1] # out of order - - changes: Any = [] - if field_name == "nonce_changes": - changes = [ - BalNonceChange(tx_index=idx, post_nonce=1) for idx in tx_indices - ] - elif field_name == "balance_changes": - changes = [ - BalBalanceChange(tx_index=idx, post_balance=100) - for idx in tx_indices - ] - elif field_name == "code_changes": - changes = [ - BalCodeChange(tx_index=idx, new_code=b"code") for idx in tx_indices - ] - - actual_bal = BlockAccessList( - [BalAccountChange(address=addr, **{field_name: changes})] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises( - BlockAccessListValidationError, - match="Transaction indices not in ascending order", - ): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "field_name", - ["nonce_changes", "balance_changes", "code_changes"], -) -def test_actual_bal_duplicate_tx_indices(field_name: str) -> None: - """ - Test that actual BAL must not have duplicate tx indices in change lists. - """ - addr = Address(0xA) - - # Duplicate tx_index=1 - changes: Any = [] - if field_name == "nonce_changes": - changes = [ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=1, post_nonce=2), # duplicate tx_index - BalNonceChange(tx_index=2, post_nonce=3), - ] - elif field_name == "balance_changes": - changes = [ - BalBalanceChange(tx_index=1, post_balance=100), - BalBalanceChange( - tx_index=1, post_balance=200 - ), # duplicate tx_index - BalBalanceChange(tx_index=2, post_balance=300), - ] - elif field_name == "code_changes": - changes = [ - BalCodeChange(tx_index=1, new_code=b"code1"), - BalCodeChange(tx_index=1, new_code=b""), # duplicate tx_index - BalCodeChange(tx_index=2, new_code=b"code2"), - ] - - actual_bal = BlockAccessList( - [BalAccountChange(address=addr, **{field_name: changes})] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises( - BlockAccessListValidationError, - match=f"Duplicate transaction indices in {field_name}.*Duplicates: \\[1\\]", - ): - expectation.verify_against(actual_bal) - - -def test_actual_bal_storage_duplicate_tx_indices() -> None: - """ - Test that storage changes must not have duplicate tx indices within same - slot. - """ - addr = Address(0xA) - - # Create storage changes with duplicate tx_index within the same slot - actual_bal = BlockAccessList( - [ - BalAccountChange( - address=addr, - storage_changes=[ - BalStorageSlot( - slot=0x01, - slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x100), - BalStorageChange( - tx_index=1, post_value=0x200 - ), # duplicate tx_index - BalStorageChange(tx_index=2, post_value=0x300), - ], - ) - ], - ) - ] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises( - BlockAccessListValidationError, - match="Duplicate transaction indices in storage slot.*Duplicates: \\[1\\]", - ): - expectation.verify_against(actual_bal) - - def test_expected_addresses_auto_sorted() -> None: """ Test that expected addresses are automatically sorted before comparison. @@ -699,9 +502,9 @@ def test_expected_tx_indices_ordering( BalAccountChange( address=addr, nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=2, post_nonce=2), - BalNonceChange(tx_index=3, post_nonce=3), + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), + BalNonceChange(block_access_index=3, post_nonce=3), ], ) ] @@ -711,7 +514,7 @@ def test_expected_tx_indices_ordering( account_expectations={ addr: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=idx, post_nonce=idx) + BalNonceChange(block_access_index=idx, post_nonce=idx) for idx in expected_tx_indices ], ), @@ -733,10 +536,12 @@ def test_absent_values_nonce_changes(has_change_should_raise: bool) -> None: """Test nonce_changes_at_tx validator with present/absent changes.""" alice = Address(0xA) - nonce_changes = [BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes = [BalNonceChange(block_access_index=1, post_nonce=1)] if has_change_should_raise: # add nonce change at tx 2 which should trigger failure - nonce_changes.append(BalNonceChange(tx_index=2, post_nonce=2)) + nonce_changes.append( + BalNonceChange(block_access_index=2, post_nonce=2) + ) actual_bal = BlockAccessList( [ @@ -752,7 +557,9 @@ def test_absent_values_nonce_changes(has_change_should_raise: bool) -> None: # no nonce changes at tx 2 alice: BalAccountExpectation( absent_values=BalAccountAbsentValues( - nonce_changes=[BalNonceChange(tx_index=2, post_nonce=2)] + nonce_changes=[ + BalNonceChange(block_access_index=2, post_nonce=2) + ] ) ) } @@ -760,7 +567,7 @@ def test_absent_values_nonce_changes(has_change_should_raise: bool) -> None: if has_change_should_raise: with pytest.raises( - Exception, match="Unexpected nonce change found at tx 0x2" + Exception, match="Unexpected nonce change found at tx 0x02" ): expectation.verify_against(actual_bal) else: @@ -772,10 +579,14 @@ def test_absent_values_balance_changes(has_change_should_raise: bool) -> None: """Test balance_changes_at_tx validator with present/absent changes.""" alice = Address(0xA) - balance_changes = [BalBalanceChange(tx_index=1, post_balance=100)] + balance_changes = [ + BalBalanceChange(block_access_index=1, post_balance=100) + ] if has_change_should_raise: # add balance change at tx 2 which should trigger failure - balance_changes.append(BalBalanceChange(tx_index=2, post_balance=200)) + balance_changes.append( + BalBalanceChange(block_access_index=2, post_balance=200) + ) actual_bal = BlockAccessList( [ @@ -791,7 +602,9 @@ def test_absent_values_balance_changes(has_change_should_raise: bool) -> None: alice: BalAccountExpectation( absent_values=BalAccountAbsentValues( balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=200) + BalBalanceChange( + block_access_index=2, post_balance=200 + ) ] ) ), @@ -801,7 +614,7 @@ def test_absent_values_balance_changes(has_change_should_raise: bool) -> None: if has_change_should_raise: with pytest.raises( Exception, - match="Unexpected balance change found at tx 0x2", + match="Unexpected balance change found at tx 0x02", ): expectation.verify_against(actual_bal) else: @@ -816,14 +629,18 @@ def test_absent_values_storage_changes(has_change_should_raise: bool) -> None: storage_changes = [ BalStorageSlot( slot=0x01, - slot_changes=[BalStorageChange(tx_index=1, post_value=0x99)], + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0x99) + ], ) ] if has_change_should_raise: storage_changes.append( BalStorageSlot( slot=0x42, - slot_changes=[BalStorageChange(tx_index=1, post_value=0xBEEF)], + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0xBEEF) + ], ) ) @@ -845,7 +662,9 @@ def test_absent_values_storage_changes(has_change_should_raise: bool) -> None: BalStorageSlot( slot=0x42, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0xBEEF) + BalStorageChange( + block_access_index=1, post_value=0xBEEF + ) ], ) ] @@ -907,10 +726,12 @@ def test_absent_values_code_changes(has_change_should_raise: bool) -> None: """Test code_changes_at_tx validator with present/absent changes.""" alice = Address(0xA) - code_changes = [BalCodeChange(tx_index=1, new_code=b"\x00")] + code_changes = [BalCodeChange(block_access_index=1, new_code=b"\x00")] if has_change_should_raise: # add code change at tx 2 which should trigger failure - code_changes.append(BalCodeChange(tx_index=2, new_code=b"\x60\x00")) + code_changes.append( + BalCodeChange(block_access_index=2, new_code=b"\x60\x00") + ) actual_bal = BlockAccessList( [ @@ -927,7 +748,9 @@ def test_absent_values_code_changes(has_change_should_raise: bool) -> None: alice: BalAccountExpectation( absent_values=BalAccountAbsentValues( code_changes=[ - BalCodeChange(tx_index=2, new_code=b"\x60\x00") + BalCodeChange( + block_access_index=2, new_code=b"\x60\x00" + ) ] ) ), @@ -936,7 +759,7 @@ def test_absent_values_code_changes(has_change_should_raise: bool) -> None: if has_change_should_raise: with pytest.raises( - Exception, match="Unexpected code change found at tx 0x2" + Exception, match="Unexpected code change found at tx 0x02" ): expectation.verify_against(actual_bal) else: @@ -957,7 +780,9 @@ def test_multiple_absent_valuess() -> None: BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x99) + BalStorageChange( + block_access_index=1, post_value=0x99 + ) ], ) ], @@ -975,37 +800,43 @@ def test_multiple_absent_valuess() -> None: BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x99) + BalStorageChange( + block_access_index=1, post_value=0x99 + ) ], ) ], absent_values=BalAccountAbsentValues( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=0), - BalNonceChange(tx_index=2, post_nonce=0), + BalNonceChange(block_access_index=1, post_nonce=0), + BalNonceChange(block_access_index=2, post_nonce=0), ], balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=0), - BalBalanceChange(tx_index=2, post_balance=0), + BalBalanceChange(block_access_index=1, post_balance=0), + BalBalanceChange(block_access_index=2, post_balance=0), ], storage_changes=[ BalStorageSlot( slot=0x42, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0) + BalStorageChange( + block_access_index=1, post_value=0 + ) ], ), BalStorageSlot( slot=0x43, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0) + BalStorageChange( + block_access_index=1, post_value=0 + ) ], ), ], storage_reads=[StorageKey(0x42), StorageKey(0x43)], code_changes=[ - BalCodeChange(tx_index=1, new_code=b""), - BalCodeChange(tx_index=2, new_code=b""), + BalCodeChange(block_access_index=1, new_code=b""), + BalCodeChange(block_access_index=2, new_code=b""), ], ), ), @@ -1025,8 +856,8 @@ def test_absent_values_with_multiple_tx_indices() -> None: address=alice, nonce_changes=[ # nonce changes at tx 1 and 3 - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=3, post_nonce=2), + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=3, post_nonce=2), ], ), ] @@ -1036,13 +867,13 @@ def test_absent_values_with_multiple_tx_indices() -> None: account_expectations={ alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=3, post_nonce=2), + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=3, post_nonce=2), ], absent_values=BalAccountAbsentValues( nonce_changes=[ - BalNonceChange(tx_index=2, post_nonce=0), - BalNonceChange(tx_index=4, post_nonce=0), + BalNonceChange(block_access_index=2, post_nonce=0), + BalNonceChange(block_access_index=4, post_nonce=0), ] ), ), @@ -1058,8 +889,8 @@ def test_absent_values_with_multiple_tx_indices() -> None: nonce_changes=[ # wrongly forbid change at txs 1 and 2 # (1 exists, so should fail) - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=2, post_nonce=0), + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=0), ] ), ), @@ -1067,7 +898,7 @@ def test_absent_values_with_multiple_tx_indices() -> None: ) with pytest.raises( - Exception, match="Unexpected nonce change found at tx 0x1" + Exception, match="Unexpected nonce change found at tx 0x01" ): expectation_fail.verify_against(actual_bal) @@ -1081,7 +912,9 @@ def test_bal_account_absent_values_comprehensive() -> None: [ BalAccountChange( address=addr, - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ) ] ) @@ -1090,7 +923,9 @@ def test_bal_account_absent_values_comprehensive() -> None: account_expectations={ addr: BalAccountExpectation( absent_values=BalAccountAbsentValues( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] ) ), } @@ -1108,7 +943,7 @@ def test_bal_account_absent_values_comprehensive() -> None: BalAccountChange( address=addr, balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=100) + BalBalanceChange(block_access_index=2, post_balance=100) ], ) ] @@ -1119,7 +954,9 @@ def test_bal_account_absent_values_comprehensive() -> None: addr: BalAccountExpectation( absent_values=BalAccountAbsentValues( balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=100) + BalBalanceChange( + block_access_index=2, post_balance=100 + ) ] ) ), @@ -1137,7 +974,9 @@ def test_bal_account_absent_values_comprehensive() -> None: [ BalAccountChange( address=addr, - code_changes=[BalCodeChange(tx_index=3, new_code=b"\x60\x00")], + code_changes=[ + BalCodeChange(block_access_index=3, new_code=b"\x60\x00") + ], ) ] ) @@ -1147,7 +986,9 @@ def test_bal_account_absent_values_comprehensive() -> None: addr: BalAccountExpectation( absent_values=BalAccountAbsentValues( code_changes=[ - BalCodeChange(tx_index=3, new_code=b"\x60\x00") + BalCodeChange( + block_access_index=3, new_code=b"\x60\x00" + ) ] ) ), @@ -1190,7 +1031,9 @@ def test_bal_account_absent_values_comprehensive() -> None: BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=99) + BalStorageChange( + block_access_index=1, post_value=99 + ) ], ) ], @@ -1206,7 +1049,9 @@ def test_bal_account_absent_values_comprehensive() -> None: BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=99) + BalStorageChange( + block_access_index=1, post_value=99 + ) ], ) ] diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py new file mode 100644 index 0000000000..a42d86ff65 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py @@ -0,0 +1,86 @@ +""" +Tests for BlockAccessList serialization format. + +These tests verify that BAL models serialize to JSON with the correct +format, particularly zero-padded hex strings. +""" + +from execution_testing.base_types import Address, Bytes +from execution_testing.test_types.block_access_list import ( + BalAccountChange, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessList, +) + + +def test_bal_serialization_roundtrip_zero_padded_hex() -> None: + """ + Test that BAL serializes with zero-padded hex format and round-trips correctly. + + This verifies that values like 12 serialize as "0x0c" (not "0xc"), which is + required for consistency with other test vector fields. + """ + addr = Address(0xA) + + original = BlockAccessList( + [ + BalAccountChange( + address=addr, + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=12), + BalNonceChange(block_access_index=2, post_nonce=255), + ], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=15), + ], + code_changes=[ + BalCodeChange( + block_access_index=3, new_code=Bytes(b"\xde\xad") + ), + ], + storage_changes=[ + BalStorageSlot( + slot=12, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=255 + ), + BalStorageChange( + block_access_index=2, post_value=4096 + ), + ], + ), + ], + storage_reads=[1, 15, 256], + ) + ] + ) + + # Serialize to JSON + json_data = original.model_dump(mode="json") + account_data = json_data[0] + + # Verify zero-padded hex format (0x0c not 0xc, 0x01 not 0x1) + assert account_data["nonce_changes"][0]["block_access_index"] == "0x01" + assert account_data["nonce_changes"][0]["post_nonce"] == "0x0c" + assert account_data["nonce_changes"][1]["post_nonce"] == "0xff" + assert account_data["balance_changes"][0]["post_balance"] == "0x0f" + assert account_data["code_changes"][0]["block_access_index"] == "0x03" + assert account_data["storage_changes"][0]["slot"] == "0x0c" + assert ( + account_data["storage_changes"][0]["slot_changes"][0]["post_value"] + == "0xff" + ) + assert ( + account_data["storage_changes"][0]["slot_changes"][1]["post_value"] + == "0x1000" + ) + assert account_data["storage_reads"] == ["0x01", "0x0f", "0x0100"] + + # Round-trip: deserialize and verify equality + restored = BlockAccessList.model_validate(json_data) + assert restored == original diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py new file mode 100644 index 0000000000..942abf6bb2 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py @@ -0,0 +1,388 @@ +""" +Tests for BlockAccessList.validate_structure() method. + +These tests verify that the BAL structural validation correctly enforces +EIP-7928 requirements for ordering and uniqueness. +""" + +from typing import List, Union + +import pytest + +from execution_testing.base_types import Address +from execution_testing.test_types.block_access_list import ( + BalAccountChange, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessList, + BlockAccessListValidationError, +) + + +def test_bal_address_ordering_validation() -> None: + """Test that BAL addresses must be in lexicographic order.""" + alice = Address(0xAA) + bob = Address(0xBB) + + # Correct order: alice < bob + bal_valid = BlockAccessList( + [ + BalAccountChange(address=alice), + BalAccountChange(address=bob), + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order: bob before alice + bal_invalid = BlockAccessList( + [ + BalAccountChange(address=bob), + BalAccountChange(address=alice), + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="addresses are not in lexicographic order", + ): + bal_invalid.validate_structure() + + +def test_bal_storage_slot_ordering() -> None: + """Test that storage slots must be in ascending order.""" + addr = Address(0xA) + + # Correct order + bal_valid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot(slot=0, slot_changes=[]), + BalStorageSlot(slot=1, slot_changes=[]), + BalStorageSlot(slot=2, slot_changes=[]), + ], + ) + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order: slot 2 before slot 1 + bal_invalid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot(slot=0, slot_changes=[]), + BalStorageSlot(slot=2, slot_changes=[]), + BalStorageSlot(slot=1, slot_changes=[]), + ], + ) + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Storage slots not in ascending order", + ): + bal_invalid.validate_structure() + + +def test_bal_storage_reads_ordering() -> None: + """Test that storage reads must be in ascending order.""" + addr = Address(0xA) + + # Correct order + bal_valid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_reads=[0, 1, 2], + ) + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order + bal_invalid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_reads=[0, 2, 1], + ) + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Storage reads not in ascending order", + ): + bal_invalid.validate_structure() + + +@pytest.mark.parametrize( + "field_name", + ["nonce_changes", "balance_changes", "code_changes"], +) +def test_bal_block_access_indices_ordering(field_name: str) -> None: + """ + Test that transaction indices must be in ascending order within change lists. + """ + addr = Address(0xA) + + changes_valid: List[Union[BalNonceChange, BalBalanceChange, BalCodeChange]] + changes_invalid: List[ + Union[BalNonceChange, BalBalanceChange, BalCodeChange] + ] + + # Correct order: block_access_index 1, 2, 3 + if field_name == "nonce_changes": + changes_valid = [ + BalNonceChange( + block_access_index=1, + post_nonce=1, + ), + BalNonceChange( + block_access_index=2, + post_nonce=2, + ), + BalNonceChange( + block_access_index=3, + post_nonce=3, + ), + ] + changes_invalid = [ + BalNonceChange( + block_access_index=1, + post_nonce=1, + ), + BalNonceChange( + block_access_index=3, + post_nonce=3, + ), + BalNonceChange( + block_access_index=2, + post_nonce=2, + ), + ] + elif field_name == "balance_changes": + changes_valid = [ + BalBalanceChange( + block_access_index=1, + post_balance=100, + ), + BalBalanceChange( + block_access_index=2, + post_balance=200, + ), + BalBalanceChange( + block_access_index=3, + post_balance=300, + ), + ] + changes_invalid = [ + BalBalanceChange( + block_access_index=1, + post_balance=100, + ), + BalBalanceChange( + block_access_index=3, + post_balance=300, + ), + BalBalanceChange( + block_access_index=2, + post_balance=200, + ), + ] + elif field_name == "code_changes": + changes_valid = [ + BalCodeChange(block_access_index=1, new_code=b"code1"), + BalCodeChange(block_access_index=2, new_code=b"code2"), + BalCodeChange(block_access_index=3, new_code=b"code3"), + ] + changes_invalid = [ + BalCodeChange(block_access_index=1, new_code=b"code1"), + BalCodeChange(block_access_index=3, new_code=b"code3"), + BalCodeChange(block_access_index=2, new_code=b"code2"), + ] + + bal_valid = BlockAccessList( + [BalAccountChange(address=addr, **{field_name: changes_valid})] + ) + bal_valid.validate_structure() # Should not raise + + bal_invalid = BlockAccessList( + [BalAccountChange(address=addr, **{field_name: changes_invalid})] + ) + + with pytest.raises( + BlockAccessListValidationError, + match=f"Block access indices not in ascending order in {field_name}", + ): + bal_invalid.validate_structure() + + +@pytest.mark.parametrize( + "field_name", + ["nonce_changes", "balance_changes", "code_changes"], +) +def test_bal_duplicate_block_access_indices(field_name: str) -> None: + """ + Test that BAL must not have duplicate tx indices in change lists. + """ + addr = Address(0xA) + + changes: List[Union[BalNonceChange, BalBalanceChange, BalCodeChange]] + + # Duplicate block_access_index=1 + if field_name == "nonce_changes": + changes = [ + BalNonceChange( + block_access_index=1, + post_nonce=1, + ), + BalNonceChange( + block_access_index=1, + post_nonce=2, + ), # duplicate block_access_index + BalNonceChange( + block_access_index=2, + post_nonce=3, + ), + ] + elif field_name == "balance_changes": + changes = [ + BalBalanceChange( + block_access_index=1, + post_balance=100, + ), + BalBalanceChange( + block_access_index=1, + post_balance=200, + ), # duplicate block_access_index + BalBalanceChange( + block_access_index=2, + post_balance=300, + ), + ] + elif field_name == "code_changes": + changes = [ + BalCodeChange(block_access_index=1, new_code=b"code1"), + BalCodeChange( + block_access_index=1, new_code=b"" + ), # duplicate block_access_index + BalCodeChange(block_access_index=2, new_code=b"code2"), + ] + + bal = BlockAccessList( + [BalAccountChange(address=addr, **{field_name: changes})] + ) + + with pytest.raises( + BlockAccessListValidationError, + match=f"Duplicate transaction indices in {field_name}.*Duplicates: \\[1\\]", + ): + bal.validate_structure() + + +def test_bal_storage_duplicate_block_access_indices() -> None: + """ + Test that storage changes must not have duplicate tx indices within same slot. + """ + addr = Address(0xA) + + # Create storage changes with duplicate block_access_index within the same slot + bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=100, + ), + BalStorageChange( + block_access_index=1, + post_value=200, + ), # duplicate block_access_index + BalStorageChange( + block_access_index=2, + post_value=300, + ), + ], + ) + ], + ) + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Duplicate transaction indices in storage slot.*Duplicates: \\[1\\]", + ): + bal.validate_structure() + + +def test_bal_multiple_violations() -> None: + """ + Test that validation catches the first violation when multiple exist. + """ + alice = Address(0xAA) + bob = Address(0xBB) + + # Wrong address order AND duplicate tx indices + bal = BlockAccessList( + [ + BalAccountChange( + address=bob, # Should come after alice + nonce_changes=[ + BalNonceChange( + block_access_index=1, + post_nonce=1, + ), + BalNonceChange( + block_access_index=1, + post_nonce=2, + ), # duplicate + ], + ), + BalAccountChange(address=alice), + ] + ) + + # Should catch the first error (address ordering) + with pytest.raises( + BlockAccessListValidationError, + match="addresses are not in lexicographic order", + ): + bal.validate_structure() + + +def test_bal_empty_list_valid() -> None: + """Test that an empty BAL is valid.""" + bal = BlockAccessList([]) + bal.validate_structure() # Should not raise + + +def test_bal_single_account_valid() -> None: + """Test that a BAL with a single account is valid.""" + bal = BlockAccessList( + [ + BalAccountChange( + address=Address(0xA), + nonce_changes=[ + BalNonceChange( + block_access_index=1, + post_nonce=1, + ) + ], + ) + ] + ) + bal.validate_structure() # Should not raise diff --git a/pyproject.toml b/pyproject.toml index 8a03e7ac9e..dc05b9dad3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,13 @@ packages = [ "ethereum.forks.osaka.vm.instructions", "ethereum.forks.osaka.vm.precompiled_contracts", "ethereum.forks.osaka.vm.precompiled_contracts.bls12_381", + "ethereum.forks.amsterdam", + "ethereum.forks.amsterdam.block_access_lists", + "ethereum.forks.amsterdam.utils", + "ethereum.forks.amsterdam.vm", + "ethereum.forks.amsterdam.vm.instructions", + "ethereum.forks.amsterdam.vm.precompiled_contracts", + "ethereum.forks.amsterdam.vm.precompiled_contracts.bls12_381", ] [tool.setuptools.package-data] @@ -378,6 +385,15 @@ ignore = [ "src/ethereum_spec_tools/evm_tools/t8n/evm_trace.py" = [ "N815" # The traces must use camel case in JSON property names ] +"src/ethereum/forks/amsterdam/blocks.py" = [ + "E501" # Line too long - needed for long ref links +] + "src/ethereum/forks/amsterdam/block_access_lists/builder.py" = [ + "E501" # Line too long - needed for long ref links + ] +"src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py" = [ + "E501" # Line too long - needed for long ref links + ] "tests/*" = ["ARG001"] "vulture_whitelist.py" = [ "B018", # Useless expression (intentional for Vulture whitelisting) @@ -385,6 +401,10 @@ ignore = [ "F405", # Undefined names from star imports ] +[tool.ruff.lint.mccabe] +# Set the maximum allowed cyclomatic complexity. C901 default is 10. +max-complexity = 7 + [tool.codespell] builtin = "clear,code,usage" # Version control & tooling, build artifacts, data files, test fixtures, temp files, lock files diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py index ed77d4700c..e6f3e9476a 100644 --- a/src/ethereum/forks/amsterdam/__init__.py +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -1,9 +1,14 @@ """ -The Amsterdam fork ([EIP-7773]). +The Amsterdam fork ([EIP-7773]) includes block-level access lists. + +### Changes + +- [EIP-7928: Block-Level Access Lists][EIP-7928] ### Releases [EIP-7773]: https://eips.ethereum.org/EIPS/eip-7773 +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 """ from ethereum.fork_criteria import ForkCriteria, Unscheduled diff --git a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py new file mode 100644 index 0000000000..8c3fef14a0 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -0,0 +1,31 @@ +""" +Block Access Lists (EIP-7928) implementation for Ethereum Amsterdam fork. +""" + +from .builder import ( + BlockAccessListBuilder, + add_balance_change, + add_code_change, + add_nonce_change, + add_storage_read, + add_storage_write, + add_touched_account, + build_block_access_list, +) +from .rlp_utils import ( + compute_block_access_list_hash, + rlp_encode_block_access_list, +) + +__all__ = [ + "BlockAccessListBuilder", + "add_balance_change", + "add_code_change", + "add_nonce_change", + "add_storage_read", + "add_storage_write", + "add_touched_account", + "build_block_access_list", + "compute_block_access_list_hash", + "rlp_encode_block_access_list", +] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py new file mode 100644 index 0000000000..ff5426746a --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -0,0 +1,519 @@ +""" +Implements the Block Access List builder that tracks all account +and storage accesses during block execution and constructs the final +[`BlockAccessList`]. + +The builder follows a two-phase approach: + +1. **Collection Phase**: During transaction execution, all state accesses are + recorded via the tracking functions. +2. **Build Phase**: After block execution, the accumulated data is sorted + and encoded into the final deterministic format. + +[`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Set + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64, U256 + +from ..fork_types import Address +from .rlp_types import ( + AccountChanges, + BalanceChange, + BlockAccessIndex, + BlockAccessList, + CodeChange, + NonceChange, + SlotChanges, + StorageChange, +) + +if TYPE_CHECKING: + from ..state_tracker import StateChanges + + +@dataclass +class AccountData: + """ + Account data stored in the builder during block execution. + + This dataclass tracks all changes made to a single account throughout + the execution of a block, organized by the type of change and the + transaction index where it occurred. + """ + + storage_changes: Dict[U256, List[StorageChange]] = field( + default_factory=dict + ) + """ + Mapping from storage slot to list of changes made to that slot. + Each change includes the transaction index and new value. + """ + + storage_reads: Set[U256] = field(default_factory=set) + """ + Set of storage slots that were read but not modified. + """ + + balance_changes: List[BalanceChange] = field(default_factory=list) + """ + List of balance changes for this account, ordered by transaction index. + """ + + nonce_changes: List[NonceChange] = field(default_factory=list) + """ + List of nonce changes for this account, ordered by transaction index. + """ + + code_changes: List[CodeChange] = field(default_factory=list) + """ + List of code changes (contract deployments) for this account, + ordered by transaction index. + """ + + +@dataclass +class BlockAccessListBuilder: + """ + Builder for constructing [`BlockAccessList`] efficiently during transaction + execution. + + The builder accumulates all account and storage accesses during block + execution and constructs a deterministic access list. Changes are tracked + by address, field type, and transaction index to enable efficient + reconstruction of state changes. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + """ + + accounts: Dict[Address, AccountData] = field(default_factory=dict) + """ + Mapping from account address to its tracked changes during block execution. + """ + + +def ensure_account(builder: BlockAccessListBuilder, address: Address) -> None: + """ + Ensure an account exists in the builder's tracking structure. + + Creates an empty [`AccountData`] entry for the given address if it + doesn't already exist. This function is idempotent and safe to call + multiple times for the same address. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address to ensure exists. + + [`AccountData`] : + ref:ethereum.forks.amsterdam.block_access_lists.builder.AccountData + + """ + if address not in builder.accounts: + builder.accounts[address] = AccountData() + + +def add_storage_write( + builder: BlockAccessListBuilder, + address: Address, + slot: U256, + block_access_index: BlockAccessIndex, + new_value: U256, +) -> None: + """ + Add a storage write operation to the block access list. + + Records a storage slot modification for a given address at a specific + transaction index. If multiple writes occur to the same slot within the + same transaction (same block_access_index), only the final value is kept. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose storage is being modified. + slot : + The storage slot being written to. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + new_value : + The new value being written to the storage slot. + + """ + ensure_account(builder, address) + + if slot not in builder.accounts[address].storage_changes: + builder.accounts[address].storage_changes[slot] = [] + + # Check if there's already an entry with the same block_access_index + # If so, update it with the new value, keeping only the final write + changes = builder.accounts[address].storage_changes[slot] + for i, existing_change in enumerate(changes): + if existing_change.block_access_index == block_access_index: + # Update the existing entry with the new value + changes[i] = StorageChange( + block_access_index=block_access_index, new_value=new_value + ) + return + + # No existing entry found, append new change + change = StorageChange( + block_access_index=block_access_index, new_value=new_value + ) + builder.accounts[address].storage_changes[slot].append(change) + + +def add_storage_read( + builder: BlockAccessListBuilder, address: Address, slot: U256 +) -> None: + """ + Add a storage read operation to the block access list. + + Records that a storage slot was read during execution. Storage slots + that are both read and written will only appear in the storage changes + list, not in the storage reads list, as per [EIP-7928]. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose storage is being read. + slot : + The storage slot being read. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + + """ + ensure_account(builder, address) + builder.accounts[address].storage_reads.add(slot) + + +def add_balance_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + post_balance: U256, +) -> None: + """ + Add a balance change to the block access list. + + Records the post-transaction balance for an account after it has been + modified. This includes changes from transfers, gas fees, block rewards, + and any other balance-affecting operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose balance changed. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + post_balance : + The account balance after the change as U256. + + """ + ensure_account(builder, address) + + # Balance value is already U256 + balance_value = post_balance + + # Check if we already have a balance change for this tx_index and update it + # This ensures we only track the final balance per transaction + existing_changes = builder.accounts[address].balance_changes + for i, existing in enumerate(existing_changes): + if existing.block_access_index == block_access_index: + # Update the existing balance change with the new balance + existing_changes[i] = BalanceChange( + block_access_index=block_access_index, + post_balance=balance_value, + ) + return + + # No existing change for this tx_index, add a new one + change = BalanceChange( + block_access_index=block_access_index, post_balance=balance_value + ) + builder.accounts[address].balance_changes.append(change) + + +def add_nonce_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + new_nonce: U64, +) -> None: + """ + Add a nonce change to the block access list. + + Records a nonce increment for an account. This occurs when an EOA sends + a transaction or when a contract performs [`CREATE`] or [`CREATE2`] + operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose nonce changed. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + new_nonce : + The new nonce value after the change. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + ensure_account(builder, address) + + # Check if we already have a nonce change for this tx_index and update it + # This ensures we only track the final (highest) nonce per transaction + existing_changes = builder.accounts[address].nonce_changes + for i, existing in enumerate(existing_changes): + if existing.block_access_index == block_access_index: + # Keep the highest nonce value + if new_nonce > existing.new_nonce: + existing_changes[i] = NonceChange( + block_access_index=block_access_index, new_nonce=new_nonce + ) + return + + # No existing change for this tx_index, add a new one + change = NonceChange( + block_access_index=block_access_index, new_nonce=new_nonce + ) + builder.accounts[address].nonce_changes.append(change) + + +def add_code_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + new_code: Bytes, +) -> None: + """ + Add a code change to the block access list. + + Records contract code deployment or modification. This typically occurs + during contract creation via [`CREATE`], [`CREATE2`], or [`SETCODE`] + operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address receiving new code. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + new_code : + The deployed contract bytecode. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + ensure_account(builder, address) + + # Check if we already have a code change for this block_access_index + # This handles the case of in-transaction selfdestructs where code is + # first deployed and then cleared in the same transaction + existing_changes = builder.accounts[address].code_changes + for i, existing in enumerate(existing_changes): + if existing.block_access_index == block_access_index: + # Replace the existing code change with the new one + # For selfdestructs, this ensures we only record the final state (empty code) + existing_changes[i] = CodeChange( + block_access_index=block_access_index, new_code=new_code + ) + return + + # No existing change for this block_access_index, add a new one + change = CodeChange( + block_access_index=block_access_index, new_code=new_code + ) + builder.accounts[address].code_changes.append(change) + + +def add_touched_account( + builder: BlockAccessListBuilder, address: Address +) -> None: + """ + Add an account that was accessed but not modified. + + Records that an account was accessed during execution without any state + changes. This is used for operations like [`EXTCODEHASH`], [`BALANCE`], + [`EXTCODESIZE`], and [`EXTCODECOPY`] that read account data without + modifying it. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address that was accessed. + + [`EXTCODEHASH`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodehash + [`BALANCE`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.balance + [`EXTCODESIZE`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodesize + [`EXTCODECOPY`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodecopy + + """ + ensure_account(builder, address) + + +def _build_from_builder( + builder: BlockAccessListBuilder, +) -> BlockAccessList: + """ + Build the final [`BlockAccessList`] from a builder (internal helper). + + Constructs a deterministic block access list by sorting all accumulated + changes. The resulting list is ordered by: + + 1. Account addresses (lexicographically) + 2. Within each account: + - Storage slots (lexicographically) + - Transaction indices (numerically) for each change type + + Parameters + ---------- + builder : + The block access list builder containing all tracked changes. + + Returns + ------- + block_access_list : + The final sorted and encoded block access list. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + + """ + block_access_list: BlockAccessList = [] + + for address, changes in builder.accounts.items(): + storage_changes = [] + for slot, slot_changes in changes.storage_changes.items(): + sorted_changes = tuple( + sorted(slot_changes, key=lambda x: x.block_access_index) + ) + storage_changes.append( + SlotChanges(slot=slot, changes=sorted_changes) + ) + + storage_reads = [] + for slot in changes.storage_reads: + if slot not in changes.storage_changes: + storage_reads.append(slot) + + balance_changes = tuple( + sorted(changes.balance_changes, key=lambda x: x.block_access_index) + ) + nonce_changes = tuple( + sorted(changes.nonce_changes, key=lambda x: x.block_access_index) + ) + code_changes = tuple( + sorted(changes.code_changes, key=lambda x: x.block_access_index) + ) + + storage_changes.sort(key=lambda x: x.slot) + storage_reads.sort() + + account_change = AccountChanges( + address=address, + storage_changes=tuple(storage_changes), + storage_reads=tuple(storage_reads), + balance_changes=balance_changes, + nonce_changes=nonce_changes, + code_changes=code_changes, + ) + + block_access_list.append(account_change) + + block_access_list.sort(key=lambda x: x.address) + + return block_access_list + + +def build_block_access_list( + state_changes: "StateChanges", +) -> BlockAccessList: + """ + Build a [`BlockAccessList`] from a StateChanges frame. + + Converts the accumulated state changes from the frame-based architecture + into the final deterministic BlockAccessList format. + + Parameters + ---------- + state_changes : + The block-level StateChanges frame containing all changes from the block. + + Returns + ------- + block_access_list : + The final sorted and encoded block access list. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + [`StateChanges`]: ref:ethereum.forks.amsterdam.state_tracker.StateChanges + + """ + builder = BlockAccessListBuilder() + + # Add all touched addresses + for address in state_changes.touched_addresses: + add_touched_account(builder, address) + + # Add all storage reads + for address, slot in state_changes.storage_reads: + add_storage_read(builder, address, U256(int.from_bytes(slot))) + + # Add all storage writes + # Net-zero filtering happens at transaction commit time, not here. + # At block level, we track ALL writes at their respective indices. + for ( + address, + slot, + block_access_index, + ), value in state_changes.storage_writes.items(): + u256_slot = U256(int.from_bytes(slot)) + add_storage_write( + builder, address, u256_slot, block_access_index, value + ) + + # Add all balance changes (balance_changes is keyed by (address, index)) + for ( + address, + block_access_index, + ), new_balance in state_changes.balance_changes.items(): + add_balance_change(builder, address, block_access_index, new_balance) + + # Add all nonce changes + for address, block_access_index, new_nonce in state_changes.nonce_changes: + add_nonce_change(builder, address, block_access_index, new_nonce) + + # Add all code changes + # Filtering happens at transaction level in eoa_delegation.py + for ( + address, + block_access_index, + ), new_code in state_changes.code_changes.items(): + add_code_change(builder, address, block_access_index, new_code) + + return _build_from_builder(builder) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py new file mode 100644 index 0000000000..e604d43da1 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py @@ -0,0 +1,121 @@ +""" +Defines the RLP data structures for Block-Level Access Lists +as specified in EIP-7928. These structures enable efficient encoding and +decoding of all accounts and storage locations accessed during block execution. + +The encoding follows the pattern: +address -> field -> block_access_index -> change. +""" + +from dataclasses import dataclass +from typing import List, Tuple + +from ethereum_types.bytes import Bytes, Bytes20 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint + +# Type aliases for clarity (matching EIP-7928 specification) +Address = Bytes20 +StorageKey = U256 +StorageValue = U256 +CodeData = Bytes +BlockAccessIndex = Uint # uint16 in the spec, but using Uint for compatibility +Balance = U256 # Post-transaction balance in wei +Nonce = U64 + +# Constants chosen to support a 630m block gas limit +MAX_TXS = 30_000 +# MAX_SLOTS = 300_000 +# MAX_ACCOUNTS = 300_000 +MAX_CODE_SIZE = 24_576 +MAX_CODE_CHANGES = 1 + + +@slotted_freezable +@dataclass +class StorageChange: + """ + Storage change: [block_access_index, new_value]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + new_value: StorageValue + + +@slotted_freezable +@dataclass +class BalanceChange: + """ + Balance change: [block_access_index, post_balance]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + post_balance: Balance + + +@slotted_freezable +@dataclass +class NonceChange: + """ + Nonce change: [block_access_index, new_nonce]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + new_nonce: Nonce + + +@slotted_freezable +@dataclass +class CodeChange: + """ + Code change: [block_access_index, new_code]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + new_code: CodeData + + +@slotted_freezable +@dataclass +class SlotChanges: + """ + All changes to a single storage slot: [slot, [changes]]. + RLP encoded as a list. + """ + + slot: StorageKey + changes: Tuple[StorageChange, ...] + + +@slotted_freezable +@dataclass +class AccountChanges: + """ + All changes for a single account, grouped by field type. + RLP encoded as: [address, storage_changes, storage_reads, + balance_changes, nonce_changes, code_changes]. + """ + + address: Address + + # slot -> [block_access_index -> new_value] + storage_changes: Tuple[SlotChanges, ...] + + # read-only storage keys + storage_reads: Tuple[StorageKey, ...] + + # [block_access_index -> post_balance] + balance_changes: Tuple[BalanceChange, ...] + + # [block_access_index -> new_nonce] + nonce_changes: Tuple[NonceChange, ...] + + # [block_access_index -> new_code] + code_changes: Tuple[CodeChange, ...] + + +BlockAccessList = List[AccountChanges] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py new file mode 100644 index 0000000000..cdbd1f4626 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -0,0 +1,117 @@ +""" +Utilities for working with Block Access Lists using RLP encoding, +as specified in EIP-7928. + +This module provides: + +- RLP encoding functions for all Block Access List types +- Hash computation using [`keccak256`] +- Validation logic to ensure structural correctness + +The encoding follows the RLP specification used throughout Ethereum. + +[`keccak256`]: ref:ethereum.crypto.hash.keccak256 +""" + +from typing import cast + +from ethereum_rlp import Extended, rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import Hash32, keccak256 + +from .rlp_types import BlockAccessList + + +def compute_block_access_list_hash( + block_access_list: BlockAccessList, +) -> Hash32: + """ + Compute the hash of a Block Access List. + + The Block Access List is RLP-encoded and then hashed with keccak256. + + Parameters + ---------- + block_access_list : + The Block Access List to hash. + + Returns + ------- + hash : + The keccak256 hash of the RLP-encoded Block Access List. + + """ + block_access_list_bytes = rlp_encode_block_access_list(block_access_list) + return keccak256(block_access_list_bytes) + + +def rlp_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes: + """ + Encode a [`BlockAccessList`] to RLP bytes. + + This is the top-level encoding function that produces the final RLP + representation of a block's access list, following the updated EIP-7928 + specification. + + Parameters + ---------- + block_access_list : + The block access list to encode. + + Returns + ------- + encoded : + The complete RLP-encoded block access list. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + + """ + # Encode as a list of AccountChanges directly (not wrapped) + account_changes_list = [] + for account in block_access_list: + # Each account is encoded as: + # [address, storage_changes, storage_reads, + # balance_changes, nonce_changes, code_changes] + storage_changes_list = [ + [ + slot_changes.slot, + [ + [Uint(c.block_access_index), c.new_value] + for c in slot_changes.changes + ], + ] + for slot_changes in account.storage_changes + ] + + storage_reads_list = list(account.storage_reads) + + balance_changes_list = [ + [Uint(bc.block_access_index), Uint(bc.post_balance)] + for bc in account.balance_changes + ] + + nonce_changes_list = [ + [Uint(nc.block_access_index), Uint(nc.new_nonce)] + for nc in account.nonce_changes + ] + + code_changes_list = [ + [Uint(cc.block_access_index), cc.new_code] + for cc in account.code_changes + ] + + account_changes_list.append( + [ + account.address, + storage_changes_list, + storage_reads_list, + balance_changes_list, + nonce_changes_list, + code_changes_list, + ] + ) + + encoded = rlp.encode(cast(Extended, account_changes_list)) + return Bytes(encoded) diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index ba3c27e9e3..70038d54cd 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -242,6 +242,18 @@ class Header: [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 """ + block_access_list_hash: Hash32 + """ + [`keccak256`] hash of the Block Access List containing all accounts and + storage locations accessed during block execution. Introduced in + [EIP-7928]. See [`compute_block_access_list_hash`][cbalh] for more + details. + + [`keccak256`]: ref:ethereum.crypto.hash.keccak256 + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + [cbalh]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_utils.compute_block_access_list_hash + """ # noqa: E501 + @slotted_freezable @dataclass diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 1d8bbcc106..3e45c3e953 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -29,6 +29,8 @@ ) from . import vm +from .block_access_lists.builder import build_block_access_list +from .block_access_lists.rlp_utils import compute_block_access_list_hash from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom from .exceptions import ( @@ -53,6 +55,7 @@ from .state import ( State, TransientStorage, + account_exists_and_is_empty, destroy_account, get_account, increment_nonce, @@ -60,6 +63,18 @@ set_account_balance, state_root, ) +from .state_tracker import ( + StateChanges, + capture_pre_balance, + commit_transaction_frame, + create_child_frame, + filter_net_zero_frame_changes, + increment_block_access_index, + track_address, + track_balance_change, + track_nonce_change, + track_selfdestruct, +) from .transactions import ( AccessListTransaction, BlobTransaction, @@ -233,6 +248,7 @@ def state_transition(chain: BlockChain, block: Block) -> None: prev_randao=block.header.prev_randao, excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, + state_changes=StateChanges(), ) block_output = apply_body( @@ -246,6 +262,9 @@ def state_transition(chain: BlockChain, block: Block) -> None: block_logs_bloom = logs_bloom(block_output.block_logs) withdrawals_root = root(block_output.withdrawals_trie) requests_hash = compute_requests_hash(block_output.requests) + computed_block_access_list_hash = compute_block_access_list_hash( + block_output.block_access_list + ) if block_output.block_gas_used != block.header.gas_used: raise InvalidBlock( @@ -265,6 +284,8 @@ def state_transition(chain: BlockChain, block: Block) -> None: raise InvalidBlock if requests_hash != block.header.requests_hash: raise InvalidBlock + if computed_block_access_list_hash != block.header.block_access_list_hash: + raise InvalidBlock("Invalid block access list hash") chain.blocks.append(block) if len(chain.blocks) > 255: @@ -613,6 +634,10 @@ def process_system_transaction( Output of processing the system transaction. """ + # EIP-7928: Create a child frame for system transaction + # This allows proper pre-state capture for net-zero filtering + system_tx_state_changes = create_child_frame(block_env.state_changes) + tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, gas_price=block_env.base_fee_per_gas, @@ -624,8 +649,12 @@ def process_system_transaction( authorizations=(), index_in_block=None, tx_hash=None, + state_changes=system_tx_state_changes, ) + # Create call frame as child of tx frame + call_frame = create_child_frame(tx_env.state_changes) + system_tx_message = Message( block_env=block_env, tx_env=tx_env, @@ -644,10 +673,16 @@ def process_system_transaction( accessed_storage_keys=set(), disable_precompiles=False, parent_evm=None, + is_create=False, + state_changes=call_frame, ) system_tx_output = process_message_call(system_tx_message) + # Commit system transaction changes to block frame + # System transactions always succeed (or block is invalid) + commit_transaction_frame(tx_env.state_changes) + return system_tx_output @@ -764,6 +799,10 @@ def apply_body( """ block_output = vm.BlockOutput() + # EIP-7928: System contracts use block_access_index 0 + # The block frame already starts at index 0, so system transactions + # naturally use that index through the block frame + process_unchecked_system_transaction( block_env=block_env, target_address=BEACON_ROOTS_ADDRESS, @@ -779,12 +818,21 @@ def apply_body( for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) + # EIP-7928: Increment block frame to post-execution index + # After N transactions, block frame is at index N + # Post-execution operations (withdrawals, etc.) use index N+1 + increment_block_access_index(block_env.state_changes) + process_withdrawals(block_env, block_output, withdrawals) process_general_purpose_requests( block_env=block_env, block_output=block_output, ) + # Build block access list from block_env.state_changes + block_output.block_access_list = build_block_access_list( + block_env.state_changes + ) return block_output @@ -864,6 +912,20 @@ def process_transaction( Index of the transaction in the block. """ + # EIP-7928: Create a transaction-level StateChanges frame + # The frame will read the current block_access_index from the block frame + increment_block_access_index(block_env.state_changes) + tx_state_changes = create_child_frame(block_env.state_changes) + + # Capture coinbase pre-balance for net-zero filtering + coinbase_pre_balance = get_account( + block_env.state, block_env.coinbase + ).balance + track_address(tx_state_changes, block_env.coinbase) + capture_pre_balance( + tx_state_changes, block_env.coinbase, coinbase_pre_balance + ) + trie_set( block_output.transactions_trie, rlp.encode(index), @@ -893,7 +955,16 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas + + # Track sender nonce increment increment_nonce(block_env.state, sender) + sender_nonce_after = get_account(block_env.state, sender).nonce + track_nonce_change(tx_state_changes, sender, U64(sender_nonce_after)) + + # Track sender balance deduction for gas fee + sender_balance_before = get_account(block_env.state, sender).balance + track_address(tx_state_changes, sender) + capture_pre_balance(tx_state_changes, sender, sender_balance_before) sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee @@ -901,6 +972,11 @@ def process_transaction( set_account_balance( block_env.state, sender, U256(sender_balance_after_gas_fee) ) + track_balance_change( + tx_state_changes, + sender, + U256(sender_balance_after_gas_fee), + ) access_list_addresses = set() access_list_storage_keys = set() @@ -934,9 +1010,14 @@ def process_transaction( authorizations=authorizations, index_in_block=index, tx_hash=get_transaction_hash(encode_transaction(tx)), + state_changes=tx_state_changes, ) - message = prepare_message(block_env, tx_env, tx) + message = prepare_message( + block_env, + tx_env, + tx, + ) tx_output = process_message_call(message) @@ -966,19 +1047,29 @@ def process_transaction( block_env.state, sender ).balance + U256(gas_refund_amount) set_account_balance(block_env.state, sender, sender_balance_after_refund) + track_balance_change( + tx_env.state_changes, + sender, + sender_balance_after_refund, + ) - # transfer miner fees coinbase_balance_after_mining_fee = get_account( block_env.state, block_env.coinbase ).balance + U256(transaction_fee) + set_account_balance( - block_env.state, + block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee + ) + track_balance_change( + tx_env.state_changes, block_env.coinbase, coinbase_balance_after_mining_fee, ) - for address in tx_output.accounts_to_delete: - destroy_account(block_env.state, address) + if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( + block_env.state, block_env.coinbase + ): + destroy_account(block_env.state, block_env.coinbase) block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -998,6 +1089,14 @@ def process_transaction( block_output.block_logs += tx_output.logs + for address in tx_output.accounts_to_delete: + destroy_account(block_env.state, address) + track_selfdestruct(tx_env.state_changes, address) + + # EIP-7928: Commit transaction frame (includes net-zero filtering). + # Must happen AFTER destroy_account so filtering sees correct state. + commit_transaction_frame(tx_env.state_changes) + def process_withdrawals( block_env: vm.BlockEnvironment, @@ -1007,6 +1106,12 @@ def process_withdrawals( """ Increase the balance of the withdrawing account. """ + # Capture pre-state for withdrawal balance filtering + withdrawal_addresses = {wd.address for wd in withdrawals} + for address in withdrawal_addresses: + pre_balance = get_account(block_env.state, address).balance + track_address(block_env.state_changes, address) + capture_pre_balance(block_env.state_changes, address, pre_balance) def increase_recipient_balance(recipient: Account) -> None: recipient.balance += wd.amount * U256(10**9) @@ -1020,6 +1125,19 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(block_env.state, wd.address, increase_recipient_balance) + new_balance = get_account(block_env.state, wd.address).balance + track_balance_change( + block_env.state_changes, + wd.address, + new_balance, + ) + + if account_exists_and_is_empty(block_env.state, wd.address): + destroy_account(block_env.state, wd.address) + + # EIP-7928: Filter net-zero balance changes for withdrawals + filter_net_zero_frame_changes(block_env.state_changes) + def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: """ diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index e997411f6d..fcf12e971b 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -17,7 +17,7 @@ """ from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify @@ -26,6 +26,9 @@ from .fork_types import EMPTY_ACCOUNT, Account, Address, Root from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set +if TYPE_CHECKING: + from .vm import BlockEnvironment # noqa: F401 + @dataclass class State: @@ -440,6 +443,34 @@ def account_has_storage(state: State, address: Address) -> bool: return address in state._storage_tries +def account_exists_and_is_empty(state: State, address: Address) -> bool: + """ + Checks if an account exists and has zero nonce, empty code and zero + balance. + + Parameters + ---------- + state: + The state + address: + Address of the account that needs to be checked. + + Returns + ------- + exists_and_is_empty : `bool` + True if an account exists and has zero nonce, empty code and zero + balance, False otherwise. + + """ + account = get_account_optional(state, address) + return ( + account is not None + and account.nonce == Uint(0) + and account.code == b"" + and account.balance == 0 + ) + + def is_account_alive(state: State, address: Address) -> bool: """ Check whether an account is both in the state and non-empty. @@ -469,16 +500,7 @@ def modify_state( exists and has zero nonce, empty code, and zero balance, it is destroyed. """ set_account(state, address, modify(get_account(state, address), f)) - - account = get_account_optional(state, address) - account_exists_and_is_empty = ( - account is not None - and account.nonce == Uint(0) - and account.code == b"" - and account.balance == 0 - ) - - if account_exists_and_is_empty: + if account_exists_and_is_empty(state, address): destroy_account(state, address) @@ -490,6 +512,18 @@ def move_ether( ) -> None: """ Move funds between accounts. + + Parameters + ---------- + state: + The current state. + sender_address: + Address of the sender. + recipient_address: + Address of the recipient. + amount: + The amount to transfer. + """ def reduce_sender_balance(sender: Account) -> None: @@ -514,7 +548,7 @@ def set_account_balance(state: State, address: Address, amount: U256) -> None: The current state. address: - Address of the account whose nonce needs to be incremented. + Address of the account whose balance needs to be set. amount: The amount that needs to set in balance. @@ -570,6 +604,32 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) +def set_authority_code(state: State, address: Address, code: Bytes) -> None: + """ + Sets authority account code for EIP-7702 delegation. + + This function is used specifically for setting authority code within + EIP-7702 Set Code Transactions. + + Parameters + ---------- + state: + The current state. + + address: + Address of the authority account whose code needs to be set. + + code: + The delegation designation bytecode to set. + + """ + + def write_code(sender: Account) -> None: + sender.code = code + + modify_state(state, address, write_code) + + def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: """ Get the original value in a storage slot i.e. the value before the current diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py new file mode 100644 index 0000000000..189e088895 --- /dev/null +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -0,0 +1,558 @@ +""" +EIP-7928 Block Access Lists: Hierarchical State Change Tracking. + +Frame hierarchy mirrors EVM execution: Block -> Transaction -> Call frames. +Each frame tracks state accesses and merges to parent on completion. + +On success, changes merge upward with net-zero filtering (pre-state vs final). +On failure, only reads merge (writes discarded). Pre-state captures use +first-write-wins semantics and are stored at the transaction frame level. + +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +""" + +from dataclasses import dataclass, field +from typing import Dict, Optional, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from .block_access_lists.rlp_types import BlockAccessIndex +from .fork_types import Address + + +@dataclass +class StateChanges: + """ + Tracks state changes within a single execution frame. + + Frames form a hierarchy (Block -> Transaction -> Call) linked by parent + references. The block_access_index is stored at the root frame. Pre-state + captures (pre_balances, etc.) are only populated at the transaction level. + """ + + parent: Optional["StateChanges"] = None + block_access_index: BlockAccessIndex = BlockAccessIndex(0) + + touched_addresses: Set[Address] = field(default_factory=set) + storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) + storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256] = ( + field(default_factory=dict) + ) + + balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256] = field( + default_factory=dict + ) + nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( + default_factory=set + ) + code_changes: Dict[Tuple[Address, BlockAccessIndex], Bytes] = field( + default_factory=dict + ) + + # Pre-state captures (transaction-scoped, only populated at tx frame) + pre_balances: Dict[Address, U256] = field(default_factory=dict) + pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( + default_factory=dict + ) + pre_code: Dict[Address, Bytes] = field(default_factory=dict) + + +def get_block_frame(state_changes: StateChanges) -> StateChanges: + """ + Walk to the root (block-level) frame. + + Parameters + ---------- + state_changes : + Any frame in the hierarchy. + + Returns + ------- + block_frame : StateChanges + The root block-level frame. + + """ + block_frame = state_changes + while block_frame.parent is not None: + block_frame = block_frame.parent + return block_frame + + +def increment_block_access_index(root_frame: StateChanges) -> None: + """ + Increment the block access index in the root frame. + + Parameters + ---------- + root_frame : + The root block-level frame. + + """ + root_frame.block_access_index = BlockAccessIndex( + root_frame.block_access_index + Uint(1) + ) + + +def get_transaction_frame(state_changes: StateChanges) -> StateChanges: + """ + Walk to the transaction-level frame (child of block frame). + + Parameters + ---------- + state_changes : + Any frame in the hierarchy. + + Returns + ------- + tx_frame : StateChanges + The transaction-level frame. + + """ + tx_frame = state_changes + while tx_frame.parent is not None and tx_frame.parent.parent is not None: + tx_frame = tx_frame.parent + return tx_frame + + +def capture_pre_balance( + tx_frame: StateChanges, address: Address, balance: U256 +) -> None: + """ + Capture pre-balance if not already captured (first-write-wins). + + Parameters + ---------- + tx_frame : + The transaction-level frame. + address : + The address whose balance to capture. + balance : + The current balance value. + + """ + # Only capture pre-values in a transaction level + # or block level frame + assert tx_frame.parent is None or tx_frame.parent.parent is None + if address not in tx_frame.pre_balances: + tx_frame.pre_balances[address] = balance + + +def capture_pre_storage( + tx_frame: StateChanges, address: Address, key: Bytes32, value: U256 +) -> None: + """ + Capture pre-storage value if not already captured (first-write-wins). + + Parameters + ---------- + tx_frame : + The transaction-level frame. + address : + The address whose storage to capture. + key : + The storage key. + value : + The current storage value. + + """ + # Only capture pre-values in a transaction level + # or block level frame + assert tx_frame.parent is None or tx_frame.parent.parent is None + slot = (address, key) + if slot not in tx_frame.pre_storage: + tx_frame.pre_storage[slot] = value + + +def capture_pre_code( + tx_frame: StateChanges, address: Address, code: Bytes +) -> None: + """ + Capture pre-code if not already captured (first-write-wins). + + Parameters + ---------- + tx_frame : + The transaction-level frame. + address : + The address whose code to capture. + code : + The current code value. + + """ + # Only capture pre-values in a transaction level + # or block level frame + assert tx_frame.parent is None or tx_frame.parent.parent is None + if address not in tx_frame.pre_code: + tx_frame.pre_code[address] = code + + +def track_address(state_changes: StateChanges, address: Address) -> None: + """ + Record that an address was accessed. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address that was accessed. + + """ + state_changes.touched_addresses.add(address) + + +def track_storage_read( + state_changes: StateChanges, address: Address, key: Bytes32 +) -> None: + """ + Record a storage read operation. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose storage was read. + key : + The storage key that was read. + + """ + state_changes.storage_reads.add((address, key)) + + +def track_storage_write( + state_changes: StateChanges, + address: Address, + key: Bytes32, + value: U256, +) -> None: + """ + Record a storage write keyed by (address, key, block_access_index). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose storage was written. + key : + The storage key that was written. + value : + The new storage value. + + """ + idx = state_changes.block_access_index + state_changes.storage_writes[(address, key, idx)] = value + + +def track_balance_change( + state_changes: StateChanges, + address: Address, + new_balance: U256, +) -> None: + """ + Record a balance change keyed by (address, block_access_index). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose balance changed. + new_balance : + The new balance value. + + """ + idx = state_changes.block_access_index + state_changes.balance_changes[(address, idx)] = new_balance + + +def track_nonce_change( + state_changes: StateChanges, + address: Address, + new_nonce: U64, +) -> None: + """ + Record a nonce change as (address, block_access_index, new_nonce). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose nonce changed. + new_nonce : + The new nonce value. + + """ + idx = state_changes.block_access_index + state_changes.nonce_changes.add((address, idx, new_nonce)) + + +def track_code_change( + state_changes: StateChanges, + address: Address, + new_code: Bytes, +) -> None: + """ + Record a code change keyed by (address, block_access_index). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose code changed. + new_code : + The new code value. + + """ + idx = state_changes.block_access_index + state_changes.code_changes[(address, idx)] = new_code + + +def track_selfdestruct( + tx_frame: StateChanges, + address: Address, +) -> None: + """ + Handle selfdestruct of account created in same transaction. + + Per EIP-7928/EIP-6780: removes nonce/code changes, converts storage + writes to reads. Balance changes handled by net-zero filtering. + + Parameters + ---------- + tx_frame : + The state changes tracker. Should be a transaction frame. + address : + The address that self-destructed. + + """ + # Has to be a transaction frame + assert tx_frame.parent is not None and tx_frame.parent.parent is None + + idx = tx_frame.block_access_index + + # Remove nonce changes from current transaction + tx_frame.nonce_changes = { + (addr, i, nonce) + for addr, i, nonce in tx_frame.nonce_changes + if not (addr == address and i == idx) + } + + # Remove balance changes from current transaction + if (address, idx) in tx_frame.balance_changes: + pre_balance = tx_frame.pre_balances[address] + if pre_balance == U256(0): + # Post balance will be U256(0) after deletion. + # So no change and hence bal does not need to + # capture anything. + del tx_frame.balance_changes[(address, idx)] + + # Remove code changes from current transaction + if (address, idx) in tx_frame.code_changes: + del tx_frame.code_changes[(address, idx)] + + # Convert storage writes from current transaction to reads + for addr, key, i in list(tx_frame.storage_writes.keys()): + if addr == address and i == idx: + del tx_frame.storage_writes[(addr, key, i)] + tx_frame.storage_reads.add((addr, key)) + + +def merge_on_success(child_frame: StateChanges) -> None: + """ + Merge child frame into parent on success. + + Child values overwrite parent values (most recent wins). No net-zero + filtering here - that happens once at transaction commit via + normalize_transaction(). + + Parameters + ---------- + child_frame : + The child frame being merged. + + """ + assert child_frame.parent is not None + parent_frame = child_frame.parent + + # Merge address accesses + parent_frame.touched_addresses.update(child_frame.touched_addresses) + + # Merge storage: reads union, writes overwrite (child supersedes parent) + parent_frame.storage_reads.update(child_frame.storage_reads) + for storage_key, storage_value in child_frame.storage_writes.items(): + parent_frame.storage_writes[storage_key] = storage_value + + # Merge balance changes: child overwrites parent for same key + for balance_key, balance_value in child_frame.balance_changes.items(): + parent_frame.balance_changes[balance_key] = balance_value + + # Merge nonce changes: keep highest nonce per address + address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} + for addr, idx, nonce in child_frame.nonce_changes: + if ( + addr not in address_final_nonces + or nonce > address_final_nonces[addr][1] + ): + address_final_nonces[addr] = (idx, nonce) + for addr, (idx, final_nonce) in address_final_nonces.items(): + parent_frame.nonce_changes.add((addr, idx, final_nonce)) + + # Merge code changes: child overwrites parent for same key + for code_key, code_value in child_frame.code_changes.items(): + parent_frame.code_changes[code_key] = code_value + + +def merge_on_failure(child_frame: StateChanges) -> None: + """ + Merge child frame into parent on failure/revert. + + Only reads merge; writes are discarded (converted to reads). + + Parameters + ---------- + child_frame : + The failed child frame. + + """ + assert child_frame.parent is not None + parent_frame = child_frame.parent + # Only merge reads and address accesses on failure + parent_frame.touched_addresses.update(child_frame.touched_addresses) + parent_frame.storage_reads.update(child_frame.storage_reads) + + # Convert writes to reads (failed writes still accessed the slots) + for address, key, _idx in child_frame.storage_writes.keys(): + parent_frame.storage_reads.add((address, key)) + + # Note: balance_changes, nonce_changes, and code_changes are NOT + # merged on failure - they are discarded + + +def commit_transaction_frame(tx_frame: StateChanges) -> None: + """ + Commit transaction frame to block frame. + + Filters net-zero changes before merging to ensure only actual state + modifications are recorded in the block access list. + + Parameters + ---------- + tx_frame : + The transaction frame to commit. + + """ + assert tx_frame.parent is not None + block_frame = tx_frame.parent + + # Filter net-zero changes before committing + filter_net_zero_frame_changes(tx_frame) + + # Merge address accesses + block_frame.touched_addresses.update(tx_frame.touched_addresses) + + # Merge storage operations + block_frame.storage_reads.update(tx_frame.storage_reads) + for (addr, key, idx), value in tx_frame.storage_writes.items(): + block_frame.storage_writes[(addr, key, idx)] = value + + # Merge balance changes + for (addr, idx), final_balance in tx_frame.balance_changes.items(): + block_frame.balance_changes[(addr, idx)] = final_balance + + # Merge nonce changes + for addr, idx, nonce in tx_frame.nonce_changes: + block_frame.nonce_changes.add((addr, idx, nonce)) + + # Merge code changes + for (addr, idx), final_code in tx_frame.code_changes.items(): + block_frame.code_changes[(addr, idx)] = final_code + + +def create_child_frame(parent: StateChanges) -> StateChanges: + """ + Create a child frame linked to the given parent. + + Inherits block_access_index from parent so track functions can + access it directly without walking up the frame hierarchy. + + Parameters + ---------- + parent : + The parent frame. + + Returns + ------- + child : StateChanges + A new child frame with parent reference and inherited + block_access_index. + + """ + return StateChanges( + parent=parent, + block_access_index=parent.block_access_index, + ) + + +def filter_net_zero_frame_changes(tx_frame: StateChanges) -> None: + """ + Filter net-zero changes from transaction frame before commit. + + Compares final values against pre-tx state for storage, balance, and code. + Net-zero storage writes are converted to reads. Net-zero balance/code + changes are removed entirely. Nonces are not filtered (only increment). + + Parameters + ---------- + tx_frame : + The transaction-level state changes frame. + + """ + idx = tx_frame.block_access_index + + # Filter storage: compare against pre_storage, convert net-zero to reads + addresses_to_check_storage = [ + (addr, key) + for (addr, key, i) in tx_frame.storage_writes.keys() + if i == idx + ] + for addr, key in addresses_to_check_storage: + # For any (address, key) whose balance has changed, its + # pre-value should have been captured + assert (addr, key) in tx_frame.pre_storage + pre_value = tx_frame.pre_storage[(addr, key)] + post_value = tx_frame.storage_writes[(addr, key, idx)] + if pre_value == post_value: + # Net-zero write - convert to read + del tx_frame.storage_writes[(addr, key, idx)] + tx_frame.storage_reads.add((addr, key)) + + # Filter balance: compare pre vs post, remove if equal + addresses_to_check_balance = [ + addr for (addr, i) in tx_frame.balance_changes.keys() if i == idx + ] + for addr in addresses_to_check_balance: + # For any account whose balance has changed, its + # pre-balance should have been captured + assert addr in tx_frame.pre_balances + pre_balance = tx_frame.pre_balances[addr] + post_balance = tx_frame.balance_changes[(addr, idx)] + if pre_balance == post_balance: + del tx_frame.balance_changes[(addr, idx)] + + # Filter code: compare pre vs post, remove if equal + addresses_to_check_code = [ + addr for (addr, i) in tx_frame.code_changes.keys() if i == idx + ] + for addr in addresses_to_check_code: + assert addr in tx_frame.pre_code + pre_code = tx_frame.pre_code[addr] + post_code = tx_frame.code_changes[(addr, idx)] + if pre_code == post_code: + del tx_frame.code_changes[(addr, idx)] + + # Nonces: no filtering needed (nonces only increment, never net-zero) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index 107cdcaf7a..130532fef6 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -17,6 +17,7 @@ from ..fork_types import Address from ..state import get_account +from ..state_tracker import create_child_frame from ..transactions import Transaction from ..vm import BlockEnvironment, Message, TransactionEnvironment from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS @@ -69,6 +70,9 @@ def prepare_message( accessed_addresses.add(current_target) + # Create call frame as child of transaction frame + call_frame = create_child_frame(tx_env.state_changes) + return Message( block_env=block_env, tx_env=tx_env, @@ -87,4 +91,6 @@ def prepare_message( accessed_storage_keys=set(tx_env.access_list_storage_keys), disable_precompiles=False, parent_evm=None, + is_create=isinstance(tx.to, Bytes0), + state_changes=call_frame, ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index b2a8c5e2b9..6c47b50acf 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,9 +21,11 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException +from ..block_access_lists.rlp_types import BlockAccessList from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage +from ..state_tracker import StateChanges, merge_on_failure, merge_on_success from ..transactions import LegacyTransaction from ..trie import Trie @@ -47,6 +49,7 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 + state_changes: StateChanges @dataclass @@ -73,6 +76,8 @@ class BlockOutput: Total blob gas used in the block. requests : `Bytes` Hash of all the requests in the block. + block_access_list: `BlockAccessList` + The block access list for the block. """ block_gas_used: Uint = Uint(0) @@ -89,6 +94,7 @@ class BlockOutput: ) blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) + block_access_list: BlockAccessList = field(default_factory=list) @dataclass @@ -107,6 +113,7 @@ class TransactionEnvironment: authorizations: Tuple[Authorization, ...] index_in_block: Optional[Uint] tx_hash: Optional[Hash32] + state_changes: "StateChanges" = field(default_factory=StateChanges) @dataclass @@ -132,6 +139,8 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] + is_create: bool + state_changes: "StateChanges" = field(default_factory=StateChanges) @dataclass @@ -154,6 +163,7 @@ class Evm: error: Optional[EthereumException] accessed_addresses: Set[Address] accessed_storage_keys: Set[Tuple[Address, Bytes32]] + state_changes: StateChanges def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: @@ -175,6 +185,8 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.accessed_addresses.update(child_evm.accessed_addresses) evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) + merge_on_success(child_evm.state_changes) + def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ @@ -189,3 +201,5 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left + + merge_on_failure(child_evm.state_changes) diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 29909b5fa5..e56fb0cccd 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -5,7 +5,6 @@ from typing import Optional, Tuple from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover @@ -13,7 +12,18 @@ from ethereum.exceptions import InvalidBlock, InvalidSignatureError from ..fork_types import Address, Authorization -from ..state import account_exists, get_account, increment_nonce, set_code +from ..state import ( + account_exists, + get_account, + increment_nonce, + set_authority_code, +) +from ..state_tracker import ( + capture_pre_code, + track_address, + track_code_change, + track_nonce_change, +) from ..utils.hexadecimal import hex_to_address from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS from . import Evm, Message @@ -114,39 +124,41 @@ def recover_authority(authorization: Authorization) -> Address: return Address(keccak256(public_key)[12:32]) -def access_delegation( +def calculate_delegation_cost( evm: Evm, address: Address -) -> Tuple[bool, Address, Bytes, Uint]: +) -> Tuple[bool, Address, Uint]: """ - Get the delegation address, code, and the cost of access from the address. + Get the delegation address and the cost of access from the address. Parameters ---------- evm : `Evm` The execution frame. address : `Address` - The address to get the delegation from. + The address to check for delegation. Returns ------- - delegation : `Tuple[bool, Address, Bytes, Uint]` - The delegation address, code, and access gas cost. + delegation : `Tuple[bool, Address, Uint]` + The delegation address and access gas cost. """ state = evm.message.block_env.state + code = get_account(state, address).code + track_address(evm.state_changes, address) + if not is_valid_delegation(code): - return False, address, code, Uint(0) + return False, address, Uint(0) - address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + delegated_address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) + + if delegated_address in evm.accessed_addresses: + delegation_gas_cost = GAS_WARM_ACCESS else: - evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - code = get_account(state, address).code + delegation_gas_cost = GAS_COLD_ACCOUNT_ACCESS - return True, address, code, access_gas_cost + return True, delegated_address, delegation_gas_cost def set_delegation(message: Message) -> U256: @@ -184,6 +196,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code + track_address(message.tx_env.state_changes, authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -199,9 +212,20 @@ def set_delegation(message: Message) -> U256: code_to_set = b"" else: code_to_set = EOA_DELEGATION_MARKER + auth.address - set_code(state, authority, code_to_set) + + tx_frame = message.tx_env.state_changes + # EIP-7928: Capture pre-code before any changes + capture_pre_code(tx_frame, authority, authority_code) + + set_authority_code(state, authority, code_to_set) + + if authority_code != code_to_set: + # Track code change if different from current + track_code_change(tx_frame, authority, code_to_set) increment_nonce(state, authority) + nonce_after = get_account(state, authority).nonce + track_nonce_change(tx_frame, authority, U64(nonce_after)) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 360a4430e3..8fe1820feb 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -118,6 +118,23 @@ class MessageCallGas: sub_call: Uint +def check_gas(evm: Evm, amount: Uint) -> None: + """ + Checks if `amount` gas is available without charging it. + Raises OutOfGasError if insufficient gas. + + Parameters + ---------- + evm : + The current EVM. + amount : + The amount of gas to check. + + """ + if evm.gas_left < amount: + raise OutOfGasError + + def charge_gas(evm: Evm, amount: Uint) -> None: """ Subtracts `amount` from `evm.gas_left`. diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 8369043465..5ee84a3d49 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,8 +17,10 @@ from ethereum.crypto.hash import keccak256 from ethereum.utils.numeric import ceil32 +# track_address_access removed - now using state_changes.track_address() from ...fork_types import EMPTY_ACCOUNT from ...state import get_account +from ...state_tracker import track_address from ...utils.address import to_address_masked from ...vm.memory import buffer_read, memory_write from .. import Evm @@ -76,15 +78,18 @@ def balance(evm: Evm) -> None: address = to_address_masked(pop(evm.stack)) # GAS - if address in evm.accessed_addresses: - charge_gas(evm, GAS_WARM_ACCESS) - else: + is_cold_access = address not in evm.accessed_addresses + gas_cost = GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + if is_cold_access: evm.accessed_addresses.add(address) - charge_gas(evm, GAS_COLD_ACCOUNT_ACCESS) + + charge_gas(evm, gas_cost) # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. - balance = get_account(evm.message.block_env.state, address).balance + state = evm.message.block_env.state + balance = get_account(state, address).balance + track_address(evm.state_changes, address) push(evm.stack, balance) @@ -341,16 +346,19 @@ def extcodesize(evm: Evm) -> None: address = to_address_masked(pop(evm.stack)) # GAS - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: + is_cold_access = address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS charge_gas(evm, access_gas_cost) # OPERATION - code = get_account(evm.message.block_env.state, address).code + state = evm.message.block_env.state + code = get_account(state, address).code + track_address(evm.state_changes, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -382,17 +390,22 @@ def extcodecopy(evm: Evm) -> None: evm.memory, [(memory_start_index, size)] ) - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: + is_cold_access = address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) + total_gas_cost = access_gas_cost + copy_gas_cost + extend_memory.cost + + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - charge_gas(evm, access_gas_cost + copy_gas_cost + extend_memory.cost) + charge_gas(evm, total_gas_cost) # OPERATION evm.memory += b"\x00" * extend_memory.expand_by - code = get_account(evm.message.block_env.state, address).code + state = evm.message.block_env.state + code = get_account(state, address).code + track_address(evm.state_changes, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -473,16 +486,19 @@ def extcodehash(evm: Evm) -> None: address = to_address_masked(pop(evm.stack)) # GAS - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: + is_cold_access = address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS charge_gas(evm, access_gas_cost) # OPERATION - account = get_account(evm.message.block_env.state, address) + state = evm.message.block_env.state + account = get_account(state, address) + track_address(evm.state_changes, address) if account == EMPTY_ACCOUNT: codehash = U256(0) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index e6777c30a0..18afa2a2ba 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -20,8 +20,13 @@ set_storage, set_transient_storage, ) +from ...state_tracker import ( + capture_pre_storage, + track_storage_read, + track_storage_write, +) from .. import Evm -from ..exceptions import OutOfGasError, WriteInStaticContext +from ..exceptions import WriteInStaticContext from ..gas import ( GAS_CALL_STIPEND, GAS_COLD_SLOAD, @@ -30,6 +35,7 @@ GAS_STORAGE_UPDATE, GAS_WARM_ACCESS, charge_gas, + check_gas, ) from ..stack import pop, push @@ -59,6 +65,11 @@ def sload(evm: Evm) -> None: value = get_storage( evm.message.block_env.state, evm.message.current_target, key ) + track_storage_read( + evm.state_changes, + evm.message.current_target, + key, + ) push(evm.stack, value) @@ -76,11 +87,15 @@ def sstore(evm: Evm) -> None: The current EVM frame. """ + if evm.message.is_static: + raise WriteInStaticContext + # STACK key = pop(evm.stack).to_be_bytes32() new_value = pop(evm.stack) - if evm.gas_left <= GAS_CALL_STIPEND: - raise OutOfGasError + + # check we have at least the stipend gas + check_gas(evm, GAS_CALL_STIPEND + Uint(1)) state = evm.message.block_env.state original_value = get_storage_original( @@ -94,6 +109,18 @@ def sstore(evm: Evm) -> None: evm.accessed_storage_keys.add((evm.message.current_target, key)) gas_cost += GAS_COLD_SLOAD + capture_pre_storage( + evm.message.tx_env.state_changes, + evm.message.current_target, + key, + current_value, + ) + track_storage_read( + evm.state_changes, + evm.message.current_target, + key, + ) + if original_value == current_value and current_value != new_value: if original_value == 0: gas_cost += GAS_STORAGE_SET @@ -124,9 +151,13 @@ def sstore(evm: Evm) -> None: ) charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext set_storage(state, evm.message.current_target, key, new_value) + track_storage_write( + evm.state_changes, + evm.message.current_target, + key, + new_value, + ) # PROGRAM COUNTER evm.pc += Uint(1) @@ -169,14 +200,15 @@ def tstore(evm: Evm) -> None: The current EVM frame. """ + if evm.message.is_static: + raise WriteInStaticContext + # STACK key = pop(evm.stack).to_be_bytes32() new_value = pop(evm.stack) # GAS charge_gas(evm, GAS_WARM_ACCESS) - if evm.message.is_static: - raise WriteInStaticContext set_transient_storage( evm.message.tx_env.transient_storage, evm.message.current_target, diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index fea7a0c1b9..9b54fab312 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -12,7 +12,7 @@ """ from ethereum_types.bytes import Bytes, Bytes0 -from ethereum_types.numeric import U256, Uint +from ethereum_types.numeric import U64, U256, Uint from ethereum.utils.numeric import ceil32 @@ -26,12 +26,21 @@ move_ether, set_account_balance, ) +from ...state_tracker import ( + capture_pre_balance, + create_child_frame, + track_address, + track_balance_change, + track_nonce_change, +) from ...utils.address import ( compute_contract_address, compute_create2_contract_address, to_address_masked, ) -from ...vm.eoa_delegation import access_delegation +from ...vm.eoa_delegation import ( + calculate_delegation_cost, +) from .. import ( Evm, Message, @@ -52,6 +61,7 @@ calculate_gas_extend_memory, calculate_message_call_gas, charge_gas, + check_gas, init_code_cost, max_message_call_gas, ) @@ -77,20 +87,26 @@ def generic_create( process_create_message, ) + # Check static context first + if evm.message.is_static: + raise WriteInStaticContext + + # Check max init code size early before memory read + if memory_size > U256(MAX_INIT_CODE_SIZE): + raise OutOfGasError + + state = evm.message.block_env.state + call_data = memory_read_bytes( evm.memory, memory_start_position, memory_size ) - if len(call_data) > MAX_INIT_CODE_SIZE: - raise OutOfGasError create_message_gas = max_message_call_gas(Uint(evm.gas_left)) evm.gas_left -= create_message_gas - if evm.message.is_static: - raise WriteInStaticContext evm.return_data = b"" sender_address = evm.message.current_target - sender = get_account(evm.message.block_env.state, sender_address) + sender = get_account(state, sender_address) if ( sender.balance < endowment @@ -103,16 +119,31 @@ def generic_create( evm.accessed_addresses.add(contract_address) + track_address(evm.state_changes, contract_address) if account_has_code_or_nonce( - evm.message.block_env.state, contract_address - ) or account_has_storage(evm.message.block_env.state, contract_address): - increment_nonce( - evm.message.block_env.state, evm.message.current_target + state, contract_address + ) or account_has_storage(state, contract_address): + increment_nonce(state, evm.message.current_target) + nonce_after = get_account(state, evm.message.current_target).nonce + track_nonce_change( + evm.state_changes, + evm.message.current_target, + U64(nonce_after), ) push(evm.stack, U256(0)) return - increment_nonce(evm.message.block_env.state, evm.message.current_target) + # Track nonce increment for CREATE + increment_nonce(state, evm.message.current_target) + nonce_after = get_account(state, evm.message.current_target).nonce + track_nonce_change( + evm.state_changes, + evm.message.current_target, + U64(nonce_after), + ) + + # Create call frame as child of parent EVM's frame + child_state_changes = create_child_frame(evm.state_changes) child_message = Message( block_env=evm.message.block_env, @@ -132,6 +163,8 @@ def generic_create( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=False, parent_evm=evm, + is_create=True, + state_changes=child_state_changes, ) child_evm = process_create_message(child_message) @@ -307,6 +340,9 @@ def generic_call( evm.memory, memory_input_start_position, memory_input_size ) + # Create call frame as child of parent EVM's frame + child_state_changes = create_child_frame(evm.state_changes) + child_message = Message( block_env=evm.message.block_env, tx_env=evm.message.tx_env, @@ -325,7 +361,10 @@ def generic_call( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=disable_precompiles, parent_evm=evm, + is_create=False, + state_changes=child_state_changes, ) + child_evm = process_message(child_message) if child_evm.error: @@ -364,6 +403,9 @@ def call(evm: Evm) -> None: memory_output_start_position = pop(evm.stack) memory_output_size = pop(evm.stack) + if evm.message.is_static and value != U256(0): + raise WriteInStaticContext + # GAS extend_memory = calculate_gas_extend_memory( evm.memory, @@ -373,39 +415,57 @@ def call(evm: Evm) -> None: ], ) - if to in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + is_cold_access = to not in evm.accessed_addresses + if is_cold_access: + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS else: + access_gas_cost = GAS_WARM_ACCESS + + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + + # check static gas before state access + check_gas( + evm, + access_gas_cost + transfer_gas_cost + extend_memory.cost, + ) + + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: evm.accessed_addresses.add(to) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - code_address = to + create_gas_cost = GAS_NEW_ACCOUNT + if value == 0 or is_account_alive(state, to): + create_gas_cost = Uint(0) + + extra_gas = access_gas_cost + transfer_gas_cost + create_gas_cost ( - disable_precompiles, + is_delegated, code_address, - code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_access_cost, + ) = calculate_delegation_cost(evm, to) + + if is_delegated: + # check enough gas for delegation access + extra_gas += delegation_access_cost + check_gas(evm, extra_gas + extend_memory.cost) + track_address(evm.state_changes, code_address) + if code_address not in evm.accessed_addresses: + evm.accessed_addresses.add(code_address) + + code = get_account(state, code_address).code - create_gas_cost = GAS_NEW_ACCOUNT - if value == 0 or is_account_alive(evm.message.block_env.state, to): - create_gas_cost = Uint(0) - transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE message_call_gas = calculate_message_call_gas( value, gas, Uint(evm.gas_left), extend_memory.cost, - access_gas_cost + create_gas_cost + transfer_gas_cost, + extra_gas, ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - if evm.message.is_static and value != U256(0): - raise WriteInStaticContext + evm.memory += b"\x00" * extend_memory.expand_by - sender_balance = get_account( - evm.message.block_env.state, evm.message.current_target - ).balance + sender_balance = get_account(state, evm.message.current_target).balance if sender_balance < value: push(evm.stack, U256(0)) evm.return_data = b"" @@ -425,7 +485,7 @@ def call(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER @@ -434,7 +494,7 @@ def call(evm: Evm) -> None: def callcode(evm: Evm) -> None: """ - Message-call into this account with alternative accountโ€™s code. + Message-call into this account with alternative account's code. Parameters ---------- @@ -462,27 +522,48 @@ def callcode(evm: Evm) -> None: ], ) - if code_address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + is_cold_access = code_address not in evm.accessed_addresses + if is_cold_access: + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS else: + access_gas_cost = GAS_WARM_ACCESS + + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + + # check static gas before state access + check_gas( + evm, + access_gas_cost + extend_memory.cost + transfer_gas_cost, + ) + + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: evm.accessed_addresses.add(code_address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + extra_gas = access_gas_cost + transfer_gas_cost ( - disable_precompiles, + is_delegated, code_address, - code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_access_cost, + ) = calculate_delegation_cost(evm, code_address) + + if is_delegated: + # check enough gas for delegation access + extra_gas += delegation_access_cost + check_gas(evm, extra_gas + extend_memory.cost) + track_address(evm.state_changes, code_address) + if code_address not in evm.accessed_addresses: + evm.accessed_addresses.add(code_address) + + code = get_account(state, code_address).code - transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE message_call_gas = calculate_message_call_gas( value, gas, Uint(evm.gas_left), extend_memory.cost, - access_gas_cost + transfer_gas_cost, + extra_gas, ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) @@ -491,6 +572,17 @@ def callcode(evm: Evm) -> None: sender_balance = get_account( evm.message.block_env.state, evm.message.current_target ).balance + + # EIP-7928: For CALLCODE with value transfer, capture pre-balance + # in transaction frame. CALLCODE transfers value from/to current_target + # (same address), affecting current storage context, not child frame + if value != 0 and sender_balance >= value: + capture_pre_balance( + evm.message.tx_env.state_changes, + evm.message.current_target, + sender_balance, + ) + if sender_balance < value: push(evm.stack, U256(0)) evm.return_data = b"" @@ -510,7 +602,7 @@ def callcode(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER @@ -527,46 +619,74 @@ def selfdestruct(evm: Evm) -> None: The current EVM frame. """ + if evm.message.is_static: + raise WriteInStaticContext + # STACK beneficiary = to_address_masked(pop(evm.stack)) # GAS gas_cost = GAS_SELF_DESTRUCT - if beneficiary not in evm.accessed_addresses: - evm.accessed_addresses.add(beneficiary) + + is_cold_access = beneficiary not in evm.accessed_addresses + if is_cold_access: gas_cost += GAS_COLD_ACCOUNT_ACCESS + # check access gas cost before state access + check_gas(evm, gas_cost) + + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: + evm.accessed_addresses.add(beneficiary) + + track_address(evm.state_changes, beneficiary) + if ( - not is_account_alive(evm.message.block_env.state, beneficiary) - and get_account( - evm.message.block_env.state, evm.message.current_target - ).balance - != 0 + not is_account_alive(state, beneficiary) + and get_account(state, evm.message.current_target).balance != 0 ): gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext + state = evm.message.block_env.state originator = evm.message.current_target - originator_balance = get_account( - evm.message.block_env.state, originator - ).balance + originator_balance = get_account(state, originator).balance + beneficiary_balance = get_account(state, beneficiary).balance + + # Get tracking context + tx_frame = evm.message.tx_env.state_changes - move_ether( - evm.message.block_env.state, + # Capture pre-balances for net-zero filtering + track_address(evm.state_changes, originator) + capture_pre_balance(tx_frame, originator, originator_balance) + capture_pre_balance(tx_frame, beneficiary, beneficiary_balance) + + # Transfer balance + move_ether(state, originator, beneficiary, originator_balance) + + # Track balance changes + originator_new_balance = get_account(state, originator).balance + beneficiary_new_balance = get_account(state, beneficiary).balance + track_balance_change( + evm.state_changes, originator, + originator_new_balance, + ) + track_balance_change( + evm.state_changes, beneficiary, - originator_balance, + beneficiary_new_balance, ) # register account for deletion only if it was created # in the same transaction - if originator in evm.message.block_env.state.created_accounts: + if originator in state.created_accounts: # If beneficiary is the same as originator, then # the ether is burnt. - set_account_balance(evm.message.block_env.state, originator, U256(0)) + set_account_balance(state, originator, U256(0)) + track_balance_change(evm.state_changes, originator, U256(0)) evm.accounts_to_delete.add(originator) # HALT the execution @@ -603,22 +723,43 @@ def delegatecall(evm: Evm) -> None: ], ) - if code_address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + is_cold_access = code_address not in evm.accessed_addresses + if is_cold_access: + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS else: + access_gas_cost = GAS_WARM_ACCESS + + # check static gas before state access + check_gas(evm, access_gas_cost + extend_memory.cost) + + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: evm.accessed_addresses.add(code_address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + extra_gas = access_gas_cost ( - disable_precompiles, + is_delegated, code_address, - code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_access_cost, + ) = calculate_delegation_cost(evm, code_address) + + if is_delegated: + # check enough gas for delegation access + extra_gas += delegation_access_cost + check_gas(evm, extra_gas + extend_memory.cost) + track_address(evm.state_changes, code_address) + if code_address not in evm.accessed_addresses: + evm.accessed_addresses.add(code_address) + + code = get_account(state, code_address).code message_call_gas = calculate_message_call_gas( - U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + extra_gas, ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) @@ -638,7 +779,7 @@ def delegatecall(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER @@ -672,27 +813,43 @@ def staticcall(evm: Evm) -> None: ], ) - if to in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + is_cold_access = to not in evm.accessed_addresses + if is_cold_access: + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS else: + access_gas_cost = GAS_WARM_ACCESS + + # check static gas before state access + check_gas(evm, access_gas_cost + extend_memory.cost) + + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: evm.accessed_addresses.add(to) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - code_address = to + extra_gas = access_gas_cost ( - disable_precompiles, + is_delegated, code_address, - code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_access_cost, + ) = calculate_delegation_cost(evm, to) + + if is_delegated: + # check enough gas for delegation access + extra_gas += delegation_access_cost + check_gas(evm, extra_gas + extend_memory.cost) + track_address(evm.state_changes, code_address) + if code_address not in evm.accessed_addresses: + evm.accessed_addresses.add(code_address) + + code = get_account(state, code_address).code message_call_gas = calculate_message_call_gas( U256(0), gas, Uint(evm.gas_left), extend_memory.cost, - access_gas_cost, + extra_gas, ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) @@ -712,7 +869,7 @@ def staticcall(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 07e9f1d2db..d73ba88a72 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -15,7 +15,7 @@ from typing import Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes0 -from ethereum_types.numeric import U256, Uint, ulen +from ethereum_types.numeric import U64, U256, Uint, ulen from ethereum.exceptions import EthereumException from ethereum.trace import ( @@ -44,6 +44,17 @@ rollback_transaction, set_code, ) +from ..state_tracker import ( + StateChanges, + capture_pre_balance, + capture_pre_code, + merge_on_failure, + merge_on_success, + track_address, + track_balance_change, + track_code_change, + track_nonce_change, +) from ..vm import Message from ..vm.eoa_delegation import get_delegated_code_address, set_delegation from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas @@ -111,6 +122,7 @@ def process_message_call(message: Message) -> MessageCallOutput: is_collision = account_has_code_or_nonce( block_env.state, message.current_target ) or account_has_storage(block_env.state, message.current_target) + track_address(message.tx_env.state_changes, message.current_target) if is_collision: return MessageCallOutput( Uint(0), @@ -132,6 +144,7 @@ def process_message_call(message: Message) -> MessageCallOutput: message.accessed_addresses.add(delegated_address) message.code = get_account(block_env.state, delegated_address).code message.code_address = delegated_address + track_address(message.block_env.state_changes, delegated_address) evm = process_message(message) @@ -194,6 +207,15 @@ def process_create_message(message: Message) -> Evm: mark_account_created(state, message.current_target) increment_nonce(state, message.current_target) + nonce_after = get_account(state, message.current_target).nonce + track_nonce_change( + message.state_changes, + message.current_target, + U64(nonce_after), + ) + + capture_pre_code(message.tx_env.state_changes, message.current_target, b"") + evm = process_message(message) if not evm.error: contract_code = evm.output @@ -207,14 +229,24 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) + merge_on_failure(message.state_changes) evm.gas_left = Uint(0) evm.output = b"" evm.error = error else: + # Note: No need to capture pre code since it's always b"" here set_code(state, message.current_target, contract_code) + if contract_code != b"": + track_code_change( + message.state_changes, + message.current_target, + contract_code, + ) commit_transaction(state, transient_storage) + merge_on_success(message.state_changes) else: rollback_transaction(state, transient_storage) + merge_on_failure(message.state_changes) return evm @@ -241,22 +273,56 @@ def process_message(message: Message) -> Evm: # take snapshot of state before processing the message begin_transaction(state, transient_storage) + track_address(message.state_changes, message.current_target) + if message.should_transfer_value and message.value != 0: + # Track value transfer + sender_balance = get_account(state, message.caller).balance + recipient_balance = get_account(state, message.current_target).balance + + track_address(message.state_changes, message.caller) + capture_pre_balance( + message.tx_env.state_changes, message.caller, sender_balance + ) + capture_pre_balance( + message.tx_env.state_changes, + message.current_target, + recipient_balance, + ) + move_ether( state, message.caller, message.current_target, message.value ) - evm = execute_code(message) + sender_new_balance = get_account(state, message.caller).balance + recipient_new_balance = get_account( + state, message.current_target + ).balance + + track_balance_change( + message.state_changes, + message.caller, + U256(sender_new_balance), + ) + track_balance_change( + message.state_changes, + message.current_target, + U256(recipient_new_balance), + ) + + evm = execute_code(message, message.state_changes) if evm.error: - # revert state to the last saved checkpoint - # since the message call resulted in an error rollback_transaction(state, transient_storage) + if not message.is_create: + merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) + if not message.is_create: + merge_on_success(evm.state_changes) return evm -def execute_code(message: Message) -> Evm: +def execute_code(message: Message, state_changes: StateChanges) -> Evm: """ Executes bytecode present in the `message`. @@ -264,6 +330,8 @@ def execute_code(message: Message) -> Evm: ---------- message : Transaction specific items. + state_changes : + The state changes frame to use for tracking. Returns ------- @@ -291,6 +359,7 @@ def execute_code(message: Message) -> Evm: error=None, accessed_addresses=message.accessed_addresses, accessed_storage_keys=message.accessed_storage_keys, + state_changes=state_changes, ) try: if evm.message.code_address in PRE_COMPILED_CONTRACTS: diff --git a/src/ethereum/forks/osaka/vm/eoa_delegation.py b/src/ethereum/forks/osaka/vm/eoa_delegation.py index 29909b5fa5..0913fa63ff 100644 --- a/src/ethereum/forks/osaka/vm/eoa_delegation.py +++ b/src/ethereum/forks/osaka/vm/eoa_delegation.py @@ -134,6 +134,7 @@ def access_delegation( """ state = evm.message.block_env.state + code = get_account(state, address).code if not is_valid_delegation(code): return False, address, code, Uint(0) diff --git a/src/ethereum/genesis.py b/src/ethereum/genesis.py index 84519271dc..444302ffeb 100644 --- a/src/ethereum/genesis.py +++ b/src/ethereum/genesis.py @@ -259,6 +259,9 @@ def add_genesis_block( if has_field(hardfork.Header, "requests_hash"): fields["requests_hash"] = Hash32(b"\0" * 32) + if has_field(hardfork.Header, "block_access_list_hash"): + fields["block_access_list_hash"] = keccak256(rlp.encode([])) + genesis_header = hardfork.Header(**fields) block_fields = { diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py index d01eda47c9..ab9e1b99d9 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py @@ -196,4 +196,8 @@ def json_to_header(self, raw: Any) -> Any: requests_hash = hex_to_bytes32(raw.get("requestsHash")) parameters.append(requests_hash) + if "blockAccessListHash" in raw: + bal_hash = hex_to_bytes32(raw.get("blockAccessListHash")) + parameters.append(bal_hash) + return self.fork.Header(*parameters) diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index f9be3a8820..9a14efa54c 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -120,6 +120,27 @@ def has_signing_hash_155(self) -> bool: """Check if the fork has a `signing_hash_155` function.""" return hasattr(self._module("transactions"), "signing_hash_155") + @property + def build_block_access_list(self) -> Any: + """Build function of the fork.""" + return self._module("block_access_lists").build_block_access_list + + @property + def compute_block_access_list_hash(self) -> Any: + """compute_block_access_list_hash function of the fork.""" + return self._module( + "block_access_lists" + ).compute_block_access_list_hash + + @property + def has_block_access_list_hash(self) -> bool: + """Check if the fork has a `block_access_list_hash` function.""" + try: + module = self._module("block_access_lists") + except ModuleNotFoundError: + return False + return hasattr(module, "compute_block_access_list_hash") + @property def signing_hash_2930(self) -> Any: """signing_hash_2930 function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index fc09dfd8a3..4988ef25bc 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -16,6 +16,7 @@ from ethereum import trace from ethereum.exceptions import EthereumException, InvalidBlock from ethereum.fork_criteria import ByBlockNumber, ByTimestamp, Unscheduled +from ethereum.forks.amsterdam.state_tracker import StateChanges from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork from ..loaders.fixture_loader import Load @@ -308,6 +309,9 @@ def block_environment(self) -> Any: ) kw_arguments["excess_blob_gas"] = self.env.excess_blob_gas + if self.fork.has_block_access_list_hash: + kw_arguments["state_changes"] = StateChanges() + return block_environment(**kw_arguments) def backup_state(self) -> None: @@ -386,20 +390,34 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: data=block_env.parent_beacon_block_root, ) - for i, tx in zip( - self.txs.successfully_parsed, - self.txs.transactions, - strict=True, + for tx_index, (original_idx, tx) in enumerate( + zip( + self.txs.successfully_parsed, + self.txs.transactions, + strict=True, + ) ): self.backup_state() try: self.fork.process_transaction( - block_env, block_output, tx, Uint(i) + block_env, block_output, tx, Uint(tx_index) ) except EthereumException as e: - self.txs.rejected_txs[i] = f"Failed transaction: {e!r}" + self.txs.rejected_txs[original_idx] = ( + f"Failed transaction: {e!r}" + ) self.restore_state() - self.logger.warning(f"Transaction {i} failed: {e!r}") + self.logger.warning( + f"Transaction {original_idx} failed: {e!r}" + ) + + # Post-execution operations use index N+1 + if self.fork.has_block_access_list_hash: + from ethereum.forks.amsterdam.state_tracker import ( + increment_block_access_index, + ) + + increment_block_access_index(block_env.state_changes) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -417,6 +435,12 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.has_compute_requests_hash: self.fork.process_general_purpose_requests(block_env, block_output) + if self.fork.has_block_access_list_hash: + # Build block access list from block_env.state_changes + block_output.block_access_list = self.fork.build_block_access_list( + block_env.state_changes + ) + def run_blockchain_test(self) -> None: """ Apply a block on the pre-state. Also includes system operations. diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 07a3071c1f..be719ba7af 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -145,6 +145,9 @@ def read_excess_blob_gas(self, data: Any, t8n: "T8N") -> None: if t8n.fork.has_compute_requests_hash: arguments["requests_hash"] = Hash32(b"\0" * 32) + if t8n.fork.has_block_access_list_hash: + arguments["block_access_list_hash"] = Hash32(b"\0" * 32) + parent_header = t8n.fork.Header(**arguments) self.excess_blob_gas = t8n.fork.calculate_excess_blob_gas( diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index c60c266965..0e84189598 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -268,6 +268,8 @@ class Result: requests_hash: Optional[Hash32] = None requests: Optional[List[Bytes]] = None block_exception: Optional[str] = None + block_access_list: Optional[Any] = None + block_access_list_hash: Optional[Hash32] = None def get_receipts_from_output( self, @@ -323,6 +325,80 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: self.requests = block_output.requests self.requests_hash = t8n.fork.compute_requests_hash(self.requests) + if hasattr(block_output, "block_access_list"): + self.block_access_list = block_output.block_access_list + self.block_access_list_hash = ( + t8n.fork.compute_block_access_list_hash( + block_output.block_access_list + ) + ) + + @staticmethod + def _block_access_list_to_json(account_changes: Any) -> Any: + """ + Convert BlockAccessList to JSON format matching the Pydantic models. + """ + json_account_changes = [] + for account in account_changes: + account_data: Dict[str, Any] = { + "address": "0x" + account.address.hex() + } + + if account.storage_changes: + storage_changes = [] + for slot_change in account.storage_changes: + slot_data: Dict[str, Any] = { + "slot": int(slot_change.slot), + "slotChanges": [], + } + for change in slot_change.changes: + slot_data["slotChanges"].append( + { + "blockAccessIndex": int( + change.block_access_index + ), + "postValue": int(change.new_value), + } + ) + storage_changes.append(slot_data) + account_data["storageChanges"] = storage_changes + + if account.storage_reads: + account_data["storageReads"] = [ + int(slot) for slot in account.storage_reads + ] + + if account.balance_changes: + account_data["balanceChanges"] = [ + { + "blockAccessIndex": int(change.block_access_index), + "postBalance": int(change.post_balance), + } + for change in account.balance_changes + ] + + if account.nonce_changes: + account_data["nonceChanges"] = [ + { + "blockAccessIndex": int(change.block_access_index), + "postNonce": int(change.new_nonce), + } + for change in account.nonce_changes + ] + + if account.code_changes: + account_data["codeChanges"] = [ + { + "blockAccessIndex": int(change.block_access_index), + "newCode": "0x" + change.new_code.hex(), + } + for change in account.code_changes + ] + + json_account_changes.append(account_data) + + return json_account_changes + def json_encode_receipts(self) -> Any: """ Encode receipts to JSON. @@ -390,4 +466,15 @@ def to_json(self) -> Any: if self.block_exception is not None: data["blockException"] = self.block_exception + if self.block_access_list is not None: + # Convert BAL to JSON format + data["blockAccessList"] = self._block_access_list_to_json( + self.block_access_list + ) + + if self.block_access_list_hash is not None: + data["blockAccessListHash"] = encode_to_hex( + self.block_access_list_hash + ) + return data diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index d6c7948f13..9f7400b433 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -1,6 +1,6 @@ """Tests for EIP-7928 using the consistent data class pattern.""" -from typing import Callable, Dict +from typing import Callable import pytest from execution_testing import ( @@ -8,6 +8,7 @@ Account, Address, Alloc, + AuthorizationTuple, BalAccountExpectation, BalBalanceChange, BalCodeChange, @@ -21,9 +22,9 @@ Fork, Hash, Header, - Initcode, Op, Transaction, + add_kzg_version, compute_create_address, ) @@ -54,7 +55,9 @@ def test_bal_nonce_changes( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), } ), @@ -109,16 +112,21 @@ def test_bal_balance_changes( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=alice_final_balance + block_access_index=1, + post_balance=alice_final_balance, ) ], ), bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) + BalBalanceChange( + block_access_index=1, post_balance=100 + ) ], ), } @@ -189,14 +197,20 @@ def test_bal_code_changes( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), factory_contract: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], ), created_contract: BalAccountExpectation( code_changes=[ - BalCodeChange(tx_index=1, new_code=runtime_code_bytes) + BalCodeChange( + block_access_index=1, new_code=runtime_code_bytes + ) ], ), } @@ -217,162 +231,6 @@ def test_bal_code_changes( ) -@pytest.mark.parametrize( - "self_destruct_in_same_tx", [True, False], ids=["same_tx", "new_tx"] -) -@pytest.mark.parametrize( - "pre_funded", [True, False], ids=["pre_funded", "not_pre_funded"] -) -def test_bal_self_destruct( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - self_destruct_in_same_tx: bool, - pre_funded: bool, -) -> None: - """Ensure BAL captures balance changes caused by `SELFDESTRUCT`.""" - alice = pre.fund_eoa() - bob = pre.fund_eoa(amount=0) - - selfdestruct_code = ( - Op.SLOAD(0x01) # Read from storage slot 0x01 - + Op.SSTORE(0x02, 0x42) # Write to storage slot 0x02 - + Op.SELFDESTRUCT(bob) - ) - # A pre existing self-destruct contract with initial storage - kaboom = pre.deploy_contract(code=selfdestruct_code, storage={0x01: 0x123}) - - # A template for self-destruct contract - self_destruct_init_code = Initcode(deploy_code=selfdestruct_code) - template = pre.deploy_contract(code=self_destruct_init_code) - - transfer_amount = expected_recipient_balance = 100 - pre_fund_amount = 10 - - if self_destruct_in_same_tx: - # The goal is to create a self-destructing contract in the same - # transaction to trigger deletion of code as per EIP-6780. - # The factory contract below creates a new self-destructing - # contract and calls it in this transaction. - - bytecode_size = len(self_destruct_init_code) - factory_bytecode = ( - # Clone template memory - Op.EXTCODECOPY(template, 0, 0, bytecode_size) - # Fund 100 wei and deploy the clone - + Op.CREATE(transfer_amount, 0, bytecode_size) - # Call the clone, which self-destructs - + Op.CALL(100_000, Op.DUP6, 0, 0, 0, 0, 0) - + Op.STOP - ) - - factory = pre.deploy_contract(code=factory_bytecode) - kaboom_same_tx = compute_create_address(address=factory, nonce=1) - - # Determine which account will be self-destructed - self_destructed_account = ( - kaboom_same_tx if self_destruct_in_same_tx else kaboom - ) - - if pre_funded: - expected_recipient_balance += pre_fund_amount - pre.fund_address( - address=self_destructed_account, amount=pre_fund_amount - ) - - tx = Transaction( - sender=alice, - to=factory if self_destruct_in_same_tx else kaboom, - value=transfer_amount, - gas_limit=1_000_000, - gas_price=0xA, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - ), - bob: BalAccountExpectation( - balance_changes=[ - BalBalanceChange( - tx_index=1, post_balance=expected_recipient_balance - ) - ] - ), - self_destructed_account: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=0) - ] - if pre_funded - else [], - # Accessed slots for same-tx are recorded as reads (0x02) - storage_reads=[0x01, 0x02] - if self_destruct_in_same_tx - else [0x01], - # Storage changes are recorded for non-same-tx - # self-destructs - storage_changes=[ - BalStorageSlot( - slot=0x02, - slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) - ], - ) - ] - if not self_destruct_in_same_tx - else [], - code_changes=[], # should not be present - nonce_changes=[], # should not be present - ), - } - ), - ) - - post: Dict[Address, Account] = { - alice: Account(nonce=1), - bob: Account(balance=expected_recipient_balance), - } - - # If the account was self-destructed in the same transaction, - # we expect the account to be non-existent and its balance to be 0. - if self_destruct_in_same_tx: - post.update( - { - factory: Account( - nonce=2, # incremented after CREATE - balance=0, # spent on CREATE - code=factory_bytecode, - ), - kaboom_same_tx: Account.NONEXISTENT, # type: ignore - # The pre-existing contract remains unaffected - kaboom: Account( - balance=0, code=selfdestruct_code, storage={0x01: 0x123} - ), - } - ) - else: - post.update( - { - # This contract was self-destructed in a separate tx. - # From EIP 6780: `SELFDESTRUCT` does not delete any data - # (including storage keys, code, or the account itself). - kaboom: Account( - balance=0, - code=selfdestruct_code, - storage={0x01: 0x123, 0x2: 0x42}, - ), - } - ) - - blockchain_test( - pre=pre, - blocks=[block], - post=post, - ) - - @pytest.mark.parametrize( "account_access_opcode", [ @@ -430,7 +288,9 @@ def test_bal_account_access_target( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] ), target_contract: BalAccountExpectation.empty(), oracle_contract: BalAccountExpectation.empty(), @@ -441,56 +301,13 @@ def test_bal_account_access_target( blockchain_test(pre=pre, blocks=[block], post={}) -def test_bal_call_with_value_transfer( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Ensure BAL captures balance changes from CALL opcode with - value transfer. - """ - alice = pre.fund_eoa() - bob = pre.fund_eoa(amount=0) - - # Oracle contract that uses CALL to transfer 100 wei to Bob - oracle_code = Op.CALL(0, bob, 100, 0, 0, 0, 0) - oracle_contract = pre.deploy_contract(code=oracle_code, balance=200) - - tx = Transaction( - sender=alice, to=oracle_contract, gas_limit=1_000_000, gas_price=0xA - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - ), - oracle_contract: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) - ], - ), - bob: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) - ], - ), - } - ), - ) - - blockchain_test(pre=pre, blocks=[block], post={}) - - -def test_bal_callcode_with_value_transfer( +def test_bal_callcode_nested_value_transfer( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: """ - Ensure BAL captures balance changes from CALLCODE opcode with - value transfer. + Ensure BAL captures balance changes from nested value transfers + when CALLCODE executes target code that itself makes CALL with value. """ alice = pre.fund_eoa() bob = pre.fund_eoa(amount=0) @@ -512,16 +329,22 @@ def test_bal_callcode_with_value_transfer( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), oracle_contract: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) + BalBalanceChange( + block_access_index=1, post_balance=100 + ) ], ), bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) + BalBalanceChange( + block_access_index=1, post_balance=100 + ) ], ), target_contract: BalAccountExpectation.empty(), @@ -578,14 +401,18 @@ def test_bal_delegated_storage_writes( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), oracle_contract: BalAccountExpectation( storage_changes=[ BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -646,7 +473,9 @@ def test_bal_delegated_storage_reads( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), oracle_contract: BalAccountExpectation( storage_reads=[0x01], @@ -709,22 +538,27 @@ def test_bal_block_rewards( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=alice_final_balance + block_access_index=1, + post_balance=alice_final_balance, ) ], ), bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100) + BalBalanceChange( + block_access_index=1, post_balance=100 + ) ], ), charlie: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=tip_to_charlie + block_access_index=1, post_balance=tip_to_charlie ) ], ), @@ -769,7 +603,9 @@ def test_bal_2930_account_listed_but_untouched( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # The address excluded from BAL since state is not accessed oracle: None, @@ -826,7 +662,9 @@ def test_bal_2930_slot_listed_but_untouched( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # The account was loaded. pure_calculator: BalAccountExpectation.empty(), @@ -886,20 +724,26 @@ def test_bal_2930_slot_listed_and_unlisted_writes( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), storage_writer: BalAccountExpectation( storage_changes=[ BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ), BalStorageSlot( slot=0x02, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x43) + BalStorageChange( + block_access_index=1, post_value=0x43 + ) ], ), ], @@ -960,7 +804,9 @@ def test_bal_2930_slot_listed_and_unlisted_reads( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), storage_reader: BalAccountExpectation( storage_reads=[0x01, 0x02], @@ -1004,10 +850,12 @@ def test_bal_self_transfer( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], balance_changes=[ BalBalanceChange( - tx_index=1, + block_access_index=1, post_balance=start_balance - intrinsic_gas_cost * int(tx.gas_price or 0), ) @@ -1046,10 +894,12 @@ def test_bal_zero_value_transfer( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], balance_changes=[ BalBalanceChange( - tx_index=1, + block_access_index=1, post_balance=start_balance - intrinsic_gas_cost * int(tx.gas_price or 0), ) @@ -1126,7 +976,9 @@ def test_bal_net_zero_balance_transfer( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), net_zero_bal_contract: BalAccountExpectation( # receives transfer_amount and sends transfer_amount away @@ -1140,7 +992,7 @@ def test_bal_net_zero_balance_transfer( slot=0x00, slot_changes=[ BalStorageChange( - tx_index=1, + block_access_index=1, post_value=expected_balance_in_slot, ) ], @@ -1153,7 +1005,7 @@ def test_bal_net_zero_balance_transfer( recipient: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=transfer_amount + block_access_index=1, post_balance=transfer_amount ) ] if transfer_amount > 0 @@ -1201,7 +1053,9 @@ def test_bal_pure_contract_call( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # Ensure called contract is tracked pure_contract: BalAccountExpectation.empty(), @@ -1242,7 +1096,9 @@ def test_bal_noop_storage_write( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), storage_contract: BalAccountExpectation( storage_reads=[0x01], @@ -1281,7 +1137,9 @@ def test_bal_aborted_storage_access( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] ), storage_contract: BalAccountExpectation( storage_changes=[], @@ -1363,7 +1221,9 @@ def test_bal_aborted_account_access( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] ), target_contract: BalAccountExpectation.empty(), abort_contract: BalAccountExpectation.empty(), @@ -1405,7 +1265,9 @@ def test_bal_fully_unmutated_account( expected_block_access_list=BlockAccessListExpectation( account_expectations={ alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), oracle: BalAccountExpectation( storage_changes=[], # No net storage changes @@ -1417,3 +1279,1382 @@ def test_bal_fully_unmutated_account( ) blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_empty_block_no_coinbase( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL correctly handles empty blocks without including coinbase. + + When a block has no transactions and no withdrawals, the coinbase/fee + recipient receives no fees and should not be included in the BAL. + """ + coinbase = pre.fund_eoa(amount=0) + + block = Block( + txs=[], + withdrawals=None, + fee_recipient=coinbase, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + # Coinbase must NOT be included - receives no fees + coinbase: None, + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_coinbase_zero_tip( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """Ensure BAL includes coinbase even when priority fee is zero.""" + alice_initial_balance = 1_000_000 + alice = pre.fund_eoa(amount=alice_initial_balance) + bob = pre.fund_eoa(amount=0) + coinbase = pre.fund_eoa(amount=0) # fee recipient + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_gas_calculator( + calldata=b"", + contract_creation=False, + access_list=[], + ) + tx_gas_limit = intrinsic_gas + 1000 + + # Calculate base fee + genesis_env = Environment(base_fee_per_gas=0x7) + base_fee_per_gas = fork.base_fee_per_gas_calculator()( + parent_base_fee_per_gas=int(genesis_env.base_fee_per_gas or 0), + parent_gas_used=0, + parent_gas_limit=genesis_env.gas_limit, + ) + + # Set gas_price equal to base_fee so tip = 0 + tx = Transaction( + sender=alice, + to=bob, + value=5, + gas_limit=tx_gas_limit, + gas_price=base_fee_per_gas, + ) + + alice_final_balance = ( + alice_initial_balance - 5 - (intrinsic_gas * base_fee_per_gas) + ) + + block = Block( + txs=[tx], + fee_recipient=coinbase, + header_verify=Header(base_fee_per_gas=base_fee_per_gas), + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=alice_final_balance, + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=5) + ] + ), + # Coinbase must be included even with zero tip + coinbase: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1, balance=alice_final_balance), + bob: Account(balance=5), + }, + genesis_environment=genesis_env, + ) + + +@pytest.mark.pre_alloc_group( + "precompile_funded", + reason="Expects clean precompile balances, isolate in EngineX", +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(10**18, id="with_value"), + pytest.param(0, id="no_value"), + ], +) +@pytest.mark.with_all_precompiles +def test_bal_precompile_funded( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, + value: int, +) -> None: + """ + Ensure BAL records precompile value transfer. + + Alice sends value to precompile (pure value transfer). + If value > 0: BAL must include balance_changes. + If value = 0: BAL must have empty balance_changes. + """ + alice = pre.fund_eoa() + + addr_int = int.from_bytes(precompile, "big") + + # Map precompile addresses to their required minimal input sizes + # - Most precompiles accept zero-padded input of appropriate length + # - For 0x0a (POINT_EVALUATION), use a known valid input from mainnet + if addr_int == 0x0A: + # Valid point evaluation input from mainnet tx: + # https://etherscan.io/tx/0xcb3dc8f3b14f1cda0c16a619a112102a8ec70dce1b3f1b28272227cf8d5fbb0e + tx_data = ( + bytes.fromhex( + # versioned_hash (32) + "018156B94FE9735E573BAB36DAD05D60FEB720D424CCD20AAF719343C31E4246" + ) + + bytes.fromhex( + # z (32) + "019123BCB9D06356701F7BE08B4494625B87A7B02EDC566126FB81F6306E915F" + ) + + bytes.fromhex( + # y (32) + "6C2EB1E94C2532935B8465351BA1BD88EABE2B3FA1AADFF7D1CD816E8315BD38" + ) + + bytes.fromhex( + # kzg_commitment (48) + "A9546D41993E10DF2A7429B8490394EA9EE62807BAE6F326D1044A51581306F58D4B9DFD5931E044688855280FF3799E" + ) + + bytes.fromhex( + # kzg_proof (48) + "A2EA83D9391E0EE42E0C650ACC7A1F842A7D385189485DDB4FD54ADE3D9FD50D608167DCA6C776AAD4B8AD5C20691BFE" + ) + ) + else: + precompile_min_input = { + 0x01: 128, # ECRECOVER + 0x02: 0, # SHA256 (accepts empty) + 0x03: 0, # RIPEMD160 (accepts empty) + 0x04: 0, # IDENTITY (accepts empty) + 0x05: 96, # MODEXP + 0x06: 128, # BN256ADD + 0x07: 96, # BN256MUL + 0x08: 0, # BN256PAIRING (empty is valid) + 0x09: 213, # BLAKE2F + 0x0B: 256, # BLS12_G1_ADD + 0x0C: 160, # BLS12_G1_MSM + 0x0D: 512, # BLS12_G2_ADD + 0x0E: 288, # BLS12_G2_MSM + 0x0F: 384, # BLS12_PAIRING + 0x10: 64, # BLS12_MAP_FP_TO_G1 + 0x11: 128, # BLS12_MAP_FP2_TO_G2 + 0x100: 160, # P256VERIFY + } + + input_size = precompile_min_input.get(addr_int, 0) + tx_data = bytes([0x00] * input_size if input_size > 0 else []) + + tx = Transaction( + sender=alice, + to=precompile, + value=value, + gas_limit=5_000_000, + data=tx_data, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=value + ) + ] + if value > 0 + else [], + storage_reads=[], + storage_changes=[], + code_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize_by_fork( + "precompile", + lambda fork: [ + pytest.param(addr, id=f"0x{int.from_bytes(addr, 'big'):02x}") + for addr in fork.precompiles(block_number=0, timestamp=0) + ], +) +def test_bal_precompile_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, +) -> None: + """ + Ensure BAL records precompile when called via contract. + + Alice calls Oracle contract which calls precompile. + BAL must include precompile with no balance/storage/code changes. + """ + alice = pre.fund_eoa() + + # Oracle contract that calls the precompile + oracle = pre.deploy_contract( + code=Op.CALL(100_000, precompile, 0, 0, 0, 0, 0) + Op.STOP + ) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=200_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation.empty(), + precompile: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(10**18, id="positive_value"), + ], +) +def test_bal_nonexistent_value_transfer( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + value: int, +) -> None: + """ + Ensure BAL captures non-existent account on value transfer. + + Alice sends value directly to non-existent Bob. + """ + alice = pre.fund_eoa() + bob = Address(0xB0B) + + tx = Transaction( + sender=alice, + to=bob, + value=value, + gas_limit=100_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=value + ) + ] + if value > 0 + else [], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=value) if value > 0 else Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize( + "account_access_opcode", + [ + pytest.param( + lambda target_addr: Op.BALANCE(target_addr), + id="balance", + ), + pytest.param( + lambda target_addr: Op.EXTCODESIZE(target_addr), + id="extcodesize", + ), + pytest.param( + lambda target_addr: Op.EXTCODECOPY(target_addr, 0, 0, 32), + id="extcodecopy", + ), + pytest.param( + lambda target_addr: Op.EXTCODEHASH(target_addr), + id="extcodehash", + ), + pytest.param( + lambda target_addr: Op.STATICCALL(0, target_addr, 0, 0, 0, 0), + id="staticcall", + ), + pytest.param( + lambda target_addr: Op.DELEGATECALL(0, target_addr, 0, 0, 0, 0), + id="delegatecall", + ), + ], +) +def test_bal_nonexistent_account_access_read_only( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + account_access_opcode: Callable[[Address], Op], +) -> None: + """ + Ensure BAL captures non-existent account access via read-only opcodes. + + Alice calls Oracle contract which uses read-only opcodes to access + non-existent Bob (BALANCE, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH, + STATICCALL, DELEGATECALL). + """ + alice = pre.fund_eoa() + bob = Address(0xB0B) + oracle_balance = 2 * 10**18 + + oracle_code = account_access_opcode(bob) + oracle = pre.deploy_contract(code=oracle_code, balance=oracle_balance) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation.empty(), + bob: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(balance=oracle_balance), + bob: Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize( + "opcode_type,value", + [ + pytest.param("call", 0, id="call_zero_value"), + pytest.param("call", 10**18, id="call_positive_value"), + pytest.param("callcode", 0, id="callcode_zero_value"), + pytest.param("callcode", 10**18, id="callcode_positive_value"), + ], +) +def test_bal_nonexistent_account_access_value_transfer( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + opcode_type: str, + value: int, +) -> None: + """ + Ensure BAL captures non-existent account access via CALL/CALLCODE + with value. + + Alice calls Oracle contract which uses CALL or CALLCODE to access + non-existent Bob with value transfer. + - CALL: Transfers value from Oracle to Bob + - CALLCODE: Self-transfer (net zero), Bob accessed for code + """ + alice = pre.fund_eoa() + bob = Address(0xB0B) + oracle_balance = 2 * 10**18 + + if opcode_type == "call": + oracle_code = Op.CALL(100_000, bob, value, 0, 0, 0, 0) + else: # callcode + oracle_code = Op.CALLCODE(100_000, bob, value, 0, 0, 0, 0) + + oracle = pre.deploy_contract(code=oracle_code, balance=oracle_balance) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + # Calculate expected balances + if opcode_type == "call" and value > 0: + # CALL: Oracle loses value, Bob gains value + oracle_final_balance = oracle_balance - value + bob_final_balance = value + bob_has_balance_change = True + oracle_has_balance_change = True + elif opcode_type == "callcode" and value > 0: + # CALLCODE: Self-transfer (net zero), Bob just accessed for code + oracle_final_balance = oracle_balance + bob_final_balance = 0 + bob_has_balance_change = False + oracle_has_balance_change = False + else: + # Zero value + oracle_final_balance = oracle_balance + bob_final_balance = 0 + bob_has_balance_change = False + oracle_has_balance_change = False + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=oracle_final_balance, + ) + ] + if oracle_has_balance_change + else [], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=bob_final_balance, + ) + ] + if bob_has_balance_change + else [], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(balance=oracle_final_balance), + bob: Account(balance=bob_final_balance) + if bob_has_balance_change + else Account.NONEXISTENT, + }, + ) + + +def test_bal_multiple_balance_changes_same_account( + pre: Alloc, + fork: Fork, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL correctly tracks multiple balance changes to same account + across multiple transactions. + + An account that receives funds in TX0 and spends them in TX1 should + have TWO balance change entries in the BAL, one for each transaction. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + charlie = pre.fund_eoa(amount=0) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + tx_intrinsic_gas = intrinsic_gas_calculator(calldata=b"", access_list=[]) + + # bob receives funds in tx0, then spends everything in tx1 + gas_price = 10 + tx1_gas_cost = tx_intrinsic_gas * gas_price + spend_amount = 100 + funding_amount = tx1_gas_cost + spend_amount + + tx0 = Transaction( + sender=alice, + to=bob, + value=funding_amount, + gas_limit=tx_intrinsic_gas, + gas_price=gas_price, + ) + + tx1 = Transaction( + sender=bob, + to=charlie, + value=spend_amount, + gas_limit=tx_intrinsic_gas, + gas_price=gas_price, + ) + + bob_balance_after_tx0 = funding_amount + bob_balance_after_tx1 = 0 + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx0, tx1], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + bob: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=2, post_nonce=1 + ) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=bob_balance_after_tx0, + ), + BalBalanceChange( + block_access_index=2, + post_balance=bob_balance_after_tx1, + ), + ], + ), + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=2, + post_balance=spend_amount, + ) + ], + ), + } + ), + ) + ], + post={ + bob: Account(nonce=1, balance=bob_balance_after_tx1), + charlie: Account(balance=spend_amount), + }, + ) + + +def test_bal_multiple_storage_writes_same_slot( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that BAL tracks multiple writes to the same storage slot across + transactions in the same block. + + Setup: + - Deploy a contract that increments storage slot 1 on each call + - Alice calls the contract 3 times in the same block + - Each call increments slot 1: 0 -> 1 -> 2 -> 3 + + Expected BAL: + - Contract should have 3 storage_changes for slot 1: + * txIndex 1: postValue = 1 + * txIndex 2: postValue = 2 + * txIndex 3: postValue = 3 + """ + alice = pre.fund_eoa(amount=10**18) + + increment_code = Op.SSTORE(1, Op.ADD(Op.SLOAD(1), 1)) + contract = pre.deploy_contract(code=increment_code) + + tx1 = Transaction(sender=alice, to=contract, gas_limit=200_000) + tx2 = Transaction(sender=alice, to=contract, gas_limit=200_000) + tx3 = Transaction(sender=alice, to=contract, gas_limit=200_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1, tx2, tx3], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ), + BalNonceChange( + block_access_index=2, post_nonce=2 + ), + BalNonceChange( + block_access_index=3, post_nonce=3 + ), + ], + ), + contract: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=1, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ), + BalStorageChange( + block_access_index=2, post_value=2 + ), + BalStorageChange( + block_access_index=3, post_value=3 + ), + ], + ), + ], + storage_reads=[], + balance_changes=[], + code_changes=[], + ), + } + ), + ) + ], + post={ + alice: Account(nonce=3), + contract: Account(storage={1: 3}), + }, + ) + + +@pytest.mark.parametrize( + "intermediate_values", + [ + pytest.param([2], id="depth_1"), + pytest.param([2, 3], id="depth_2"), + pytest.param([2, 3, 4], id="depth_3"), + ], +) +def test_bal_nested_delegatecall_storage_writes_net_zero( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + intermediate_values: list, +) -> None: + """ + Test BAL correctly handles nested DELEGATECALL frames where intermediate + frames write different values but the deepest frame reverts to original. + + Each nesting level writes a different intermediate value, and the deepest + frame writes back the original value, resulting in net-zero change. + + Example for depth=2 (intermediate_values=[2, 3]): + - Pre-state: slot 0 = 1 + - Root frame writes: slot 0 = 2 + - Child frame writes: slot 0 = 3 + - Grandchild frame writes: slot 0 = 1 (back to original) + - Expected: No storage_changes (net-zero overall) + """ + alice = pre.fund_eoa() + starting_value = 1 + + # deepest contract writes back to starting_value + deepest_code = Op.SSTORE(0, starting_value) + Op.STOP + next_contract = pre.deploy_contract(code=deepest_code) + delegate_contracts = [next_contract] + + # Build intermediate contracts (in reverse order) that write then + # DELEGATECALL. Skip the first value since that's for the root contract + for value in reversed(intermediate_values[1:]): + code = ( + Op.SSTORE(0, value) + + Op.DELEGATECALL(100_000, next_contract, 0, 0, 0, 0) + + Op.STOP + ) + next_contract = pre.deploy_contract(code=code) + delegate_contracts.append(next_contract) + + # root_contract writes first intermediate value, then DELEGATECALLs + root_contract = pre.deploy_contract( + code=( + Op.SSTORE(0, intermediate_values[0]) + + Op.DELEGATECALL(100_000, next_contract, 0, 0, 0, 0) + + Op.STOP + ), + storage={0: starting_value}, + ) + + tx = Transaction( + sender=alice, + to=root_contract, + gas_limit=500_000, + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + root_contract: BalAccountExpectation( + storage_reads=[0], + storage_changes=[], # validate no changes + ), + } + # All delegate contracts accessed but no changes + for contract in delegate_contracts: + account_expectations[contract] = BalAccountExpectation.empty() + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + root_contract: Account(storage={0: starting_value}), + }, + ) + + +def test_bal_create_transaction_empty_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL does not record spurious code changes when a CREATE transaction + deploys empty code. + """ + alice = pre.fund_eoa() + contract_address = compute_create_address(address=alice, nonce=0) + + tx = Transaction( + sender=alice, + to=None, + data=b"", + gas_limit=100_000, + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + contract_address: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + code_changes=[], # ensure no code_changes recorded + ), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + contract_address: Account(nonce=1, code=b""), + }, + ) + + +def test_bal_cross_tx_storage_revert_to_zero( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures storage changes when tx1 writes a non-zero value + and tx2 reverts it back to zero. This is a regression test for the + blobhash scenario where slot changes were being incorrectly filtered + as net-zero across transaction boundaries. + + Tx1: slot 0 = 0x0 -> 0xABCD (change at block_access_index=1) + Tx2: slot 0 = 0xABCD -> 0x0 (change MUST be at block_access_index=2) + """ + alice = pre.fund_eoa() + + # Contract that writes to slot 0 based on calldata + contract = pre.deploy_contract(code=Op.SSTORE(0, Op.CALLDATALOAD(0))) + + # Tx1: Write slot 0 = 0xABCD + tx1 = Transaction( + sender=alice, + to=contract, + data=Hash(0xABCD), + gas_limit=100_000, + ) + + # Tx2: Write slot 0 = 0x0 (revert to zero) + tx2 = Transaction( + sender=alice, + to=contract, + data=Hash(0x0), + gas_limit=100_000, + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), + ], + ), + contract: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0xABCD + ), + # CRITICAL: tx2's write to 0x0 MUST appear + # even though it returns slot to original value + BalStorageChange(block_access_index=2, post_value=0x0), + ], + ), + ], + ), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1, tx2], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=2), + contract: Account(storage={0: 0x0}), + }, + ) + + +@pytest.mark.pre_alloc_group( + "ripemd160_state_leak", + reason="Pre-funds RIPEMD-160, must be isolated in EngineX format", +) +def test_bal_cross_block_ripemd160_state_leak( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure internal EVM state for RIMPEMD-160 precompile handling does not + leak between blocks. + + The EVM may track internal state related to the Parity Touch Bug (EIP-161) + when calling RIPEMD-160 (0x03) with zero value. If this state is not + properly reset between blocks, it can cause incorrect BAL entries in + subsequent blocks. + + Prerequisites for triggering the bug: + 1. RIPEMD-160 (0x03) must already exist in state before the call. + 2. Block 1 must call RIPEMD-160 with zero value and complete successfully. + 3. Block 2 must have a TX that triggers an exception (not REVERT). + + Expected behavior: + - Block 1: RIPEMD-160 in BAL (legitimate access) + - Block 2: RIPEMD-160 NOT in BAL (never touched in this block) + + Bug behavior: + - Block 2 incorrectly has RIPEMD-160 in its BAL due to leaked + internal state. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa() + # Pre-fund RIPEMD-160 so it exists before the call. + # This is required to trigger the internal state tracking. + ripemd160_addr = Address(0x03) + pre.fund_address(ripemd160_addr, amount=1) + + # Contract that calls RIPEMD-160 with zero value + ripemd_caller = pre.deploy_contract( + code=Op.CALL(50_000, ripemd160_addr, 0, 0, 0, 0, 0) + Op.STOP + ) + # Contract that triggers an exception + # (stack underflow from ADD on empty stack) + exception_contract = pre.deploy_contract(code=Op.ADD) + + # Block 1: Call RIPEMD-160 successfully + block1 = Block( + txs=[ + Transaction( + sender=alice, + to=ripemd_caller, + gas_limit=100_000, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] + ), + bob: None, + ripemd_caller: BalAccountExpectation.empty(), + ripemd160_addr: BalAccountExpectation.empty(), + } + ), + ) + + # Block 2: Exception triggers internal exception handling. + # If internal state leaked from Block 1, RIPEMD-160 would incorrectly + # appear in Block 2's BAL. + block2 = Block( + txs=[ + Transaction( + sender=bob, + to=exception_contract, + gas_limit=100_000, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: None, + bob: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] + ), + # this is the important check + ripemd160_addr: None, + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block1, block2], + post={ + alice: Account(nonce=1), + bob: Account(nonce=1), + ripemd160_addr: Account(balance=1), + }, + ) + + +def test_bal_all_transaction_types( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with all 5 tx types in single block. + + Types: Legacy, EIP-2930, EIP-1559, Blob, EIP-7702. + Each tx writes to contract storage. Access list addresses are pre-warmed + but NOT in BAL. + + Expected BAL: + - All 5 senders: nonce_changes + - Contracts 0-3: storage_changes + - Alice (7702): nonce_changes, code_changes (delegation), storage_changes + - Oracle: empty (delegation target, accessed) + """ + from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702 + + # Create senders for each transaction type + sender_0 = pre.fund_eoa() # Type 0 - Legacy + sender_1 = pre.fund_eoa() # Type 1 - Access List + sender_2 = pre.fund_eoa() # Type 2 - EIP-1559 + sender_3 = pre.fund_eoa() # Type 3 - Blob + sender_4 = pre.fund_eoa() # Type 4 - EIP-7702 + + # Create contracts for each tx type (except 7702 which uses delegation) + contract_code = Op.SSTORE(0x01, Op.CALLDATALOAD(0)) + Op.STOP + contract_0 = pre.deploy_contract(code=contract_code) + contract_1 = pre.deploy_contract(code=contract_code) + contract_2 = pre.deploy_contract(code=contract_code) + contract_3 = pre.deploy_contract(code=contract_code) + + # For Type 4 (EIP-7702): Alice delegates to Oracle + alice = pre.fund_eoa() + oracle = pre.deploy_contract(code=Op.SSTORE(0x01, 0x05) + Op.STOP) + + # Dummy address to warm in access list + warmed_address = pre.fund_eoa(amount=1) + + # TX1: Type 0 - Legacy transaction + tx_type_0 = Transaction( + ty=0, + sender=sender_0, + to=contract_0, + gas_limit=100_000, + gas_price=10, + data=Hash(0x01), # Value to store + ) + + # TX2: Type 1 - Access List transaction (EIP-2930) + tx_type_1 = Transaction( + ty=1, + sender=sender_1, + to=contract_1, + gas_limit=100_000, + gas_price=10, + data=Hash(0x02), + access_list=[ + AccessList( + address=warmed_address, + storage_keys=[], + ) + ], + ) + + # TX3: Type 2 - EIP-1559 Dynamic fee transaction + tx_type_2 = Transaction( + ty=2, + sender=sender_2, + to=contract_2, + gas_limit=100_000, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + data=Hash(0x03), + ) + + # TX4: Type 3 - Blob transaction (EIP-4844) + # Blob versioned hashes need KZG version prefix (0x01) + blob_hashes = add_kzg_version([Hash(0xBEEF)], 1) + tx_type_3 = Transaction( + ty=3, + sender=sender_3, + to=contract_3, + gas_limit=100_000, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + max_fee_per_blob_gas=10, + blob_versioned_hashes=blob_hashes, + data=Hash(0x04), + ) + + # TX5: Type 4 - EIP-7702 Set Code transaction + tx_type_4 = Transaction( + ty=4, + sender=sender_4, + to=alice, + gas_limit=100_000, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + authorization_list=[ + AuthorizationTuple( + address=oracle, + nonce=0, + signer=alice, + ) + ], + ) + + block = Block( + txs=[tx_type_0, tx_type_1, tx_type_2, tx_type_3, tx_type_4], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + # Type 0 sender + sender_0: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + # Type 1 sender + sender_1: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=2, post_nonce=1) + ], + ), + # Type 2 sender + sender_2: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=3, post_nonce=1) + ], + ), + # Type 3 sender + sender_3: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=4, post_nonce=1) + ], + ), + # Type 4 sender + sender_4: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=5, post_nonce=1) + ], + ), + # Contract touched by Type 0 + contract_0: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x01 + ) + ], + ) + ], + ), + # Contract touched by Type 1 + contract_1: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=2, post_value=0x02 + ) + ], + ) + ], + ), + # Note: warmed_address from access_list is NOT in BAL + # because access lists pre-warm but don't record in BAL + # Contract touched by Type 2 + warmed_address: None, # explicit check + contract_2: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=3, post_value=0x03 + ) + ], + ) + ], + ), + # Contract touched by Type 3 + contract_3: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=4, post_value=0x04 + ) + ], + ) + ], + ), + # Alice (Type 4 delegation target, executes oracle code) + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=5, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=5, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=5, post_value=0x05 + ) + ], + ) + ], + ), + # Oracle (accessed via delegation) + oracle: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + sender_0: Account(nonce=1), + sender_1: Account(nonce=1), + sender_2: Account(nonce=1), + sender_3: Account(nonce=1), + sender_4: Account(nonce=1), + contract_0: Account(storage={0x01: 0x01}), + contract_1: Account(storage={0x01: 0x02}), + contract_2: Account(storage={0x01: 0x03}), + contract_3: Account(storage={0x01: 0x04}), + alice: Account( + nonce=1, + code=Spec7702.delegation_designation(oracle), + storage={0x01: 0x05}, + ), + }, + ) + + +def test_bal_lexicographic_address_ordering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL enforces strict lexicographic byte-wise address ordering. + + Addresses: addr_low (0x...020000), addr_mid (0x...02000000), + addr_high (0x20...00). Endian-trap: addr_endian_low (0x01...02), + addr_endian_high (0x02...01). Contract touches them in reverse + order to verify sorting. + + Expected BAL order: low < mid < high < endian_low < endian_high. + Catches endianness bugs in address comparison. + """ + alice = pre.fund_eoa() + + # Create addresses with specific byte patterns for lexicographic testing + # In lexicographic (byte-wise) order: low < mid < high + # addr_low: 0x00...020000 (0x02 in third-rightmost byte) + # addr_mid: 0x00...02000000 (0x02 in fourth-rightmost byte) + # addr_high: 0x20...00 (leftmost byte = 0x20) + # Note: Using 0x2xxxx addresses to avoid precompiles (0x01-0x11, 0x100) + addr_low = Address("0x0000000000000000000000000000000000020000") + addr_mid = Address("0x0000000000000000000000000000000002000000") + addr_high = Address("0x2000000000000000000000000000000000000000") + + # Endian-trap addresses: byte-reversals to catch byte-order bugs + # addr_endian_low: 0x01...02 (0x01 at byte 0, 0x02 at byte 19) + # addr_endian_high: 0x02...01 (0x02 at byte 0, 0x01 at byte 19) + # Note: reverse(addr_endian_low) = addr_endian_high + # Correct order: endian_low < endian_high (0x01 < 0x02 at byte 0) + # Reversed bytes would incorrectly get opposite order + addr_endian_low = Address("0x0100000000000000000000000000000000000002") + addr_endian_high = Address("0x0200000000000000000000000000000000000001") + + # Give each address a balance so they exist + addr_balance = 100 + pre[addr_low] = Account(balance=addr_balance) + pre[addr_mid] = Account(balance=addr_balance) + pre[addr_high] = Account(balance=addr_balance) + pre[addr_endian_low] = Account(balance=addr_balance) + pre[addr_endian_high] = Account(balance=addr_balance) + + # Contract that accesses addresses in REVERSE lexicographic order + # to verify sorting is applied correctly + contract_code = ( + Op.BALANCE(addr_high) # Access high first + + Op.POP + + Op.BALANCE(addr_low) # Access low second + + Op.POP + + Op.BALANCE(addr_mid) # Access mid third + + Op.POP + # Access endian-trap addresses in reverse order + + Op.BALANCE(addr_endian_high) # Access endian_high before endian_low + + Op.POP + + Op.BALANCE(addr_endian_low) + + Op.POP + + Op.STOP + ) + + contract = pre.deploy_contract(code=contract_code) + + tx = Transaction( + sender=alice, + to=contract, + gas_limit=1_000_000, + ) + + # BAL must be sorted lexicographically by address bytes + # Order: low < mid < high < endian_low < endian_high + # (sorted by raw address bytes, regardless of access order) + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + contract: BalAccountExpectation.empty(), + # These addresses appear in BAL due to BALANCE access + # The expectation framework verifies correct order + addr_low: BalAccountExpectation.empty(), + addr_mid: BalAccountExpectation.empty(), + addr_high: BalAccountExpectation.empty(), + # Endian-trap addresses: must be sorted correctly despite being + # byte-reversals of each other + addr_endian_low: BalAccountExpectation.empty(), + addr_endian_high: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account(), + addr_low: Account(balance=addr_balance), + addr_mid: Account(balance=addr_balance), + addr_high: Account(balance=addr_balance), + addr_endian_low: Account(balance=addr_balance), + addr_endian_high: Account(balance=addr_balance), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py new file mode 100644 index 0000000000..9d32fdf123 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py @@ -0,0 +1,318 @@ +""" +Tests for EIP-7928 BAL cross-index tracking. + +Tests that state changes are correctly tracked across different block indices: +- Index 1..N: Regular transactions +- Index N+1: Post-execution system operations + +Includes tests for system contracts (withdrawal/consolidation) cross-index +tracking and NOOP filtering behavior. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Bytecode, + Op, + Transaction, +) + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +WITHDRAWAL_REQUEST_ADDRESS = Address( + 0x00000961EF480EB55E80D19AD83579A64C007002 +) +CONSOLIDATION_REQUEST_ADDRESS = Address( + 0x0000BBDDC7CE488642FB579F8B00F3A590007251 +) + + +def test_bal_withdrawal_contract_cross_index( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that the withdrawal system contract shows storage changes at both + index 1 (during transaction) and index 2 (during post-execution). + + This verifies that slots 0x01 and 0x03 are: + 1. Incremented during the transaction (index 1) + 2. Reset during post-execution (index 2) + """ + sender = pre.fund_eoa() + + withdrawal_calldata = ( + (b"\x01" + b"\x00" * 47) # validator pubkey + + (b"\x00" * 8) # amount + ) + + tx = Transaction( + sender=sender, + to=WITHDRAWAL_REQUEST_ADDRESS, + value=1, + data=withdrawal_calldata, + gas_limit=1_000_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + WITHDRAWAL_REQUEST_ADDRESS: BalAccountExpectation( + # slots 0x01 and 0x03 change at BOTH indices + storage_changes=[ + BalStorageSlot( + slot=0x01, # Request count + slot_changes=[ + BalStorageChange( + # Incremented during tx + block_access_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + block_access_index=2, + post_value=0, + ), + ], + ), + BalStorageSlot( + slot=0x03, # Target count + slot_changes=[ + BalStorageChange( + # Incremented during tx + block_access_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + block_access_index=2, + post_value=0, + ), + ], + ), + ], + ), + } + ), + ) + ], + post={}, + ) + + +def test_bal_consolidation_contract_cross_index( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that the consolidation system contract shows storage changes at both + index 1 (during transaction) and index 2 (during post-execution). + """ + sender = pre.fund_eoa() + + consolidation_calldata = ( + (b"\x01" + b"\x00" * 47) # source pubkey + + (b"\x02" + b"\x00" * 47) # target pubkey + ) + + tx = Transaction( + sender=sender, + to=CONSOLIDATION_REQUEST_ADDRESS, + value=1, + data=consolidation_calldata, + gas_limit=1_000_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + CONSOLIDATION_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + # Incremented during tx + block_access_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + block_access_index=2, + post_value=0, + ), + ], + ), + BalStorageSlot( + slot=0x03, + slot_changes=[ + BalStorageChange( + # Incremented during tx + block_access_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + block_access_index=2, + post_value=0, + ), + ], + ), + ], + ), + } + ), + ) + ], + post={}, + ) + + +def test_bal_noop_write_filtering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that NOOP writes (writing same value or 0 to empty) are filtered. + + This verifies that: + 1. Writing 0 to an uninitialized slot doesn't appear in BAL + 2. Writing the same value to a slot doesn't appear in BAL + 3. Only actual changes are tracked + """ + test_code = Bytecode( + # Write 0 to uninitialized slot 1 (noop) + Op.SSTORE(1, 0) + # Write 42 to slot 2 + + Op.SSTORE(2, 42) + # Write 100 to slot 3 (will be same as pre-state, should be filtered) + + Op.SSTORE(3, 100) + # Write 200 to slot 4 (different from pre-state 150, should appear) + + Op.SSTORE(4, 200) + ) + + sender = pre.fund_eoa() + test_address = pre.deploy_contract( + code=test_code, + storage={3: 100, 4: 150}, + ) + + tx = Transaction( + sender=sender, + to=test_address, + gas_limit=100_000, + ) + + # Expected BAL should only show actual changes + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + test_address: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=2, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=42 + ), + ], + ), + BalStorageSlot( + slot=4, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=200 + ), + ], + ), + ], + ), + } + ) + + block = Block( + txs=[tx], + expected_block_access_list=expected_block_access_list, + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + test_address: Account(storage={2: 42, 3: 100, 4: 200}), + }, + ) + + +def test_bal_system_contract_noop_filtering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that system contract post-execution calls filter net-zero + storage writes. + + When no transaction interacts with withdrawal/consolidation contracts + during a block, the post-execution system calls read storage slots + 0-3 but don't modify them. These should appear as storage READS, + not storage CHANGES. + """ + sender = pre.fund_eoa() + receiver = pre.fund_eoa(amount=0) + + # simple transfer that doesn't interact with system contracts + tx = Transaction( + sender=sender, + to=receiver, + value=100, + gas_limit=21_000, + ) + + # withdrawal and consolidation contracts should NOT have any storage + # changes since they weren't modified - only reads occurred during + # post-execution system calls + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + WITHDRAWAL_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[], + storage_reads=[0x00, 0x01, 0x02, 0x03], + ), + CONSOLIDATION_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[], + storage_reads=[0x00, 0x01, 0x02, 0x03], + ), + } + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=expected_block_access_list, + ) + ], + post={ + receiver: Account(balance=100), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4788.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4788.py new file mode 100644 index 0000000000..02c0c6fdd9 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4788.py @@ -0,0 +1,459 @@ +"""Tests for the effects of EIP-4788 beacon roots on EIP-7928.""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Fork, + Hash, + Op, + Transaction, +) + +from tests.cancun.eip4788_beacon_root.spec import Spec, SpecHelpers + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +BEACON_ROOTS_ADDRESS = Address(Spec.BEACON_ROOTS_ADDRESS) +SYSTEM_ADDRESS = Address(Spec.SYSTEM_ADDRESS) + + +def get_beacon_root_slots(timestamp: int) -> tuple: + """ + Return (timestamp_slot, root_slot) for beacon root ring buffer. + + The beacon root contract uses two ring buffers: + - timestamp_slot = timestamp % 8191 + - root_slot = (timestamp % 8191) + 8191 + """ + helpers = SpecHelpers() + return ( + helpers.timestamp_index(timestamp), + helpers.root_index(timestamp), + ) + + +def beacon_root_system_call_expectations( + timestamp: int, + beacon_root: Hash, +) -> dict: + """ + Build BAL expectations for beacon root pre-execution system call. + + Returns account expectations for BEACON_ROOTS_ADDRESS and + SYSTEM_ADDRESS at block_access_index=0. + """ + timestamp_slot, root_slot = get_beacon_root_slots(timestamp) + + return { + BEACON_ROOTS_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=timestamp_slot, + slot_changes=[ + BalStorageChange( + block_access_index=0, post_value=timestamp + ) + ], + ), + BalStorageSlot( + slot=root_slot, + slot_changes=[ + BalStorageChange( + block_access_index=0, post_value=beacon_root + ) + ], + ), + ], + ), + # System address MUST NOT be included + SYSTEM_ADDRESS: None, + } + + +def build_beacon_root_setup_block( + timestamp: int, + beacon_root: Hash, +) -> Block: + """ + Build a block that stores beacon root via pre-execution system call. + + This is used as the first block in tests that query beacon roots. + Returns an empty block (no transactions) that only performs the + system call to store the beacon root. + """ + account_expectations = beacon_root_system_call_expectations( + timestamp, beacon_root + ) + + return Block( + txs=[], + parent_beacon_block_root=beacon_root, + timestamp=timestamp, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + +def test_bal_4788_simple( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL captures beacon root storage writes during pre-execution + system call. + + Block with 2 normal user transactions: Alice sends 10 wei to Charlie, + Bob sends 10 wei to Charlie. At block start (pre-execution), + SYSTEM_ADDRESS calls BEACON_ROOTS_ADDRESS to store parent beacon root. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa() + charlie = pre.fund_eoa(amount=0) + + block_timestamp = 12 + beacon_root = Hash(0xABCDEF) + + transfer_value = 10 + + tx1 = Transaction( + sender=alice, + to=charlie, + value=transfer_value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + tx2 = Transaction( + sender=bob, + to=charlie, + value=transfer_value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + # Build BAL expectations starting with system call + account_expectations = beacon_root_system_call_expectations( + block_timestamp, beacon_root + ) + + # Add transaction-specific expectations + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + account_expectations[bob] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=2, post_nonce=1)], + ) + account_expectations[charlie] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=transfer_value + ), + BalBalanceChange( + block_access_index=2, post_balance=transfer_value * 2 + ), + ], + ) + + block = Block( + txs=[tx1, tx2], + parent_beacon_block_root=beacon_root, + timestamp=block_timestamp, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(nonce=1), + charlie: Account(balance=transfer_value * 2), + }, + ) + + +def test_bal_4788_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures beacon root storage writes in empty block. + + Block with no transactions. At block start (pre-execution), + SYSTEM_ADDRESS calls BEACON_ROOTS_ADDRESS to store parent beacon root. + """ + block_timestamp = 12 + beacon_root = Hash(0xABCDEF) + + # Build BAL expectations (only system call, no transactions) + account_expectations = beacon_root_system_call_expectations( + block_timestamp, beacon_root + ) + + block = Block( + txs=[], + parent_beacon_block_root=beacon_root, + timestamp=block_timestamp, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={}, + ) + + +@pytest.mark.parametrize( + "timestamp,beacon_root,query_timestamp,expected_result,is_valid", + [ + pytest.param( + 12, Hash(0xABCDEF), 12, Hash(0xABCDEF), True, id="valid_timestamp" + ), + pytest.param(12, Hash(0xABCDEF), 42, 0, False, id="invalid_timestamp"), + pytest.param(12, Hash(0xABCDEF), 0, 0, False, id="zero_timestamp"), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="no_value"), + pytest.param(100, id="with_value"), + ], +) +def test_bal_4788_query( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + timestamp: int, + beacon_root: Hash, + query_timestamp: int, + expected_result: int | Hash, + is_valid: bool, + value: int, +) -> None: + """ + Ensure BAL captures storage reads when querying beacon root. + + Test scenarios: + 1. Valid query (timestamp=12, matches stored timestamp): Beacon root + contract reads both timestamp and root slots, query contract writes + returned value + 2. Invalid query with non-zero timestamp (timestamp=42, no match): + Beacon root contract reads only timestamp slot then reverts, query + contract has implicit read recorded + 3. Invalid query with zero timestamp (timestamp=0): Beacon root + contract reverts immediately before any storage access, query + contract has implicit read recorded + 4. With value transfer: BAL captures balance changes in addition + to storage operations (only when query is valid) + """ + # Block 1: Store beacon root + block1 = build_beacon_root_setup_block(timestamp, beacon_root) + + # Block 2: Alice queries the beacon root + alice = pre.fund_eoa() + + # Contract that calls beacon root contract with timestamp from calldata + # and stores returned beacon root in slot 0, forwarding any value sent + query_code = ( + Op.CALLDATACOPY(0, 0, 32) + + Op.CALL( + Spec.BEACON_ROOTS_CALL_GAS, + BEACON_ROOTS_ADDRESS, + Op.CALLVALUE, # forward value to beacon root contract + 0, # args offset + 32, # args size (timestamp) + 32, # return offset + 32, # return size (beacon root) + ) + + Op.SSTORE(0, Op.MLOAD(32)) + ) + query_contract = pre.deploy_contract(query_code) + + tx = Transaction( + sender=alice, + to=query_contract, + data=Hash(query_timestamp), + value=value, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + # Build BAL expectations for block 2 + block2_timestamp = timestamp + 1 + block2_beacon_root = Hash(0xDEADBEEF) + + account_expectations = beacon_root_system_call_expectations( + block2_timestamp, block2_beacon_root + ) + + # Add storage reads for the query + timestamp_slot, root_slot = get_beacon_root_slots(query_timestamp) + + # Storage access depends on query validity: + # - Zero timestamp: reverts immediately (no storage access) + # - Valid timestamp: reads both timestamp and root slots + # - Invalid non-zero timestamp: reads only timestamp slot before reverting + account_expectations[BEACON_ROOTS_ADDRESS].storage_reads = ( + [] + if query_timestamp == 0 # Reverts early if timestamp is zero + else [timestamp_slot, root_slot] + if is_valid + else [timestamp_slot] + ) + + # Add balance changes if value is transferred + if value > 0 and is_valid: + account_expectations[BEACON_ROOTS_ADDRESS].balance_changes = [ + BalBalanceChange(block_access_index=1, post_balance=value) + ] + + # Add transaction-specific expectations + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + + account_expectations[query_contract] = BalAccountExpectation( + # If the call to beacon root contract reverts + # a no-op write happens and an implicit read is + # recorded. + storage_reads=[] if is_valid else [0], + # Write reverts if invalid + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=expected_result + ) + ], + ), + ] + if is_valid + else [], + # if value > 0 and invalid, no balance is sent to beacon root so + # is kept in the query contract + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=value, + ) + ] + if not is_valid and value > 0 + else [], + ) + + block2 = Block( + txs=[tx], + parent_beacon_block_root=block2_beacon_root, + timestamp=block2_timestamp, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post_state = { + alice: Account(nonce=1), + query_contract: Account(storage={0: expected_result}), + } + + if value > 0 and is_valid: + post_state[BEACON_ROOTS_ADDRESS] = Account(balance=value) + + blockchain_test( + pre=pre, + blocks=[block1, block2], + post=post_state, + ) + + +def test_bal_4788_selfdestruct_to_beacon_root( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL captures SELFDESTRUCT to beacon root address alongside + system call storage writes. + + Single block with pre-execution system call writing beacon root to + storage, followed by transaction where contract selfdestructs sending + funds to BEACON_ROOTS_ADDRESS. Tests that same address can appear in + BAL with different change types (storage_changes and balance_changes) + at different transaction indices. + """ + alice = pre.fund_eoa() + + block_timestamp = 12 + beacon_root = Hash(0xABCDEF) + contract_balance = 100 + + # Contract that selfdestructs to beacon root address + selfdestruct_code = Op.SELFDESTRUCT(BEACON_ROOTS_ADDRESS) + selfdestruct_contract = pre.deploy_contract( + code=selfdestruct_code, + balance=contract_balance, + ) + + tx = Transaction( + sender=alice, + to=selfdestruct_contract, + gas_limit=fork.transaction_gas_limit_cap(), + ) + + # Build BAL expectations starting with system call + account_expectations = beacon_root_system_call_expectations( + block_timestamp, beacon_root + ) + + # Add balance change from selfdestruct to beacon root address + account_expectations[BEACON_ROOTS_ADDRESS].balance_changes = [ + BalBalanceChange(block_access_index=1, post_balance=contract_balance) + ] + + # Add transaction-specific expectations + account_expectations[alice] = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ) + account_expectations[selfdestruct_contract] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + + block = Block( + txs=[tx], + parent_beacon_block_root=beacon_root, + timestamp=block_timestamp, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + BEACON_ROOTS_ADDRESS: Account(balance=contract_balance), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py new file mode 100644 index 0000000000..df35ba66ef --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py @@ -0,0 +1,860 @@ +"""Tests for the effects of EIP-4895 withdrawals on EIP-7928.""" + +import pytest +from execution_testing import ( + EOA, + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Environment, + Fork, + Header, + Initcode, + Op, + Transaction, + Withdrawal, + compute_create_address, +) + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +GWEI = 10**9 + + +def test_bal_withdrawal_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal balance changes in empty block. + + Charlie starts with 1 gwei balance (existing account). + Block with 0 transactions and 1 withdrawal of 10 gwei to Charlie. + Charlie ends with 11 gwei balance. + """ + charlie = pre.fund_eoa(amount=1 * GWEI) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=11 * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=11 * GWEI), + }, + ) + + +def test_bal_withdrawal_and_transaction( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures both transaction and withdrawal balance changes. + + Alice starts with 1 ETH, Bob starts with 0, Charlie starts with 0. + Alice sends 5 wei to Bob. + Charlie receives 10 gwei withdrawal. + Bob ends with 5 wei, Charlie ends with 10 gwei. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + charlie = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=bob, + value=5, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=5) + ], + ), + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=2, post_balance=10 * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=5), + charlie: Account(balance=10 * GWEI), + }, + ) + + +def test_bal_withdrawal_to_nonexistent_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to non-existent account. + + Charlie is a non-existent address (not in pre-state). + Block with 0 transactions and 1 withdrawal of 10 gwei to Charlie. + Charlie ends with 10 gwei balance. + """ + charlie = Address(0xCC) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=10 * GWEI), + }, + ) + + +def test_bal_withdrawal_no_evm_execution( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal without triggering EVM execution. + + Oracle contract starts with 0 balance and storage slot 0x01 = 0x42. + Oracle's code writes 0xFF to slot 0x01 when called. + Block with 0 transactions and 1 withdrawal of 10 gwei to Oracle. + Storage slot 0x01 remains 0x42 (EVM never executes). + """ + oracle = pre.deploy_contract( + code=Op.SSTORE(0x01, 0xFF), + storage={0x01: 0x42}, + ) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) + ], + storage_reads=[], + storage_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + oracle: Account( + balance=10 * GWEI, + storage={0x01: 0x42}, + ), + }, + ) + + +def test_bal_withdrawal_and_state_access_same_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures both state access and withdrawal to same address. + + Oracle contract starts with 0 balance and storage slot 0x01 = 0x42. + Alice calls Oracle (reads slot 0x01, writes 0x99 to slot 0x02). + Oracle receives withdrawal of 10 gwei. + Both state access and withdrawal are captured in BAL. + """ + alice = pre.fund_eoa() + oracle = pre.deploy_contract( + code=Op.SLOAD(0x01) + Op.SSTORE(0x02, 0x99), + storage={0x01: 0x42}, + ) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation( + storage_reads=[0x01], + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x99 + ) + ], + ) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=2, post_balance=10 * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account( + balance=10 * GWEI, + storage={0x01: 0x42, 0x02: 0x99}, + ), + }, + ) + + +def test_bal_withdrawal_and_value_transfer_same_address( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures both value transfer and withdrawal to same address. + + Alice starts with 1 ETH, Bob starts with 0. + Alice sends 5 gwei to Bob. + Bob receives withdrawal of 10 gwei. + Bob ends with 15 gwei (5 from tx + 10 from withdrawal). + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=bob, + value=5 * GWEI, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=bob, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=5 * GWEI + ), + BalBalanceChange( + block_access_index=2, post_balance=15 * GWEI + ), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=15 * GWEI), + }, + ) + + +def test_bal_multiple_withdrawals_same_address( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL accumulates multiple withdrawals to same address. + + Charlie starts with 0 balance. + Block empty block with 3 withdrawals to Charlie: 5 gwei, 10 gwei, 15 gwei. + Charlie ends with 30 gwei balance (cumulative). + """ + charlie = pre.fund_eoa(amount=0) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal(index=i, validator_index=i, address=charlie, amount=amt) + for i, amt in enumerate([5, 10, 15]) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=30 * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=30 * GWEI), + }, + ) + + +def test_bal_withdrawal_and_selfdestruct( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to self-destructed contract address. + + Oracle contract starts with 100 gwei balance. + Alice triggers Oracle to self-destruct, sending balance to Bob. + Oracle receives withdrawal of 50 gwei after self-destructing. + Oracle ends with 50 gwei (funded by withdrawal). + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + oracle = pre.deploy_contract( + balance=100 * GWEI, + code=Op.SELFDESTRUCT(bob), + ) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=50, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=100 * GWEI + ) + ], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0), + BalBalanceChange( + block_access_index=2, post_balance=50 * GWEI + ), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=100 * GWEI), + oracle: Account(balance=50 * GWEI), + }, + ) + + +def test_bal_withdrawal_and_new_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to newly created contract. + + Alice deploys Oracle contract with 5 gwei initial balance. + Oracle receives withdrawal of 10 gwei in same block. + Oracle ends with 15 gwei (5 from deployment + 10 from withdrawal). + """ + alice = pre.fund_eoa() + + code = Op.STOP + initcode = Initcode(deploy_code=code) + oracle = compute_create_address(address=alice) + + tx = Transaction( + sender=alice, + to=None, + data=initcode, + value=5 * GWEI, + gas_limit=1_000_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation( + code_changes=[ + BalCodeChange(block_access_index=1, new_code=code) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=5 * GWEI + ), + BalBalanceChange( + block_access_index=2, post_balance=15 * GWEI + ), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(balance=15 * GWEI, code=code), + }, + ) + + +@pytest.mark.parametrize( + "initial_balance", + [ + pytest.param(5 * GWEI, id="existing_account"), + pytest.param(0, id="nonexistent_account"), + ], +) +def test_bal_zero_withdrawal( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + initial_balance: int, +) -> None: + """ + Ensure BAL handles zero-amount withdrawal correctly. + + Charlie either exists with initial balance or is non-existent. + Block with 0 transactions and 1 zero-amount withdrawal to Charlie. + Charlie appears in BAL but with empty changes, balance unchanged. + """ + if initial_balance > 0: + charlie = pre.fund_eoa(amount=initial_balance) + else: + charlie = EOA(0xCC) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=0, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=initial_balance) + if initial_balance > 0 + else Account.NONEXISTENT, + }, + ) + + +@pytest.mark.pre_alloc_group( + "withdrawal_to_precompiles", + reason="Expects clean precompile balances, isolate in EngineX", +) +@pytest.mark.parametrize_by_fork( + "precompile", + lambda fork: [ + pytest.param(addr, id=f"0x{int.from_bytes(addr, 'big'):02x}") + for addr in fork.precompiles(block_number=0, timestamp=0) + ], +) +def test_bal_withdrawal_to_precompiles( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, +) -> None: + """ + Ensure BAL captures withdrawal to precompile addresses. + + Block with 0 transactions and 1 withdrawal of 10 gwei to precompile. + Precompile ends with 10 gwei balance. + """ + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=precompile, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) + ], + storage_reads=[], + storage_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + precompile: Account(balance=10 * GWEI), + }, + ) + + +def test_bal_withdrawal_largest_amount( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal with largest amount. + + Block with 0 transactions and 1 withdrawal of maximum + uint64 value (2^64-1)Gwei to Charlie. + Charlie ends with (2^64-1) Gwei. + """ + charlie = pre.fund_eoa(amount=0) + max_amount = 2**64 - 1 + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=max_amount, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=max_amount * GWEI, + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=max_amount * GWEI), + }, + ) + + +def test_bal_withdrawal_to_coinbase( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL captures withdrawal to coinbase address. + + Block with 1 transaction and 1 withdrawal to coinbase/fee recipient. + Coinbase receives both transaction fees and withdrawal. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + coinbase = pre.fund_eoa(amount=0) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_gas_calculator() + tx_gas_limit = intrinsic_gas + 1000 + gas_price = 0xA + + tx = Transaction( + sender=alice, + to=bob, + value=5, + gas_limit=tx_gas_limit, + gas_price=gas_price, + ) + + # Calculate tip to coinbase + genesis_env = Environment(base_fee_per_gas=0x7) + base_fee_per_gas = fork.base_fee_per_gas_calculator()( + parent_base_fee_per_gas=int(genesis_env.base_fee_per_gas or 0), + parent_gas_used=0, + parent_gas_limit=genesis_env.gas_limit, + ) + tip_to_coinbase = (gas_price - base_fee_per_gas) * intrinsic_gas + coinbase_final_balance = tip_to_coinbase + (10 * GWEI) + + block = Block( + txs=[tx], + fee_recipient=coinbase, + header_verify=Header(base_fee_per_gas=base_fee_per_gas), + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=coinbase, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=5) + ], + ), + coinbase: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=tip_to_coinbase + ), + BalBalanceChange( + block_access_index=2, + post_balance=coinbase_final_balance, + ), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=5), + coinbase: Account(balance=coinbase_final_balance), + }, + genesis_environment=genesis_env, + ) + + +def test_bal_withdrawal_to_coinbase_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to coinbase when there are no transactions. + + Empty block with 1 withdrawal of 10 gwei to coinbase/fee recipient. + Coinbase receives only withdrawal (no transaction fees). + """ + coinbase = pre.fund_eoa(amount=0) + + block = Block( + txs=[], + fee_recipient=coinbase, + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=coinbase, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + coinbase: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + coinbase: Account(balance=10 * GWEI), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py new file mode 100644 index 0000000000..be9a0d2616 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py @@ -0,0 +1,810 @@ +"""Tests for the effects of EIP-7002 transactions on EIP-7928.""" + +from typing import Callable + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Op, + Transaction, +) + +from ...prague.eip7002_el_triggerable_withdrawals.helpers import ( + WithdrawalRequest, + WithdrawalRequestContract, + WithdrawalRequestInteractionBase, + WithdrawalRequestTransaction, +) +from ...prague.eip7002_el_triggerable_withdrawals.spec import Spec as Spec7002 +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +""" +Note: +1. In each block, the count resets to zero after execution. +2. During a partial sweep, the head is updated after execution; + if not written, the head remains read. +3. Similarly, the excess is modified for overflow; + if not written, it remains read. +4. If the first 32 bytes of the public key are zero, the second slot + in the queue performs a no-op write (i.e., a read). +""" + + +# --- helpers --- # +def _encode_pubkey_amount_slot(withdrawal_request: WithdrawalRequest) -> bytes: + """ + Encode slot +2: 32 bytes containing last 16 bytes of pubkey followed by + 8 bytes of big endian amount, padded with 8 zero bytes on the right. + Storage layout: [16 bytes pubkey][8 bytes amount][8 bytes padding]. + """ + last_16_bytes = withdrawal_request.validator_pubkey[-16:] + amount_bytes = withdrawal_request.amount.to_bytes(8, byteorder="big") + return last_16_bytes + amount_bytes + b"\x00" * 8 + + +def _build_queue_storage_slots( + senders: list, withdrawal_requests: list[WithdrawalRequest] +) -> tuple[list, list]: + """Build queue storage slots for withdrawal requests.""" + num_reqs = len(senders) + queue_writes = [] + queue_reads = [] + for i in range(num_reqs): + base_slot = Spec7002.WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + (i * 3) + # Slot +0: source address + queue_writes.append( + BalStorageSlot( + slot=base_slot, + slot_changes=[ + BalStorageChange( + block_access_index=i + 1, + post_value=senders[i], + ) + ], + ), + ) + # Slot +1: first 32 bytes of validator pubkey + first_32_bytes = int.from_bytes( + withdrawal_requests[i].validator_pubkey[:32], byteorder="big" + ) + if first_32_bytes != 0: + # Non-zero write: record as storage change + queue_writes.append( + BalStorageSlot( + slot=base_slot + 1, + slot_changes=[ + BalStorageChange( + block_access_index=i + 1, + post_value=first_32_bytes, + ) + ], + ), + ) + else: + # Zero write (no-op): record as storage read + queue_reads.append(base_slot + 1) + # Slot +2: last 16 bytes of pubkey + amount + queue_writes.append( + BalStorageSlot( + slot=base_slot + 2, + slot_changes=[ + BalStorageChange( + block_access_index=i + 1, + post_value=_encode_pubkey_amount_slot( + withdrawal_requests[i] + ), + ) + ], + ), + ) + return queue_writes, queue_reads + + +def _extract_post_storage_from_queue_writes(queue_writes: list) -> dict: + """Extract post-state storage dict from queue writes.""" + post_storage = {} + for bal_slot in queue_writes: + # Get the final value from the last slot_change + if bal_slot.slot_changes: + post_storage[bal_slot.slot] = bal_slot.slot_changes[-1].post_value + return post_storage + + +def _build_incremental_changes( + count: int, + change_class: type, + value_param: str, + value_fn: Callable[[int], int] = lambda i: i, + reset_to: int | None = None, +) -> list: + """ + Build a list of incremental changes with customizable value function. + + Args: + count: Number of changes to create + change_class: Class to instantiate for each change + value_param: Parameter name for the value + (e.g., 'post_balance', 'post_value') + value_fn: Function to compute value from index (default: identity) + reset_to: Optional reset value to append at the end + + """ + changes = [ + change_class(block_access_index=i, **{value_param: value_fn(i)}) + for i in range(1, count + 1) + ] + if reset_to is not None: + changes.append( + change_class( + block_access_index=count + 1, **{value_param: reset_to} + ) + ) + return changes + + +# --- tests --- # + + +@pytest.mark.parametrize( + "pubkey", + # Use different pubkey based on parameter + # 0x01 has first 32 bytes all zero + # Full 48-byte pubkey with non-zero first word + [0x01, b"key" * 16], + ids=["pubkey_first_word_zero", "pubkey_first_word_nonzero"], +) +@pytest.mark.parametrize( + "amount", + [0, 1000], + ids=["amount_zero", "amount_nonzero"], +) +def test_bal_7002_clean_sweep( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + pubkey: bytes, + amount: int, +) -> None: + """ + Ensure BAL correctly tracks "clean sweep" where all withdrawal requests + are dequeued in same block (requests โ‰ค MAX). + + Tests combinations of: + - pubkey with first 32 bytes zero / non-zero + - amount zero / non-zero + """ + alice = pre.fund_eoa() + + withdrawal_request = WithdrawalRequest( + validator_pubkey=pubkey, + amount=amount, + fee=Spec7002.get_fee(0), + ) + + # Transaction to system contract + tx = Transaction( + sender=alice, + to=Address(Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS), + value=withdrawal_request.fee, + data=withdrawal_request.calldata, + gas_limit=200_000, + ) + + # Build queue writes and reads based on pubkey + queue_writes, queue_reads = _build_queue_storage_slots( + [alice], [withdrawal_request] + ) + + # Base storage reads that always happen + base_storage_reads = [ + # Excess is read-only if while dequeuing queue doesn't overflow + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + # Head slot is read while dequeuing + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + ] + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( # noqa: E501 + balance_changes=[ + # Fee is collected. + BalBalanceChange( + block_access_index=1, + post_balance=withdrawal_request.fee, + ) + ], + storage_reads=base_storage_reads + queue_reads, + storage_changes=[ + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + # Count goes by number of request. + # Invariant 1: Post-execution ALWAYS resets count. + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + # Tail index goes up by number of requests. + # Invariant 2: resets if clean sweep. + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + ] + + queue_writes, + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: Account( + balance=withdrawal_request.fee, + storage=_extract_post_storage_from_queue_writes(queue_writes), + ), + }, + ) + + +def test_bal_7002_partial_sweep( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL correctly tracks queue overflow when requests exceed MAX. + Block 1: 20 requests (partial sweep, 16 dequeued). + Block 2: Empty (clean sweep of remaining 4). + """ + num_requests = 20 + fee = Spec7002.get_fee(0) + senders = [pre.fund_eoa() for _ in range(num_requests)] + + # Block 1: 20 withdrawal requests + withdrawal_requests = [ + WithdrawalRequest(validator_pubkey=i + 1, amount=0, fee=fee) + for i in range(num_requests) + ] + + eip7002_address = Address(Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS) + + txs_block_1 = [ + Transaction( + sender=sender, + to=eip7002_address, + value=withdrawal_request.fee, + data=withdrawal_request.calldata, + gas_limit=200_000, + ) + for sender, withdrawal_request in zip( + senders, withdrawal_requests, strict=True + ) + ] + + excess_after_block_1 = Spec7002.get_excess_withdrawal_requests( + 0, num_requests + ) + + block_1_expectations: dict = { + sender: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=i + 1, post_nonce=1) + ] + ) + for i, sender in enumerate(senders) + } + + # Build queue writes and reads + queue_writes, queue_reads = _build_queue_storage_slots( + senders, withdrawal_requests + ) + + block_1_expectations[eip7002_address] = BalAccountExpectation( + balance_changes=_build_incremental_changes( + num_requests, + BalBalanceChange, + "post_balance", + lambda i: fee * i, + ), + storage_reads=queue_reads, + storage_changes=[ + # Excess is only updated once during + # dequeue + BalStorageSlot( + slot=Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + slot_changes=[ + BalStorageChange( + block_access_index=num_requests + 1, + post_value=excess_after_block_1, + ) + ], + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + num_requests, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + slot_changes=[ + BalStorageChange( + block_access_index=num_requests + 1, + post_value=Spec7002.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, + ) + ], + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + num_requests, + BalStorageChange, + "post_value", + lambda i: i, + ), + ), + ] + + queue_writes, + ) + + # Block 2: Empty block, clean sweep of remaining 4 requests + excess_after_block_2 = Spec7002.get_excess_withdrawal_requests( + excess_after_block_1, 0 + ) + + block_2_expectations = { + eip7002_address: BalAccountExpectation( + storage_reads=[Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT], + storage_changes=[ + BalStorageSlot( + slot=Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + slot_changes=[ + BalStorageChange( + block_access_index=1, + post_value=excess_after_block_2, + ) + ], + ), + # Head is cleared + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0) + ], + ), + # Tail is cleared + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + slot_changes=[ + BalStorageChange(block_access_index=1, post_value=0) + ], + ), + ], + ) + } + + # Build post state storage: queue data persists even after dequeue + post_storage = _extract_post_storage_from_queue_writes(queue_writes) + post_storage[Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT] = ( + excess_after_block_2 + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=txs_block_1, + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_1_expectations + ), + ), + Block( + txs=[], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=block_2_expectations + ), + ), + ], + post={ + **{sender: Account(nonce=1) for sender in senders}, + eip7002_address: Account( + balance=fee * num_requests, + storage=post_storage, + ), + }, + ) + + +def test_bal_7002_no_withdrawal_requests( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures EIP-7002 system contract dequeue operation even + when block has no withdrawal requests. + + This test verifies that the post-execution dequeue system call always + reads queue state (slots 0-3), even when no requests are present. The + system contract should have storage_reads but no storage_changes. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + value = 10 + + tx = Transaction( + sender=alice, + to=bob, + value=value, + gas_limit=200_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=value + ) + ], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( # noqa: E501 + storage_reads=[ + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + ], + storage_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=value), + }, + ) + + +def test_bal_7002_request_from_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal request from contract with correct + source address. + + Alice calls RelayContract which internally calls EIP-7002 system + contract with withdrawal request. Withdrawal request should have + source_address = RelayContract (not Alice). + """ + fee = Spec7002.get_fee(0) + + # Create withdrawal request interaction using Prague helper + interaction = WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=fee, + ) + ], + contract_balance=fee, + ) + + # Set up pre-state using helper + interaction.update_pre(pre) + + alice = interaction.sender_account + relay_contract = interaction.contract_address + + # Build queue storage slots with contract as source + queue_writes, queue_reads = _build_queue_storage_slots( + [relay_contract], interaction.requests + ) + + block = Block( + txs=interaction.transactions(), + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + relay_contract: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=0, + ) + ], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( # noqa: E501 + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=fee, + ) + ], + storage_reads=[ + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + ] + + queue_reads, + storage_changes=[ + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + BalStorageSlot( + slot=Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + slot_changes=_build_incremental_changes( + 1, + BalStorageChange, + "post_value", + lambda i: i, + reset_to=0, + ), + ), + ] + + queue_writes, + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + relay_contract: Account(balance=0), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: Account( + balance=fee, + storage=_extract_post_storage_from_queue_writes(queue_writes), + ), + }, + ) + + +@pytest.mark.parametrize( + "interaction", + [ + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=0, # Below MIN_WITHDRAWAL_REQUEST_FEE + valid=False, + ) + ] + ), + id="insufficient_fee", + ), + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + calldata_modifier=lambda x: x[ + :-1 + ], # 55 bytes instead of 56 + valid=False, + ) + ] + ), + id="calldata_too_short", + ), + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + calldata_modifier=lambda x: x + + b"\x00", # 57 bytes instead of 56 + valid=False, + ) + ] + ), + id="calldata_too_long", + ), + pytest.param( + WithdrawalRequestTransaction( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + gas_limit=25_000, # Insufficient gas + valid=False, + ) + ] + ), + id="oog", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + call_type=Op.DELEGATECALL, + ), + id="invalid_call_type_delegatecall", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + call_type=Op.STATICCALL, + ), + id="invalid_call_type_staticcall", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + call_type=Op.CALLCODE, + ), + id="invalid_call_type_callcode", + ), + pytest.param( + WithdrawalRequestContract( + requests=[ + WithdrawalRequest( + validator_pubkey=0x01, + amount=0, + fee=Spec7002.get_fee(0), + valid=False, + ) + ], + extra_code=Op.REVERT(0, 0), + ), + id="contract_reverts", + ), + ], +) +def test_bal_7002_request_invalid( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + interaction: WithdrawalRequestInteractionBase, +) -> None: + """ + Ensure BAL correctly handles invalid withdrawal request scenarios. + + Tests various failure modes: + - insufficient_fee: Transaction reverts due to fee below minimum + - calldata_too_short: Transaction reverts due to short calldata (55 bytes) + - calldata_too_long: Transaction reverts due to long calldata (57 bytes) + - oog: Transaction runs out of gas before completion + - invalid_call_type_*: Contract call via DELEGATECALL/STATICCALL/CALLCODE + - contract_reverts: Contract calls system contract but reverts after + + In all cases: + - Sender's nonce increments (transaction executed) + - Sender pays gas costs + - System contract is accessed during dequeue but has no state changes + - No withdrawal request is queued + """ + # Use helper to set up pre-state and get transaction + interaction.update_pre(pre) + tx = interaction.transactions()[0] + alice = interaction.sender_account + + # Build account expectations + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: BalAccountExpectation( + storage_reads=[ + Spec7002.EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT, + Spec7002.WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT, + ], + storage_changes=[], + ), + } + + # For all invalid scenarios, system contract should have reads but + # no write since the dequeue operation still happens post-execution + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post: dict = { + alice: Account(nonce=1), + Spec7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS: Account(storage={}), + } + + # Add relay contract to post-state for contract scenarios + if isinstance(interaction, WithdrawalRequestContract): + post[interaction.contract_address] = Account() + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index 1e14bb5a1b..60676948a5 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py @@ -16,6 +16,7 @@ BlockchainTestFiller, Op, Transaction, + Withdrawal, ) from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 @@ -69,17 +70,21 @@ def test_bal_7702_delegation_create( account_expectations = { alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=2 if self_funded else 1) + BalNonceChange( + block_access_index=1, post_nonce=2 if self_funded else 1 + ) ], code_changes=[ BalCodeChange( - tx_index=1, + block_access_index=1, new_code=Spec7702.delegation_designation(oracle), ) ], ), bob: BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] ), # Oracle must not be present in BAL - the account is never accessed oracle: None, @@ -88,7 +93,7 @@ def test_bal_7702_delegation_create( # For sponsored variant, relayer must also be included in BAL if not self_funded: account_expectations[relayer] = BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], ) block = Block( @@ -180,24 +185,28 @@ def test_bal_7702_delegation_update( account_expectations = { alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=2 if self_funded else 1), - BalNonceChange(tx_index=2, post_nonce=4 if self_funded else 2), + BalNonceChange( + block_access_index=1, post_nonce=2 if self_funded else 1 + ), + BalNonceChange( + block_access_index=2, post_nonce=4 if self_funded else 2 + ), ], code_changes=[ BalCodeChange( - tx_index=1, + block_access_index=1, new_code=Spec7702.delegation_designation(oracle1), ), BalCodeChange( - tx_index=2, + block_access_index=2, new_code=Spec7702.delegation_designation(oracle2), ), ], ), bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10), - BalBalanceChange(tx_index=2, post_balance=20), + BalBalanceChange(block_access_index=1, post_balance=10), + BalBalanceChange(block_access_index=2, post_balance=20), ] ), # Both delegation targets must not be present in BAL @@ -210,8 +219,8 @@ def test_bal_7702_delegation_update( if not self_funded: account_expectations[relayer] = BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=2, post_nonce=2), + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), ], ) @@ -305,21 +314,25 @@ def test_bal_7702_delegation_clear( account_expectations = { alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=2 if self_funded else 1), - BalNonceChange(tx_index=2, post_nonce=4 if self_funded else 2), + BalNonceChange( + block_access_index=1, post_nonce=2 if self_funded else 1 + ), + BalNonceChange( + block_access_index=2, post_nonce=4 if self_funded else 2 + ), ], code_changes=[ BalCodeChange( - tx_index=1, + block_access_index=1, new_code=Spec7702.delegation_designation(oracle), ), - BalCodeChange(tx_index=2, new_code=""), + BalCodeChange(block_access_index=2, new_code=""), ], ), bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10), - BalBalanceChange(tx_index=2, post_balance=20), + BalBalanceChange(block_access_index=1, post_balance=10), + BalBalanceChange(block_access_index=2, post_balance=20), ] ), # Both delegation targets must not be present in BAL @@ -332,8 +345,8 @@ def test_bal_7702_delegation_clear( if not self_funded: account_expectations[relayer] = BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=2, post_nonce=2), + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), ], ) @@ -394,20 +407,24 @@ def test_bal_7702_delegated_storage_access( account_expectations={ alice: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10) + BalBalanceChange(block_access_index=1, post_balance=10) ], storage_changes=[ BalStorageSlot( slot=0x02, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], storage_reads=[0x01], ), bob: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # Oracle appears in BAL due to account access # (delegation target) @@ -463,11 +480,13 @@ def test_bal_7702_invalid_nonce_authorization( # Ensuring silent fail bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10) + BalBalanceChange(block_access_index=1, post_balance=10) ] ), relayer: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # Alice's account was marked warm but no changes were made alice: BalAccountExpectation.empty(), @@ -526,11 +545,13 @@ def test_bal_7702_invalid_chain_id_authorization( # Ensuring silent fail bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10) + BalBalanceChange(block_access_index=1, post_balance=10) ] ), relayer: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # Oracle must NOT be present - authorization failed so # account never accessed @@ -591,7 +612,9 @@ def test_bal_7702_delegated_via_call_opcode( expected_block_access_list=BlockAccessListExpectation( account_expectations={ bob: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), caller: BalAccountExpectation.empty(), # `alice` is accessed due to being the call target @@ -608,3 +631,485 @@ def test_bal_7702_delegated_via_call_opcode( blocks=[block], post=post, ) + + +def test_bal_7702_null_address_delegation_no_code_change( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL does not record spurious code changes when delegating to + NULL_ADDRESS (sets code to empty on an account that already has + empty code). + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=bob, + value=10, + gas_limit=1_000_000, + authorization_list=[ + AuthorizationTuple( + address=0, + nonce=1, + signer=alice, + ) + ], + ) + + # `alice` should appear in BAL with nonce change only, NOT code change + # because setting code from b"" to b"" is a net-zero change + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + code_changes=[], # explicit check for no code changes + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] + ), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=2, code=b""), + bob: Account(balance=10), + }, + ) + + +def test_bal_7702_double_auth_reset( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures the net code change when multiple authorizations + occur in the same transaction (double auth). + + This test verifies that when: + 1. First auth sets delegation to CONTRACT_A + 2. Second auth resets delegation to empty (address 0) + + The BAL should show the NET change (empty -> empty), not intermediate + states. This is a regression test for the bug where the BAL showed + the first auth's code but the final state was empty. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + relayer = pre.fund_eoa() + + contract_a = pre.deploy_contract(code=Op.STOP) + + # Transaction with double auth: + # 1. First sets delegation to contract_a + # 2. Second resets to empty + tx = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=contract_a, + nonce=0, + signer=alice, + ), + AuthorizationTuple( + address=0, # Reset to empty + nonce=1, + signer=alice, + ), + ], + ) + + 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=2 + ) + ], + code_changes=[], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=10 + ) + ] + ), + relayer: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + contract_a: None, + } + ), + ) + ], + post={ + alice: Account(nonce=2, code=b""), # Final code is empty + bob: Account(balance=10), + relayer: Account(nonce=1), + }, + ) + + +def test_bal_7702_double_auth_swap( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures the net code change when double auth swaps + delegation targets. + + This test verifies that when: + 1. First auth sets delegation to CONTRACT_A + 2. Second auth changes delegation to CONTRACT_B + + The BAL should show the final code change (empty -> CONTRACT_B), + not the intermediate CONTRACT_A. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + relayer = pre.fund_eoa() + + contract_a = pre.deploy_contract(code=Op.STOP) + contract_b = pre.deploy_contract(code=Op.STOP) + + tx = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=contract_a, + nonce=0, + signer=alice, + ), + AuthorizationTuple( + address=contract_b, # Override to contract_b + nonce=1, + signer=alice, + ), + ], + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + code_changes=[ + # Should show final code (CONTRACT_B), not CONTRACT_A + BalCodeChange( + block_access_index=1, + new_code=Spec7702.delegation_designation(contract_b), + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] + ), + relayer: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + # Neither contract appears in BAL during delegation setup + contract_a: None, + contract_b: None, + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account( + nonce=2, code=Spec7702.delegation_designation(contract_b) + ), + bob: Account(balance=10), + relayer: Account(nonce=1), + }, + ) + + +def test_bal_selfdestruct_to_7702_delegation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with SELFDESTRUCT to 7702 delegated account. + + Tx1: Alice delegates to Oracle. + Tx2: Victim (balance=100) selfdestructs to Alice. + SELFDESTRUCT transfers balance without executing recipient code. + + Expected BAL: + - Alice tx1: code_changes (delegation), nonce_changes + - Alice tx2: balance_changes (+100) + - Victim tx2: balance_changes (100โ†’0) + - Oracle: MUST NOT appear (SELFDESTRUCT doesn't execute recipient code) + """ + # Alice (EOA) will receive delegation then receive selfdestruct balance + # Use explicit initial balance for clarity + alice_initial_balance = 10**18 # 1 ETH default + alice = pre.fund_eoa(amount=alice_initial_balance) + bob = pre.fund_eoa(amount=0) # Just to be the recipient of tx + + # Oracle contract that Alice will delegate to + oracle = pre.deploy_contract(code=Op.SSTORE(0x01, 0x42) + Op.STOP) + + victim_balance = 100 + + # Victim contract that selfdestructs to Alice + victim = pre.deploy_contract( + code=Op.SELFDESTRUCT(alice), + balance=victim_balance, + ) + + # Relayer for tx1 (delegation) + relayer = pre.fund_eoa() + + # Tx1: Alice authorizes delegation to Oracle + tx1 = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=oracle, + nonce=0, + signer=alice, + ) + ], + ) + + # Caller contract that triggers selfdestruct on victim + caller = pre.deploy_contract(code=Op.CALL(100_000, victim, 0, 0, 0, 0, 0)) + + # Tx2: Trigger selfdestruct on victim (victim sends balance to Alice) + tx2 = Transaction( + nonce=1, + sender=relayer, + to=caller, + gas_limit=1_000_000, + gas_price=0xA, + ) + + alice_final_balance = alice_initial_balance + victim_balance + + account_expectations = { + alice: BalAccountExpectation( + # tx1: nonce change for auth, code change for delegation + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + # tx2: balance change from selfdestruct + balance_changes=[ + BalBalanceChange( + block_access_index=2, post_balance=alice_final_balance + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] + ), + relayer: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1), + BalNonceChange(block_access_index=2, post_nonce=2), + ], + ), + caller: BalAccountExpectation.empty(), + # Victim (selfdestructing contract): balance changes to 0 + # Explicitly verify ALL fields to avoid false positives + victim: BalAccountExpectation( + nonce_changes=[], # Contract nonce unchanged + balance_changes=[ + BalBalanceChange(block_access_index=2, post_balance=0) + ], + code_changes=[], # Code unchanged (post-Cancun SELFDESTRUCT) + storage_changes=[], # No storage changes + storage_reads=[], # No storage reads + ), + # Oracle MUST NOT appear in tx2 - SELFDESTRUCT doesn't execute + # recipient code, so delegation target is never accessed + oracle: None, + } + + block = Block( + txs=[tx1, tx2], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post = { + alice: Account( + nonce=1, + code=Spec7702.delegation_designation(oracle), + balance=alice_final_balance, + ), + bob: Account(balance=10), + relayer: Account(nonce=2), + # Victim still exists but with 0 balance (post-Cancun SELFDESTRUCT) + victim: Account(balance=0), + } + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) + + +GWEI = 10**9 + + +def test_bal_withdrawal_to_7702_delegation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with withdrawal to 7702 delegated account. + + Tx1: Alice delegates to Oracle. Withdrawal: 10 gwei to Alice. + Withdrawals credit balance without executing code. + + Expected BAL: + - Alice tx1: code_changes (delegation), nonce_changes + - Alice tx2: balance_changes (+10 gwei) + - Oracle: MUST NOT appear (withdrawals don't execute recipient code) + """ + # Alice (EOA) will receive delegation then receive withdrawal + alice_initial_balance = 10**18 # 1 ETH default + alice = pre.fund_eoa(amount=alice_initial_balance) + bob = pre.fund_eoa(amount=0) # Recipient of tx value + + # Oracle contract that Alice will delegate to + # If delegation were followed, this would write to storage + oracle = pre.deploy_contract(code=Op.SSTORE(0x01, 0x42) + Op.STOP) + + # Relayer for the delegation tx + relayer = pre.fund_eoa() + + withdrawal_amount_gwei = 10 + + # Tx1: Alice authorizes delegation to Oracle + tx1 = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=oracle, + nonce=0, + signer=alice, + ) + ], + ) + + alice_final_balance = alice_initial_balance + ( + withdrawal_amount_gwei * GWEI + ) + + account_expectations = { + alice: BalAccountExpectation( + # tx1: nonce change for auth, code change for delegation + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + # tx2 (withdrawal): balance change + balance_changes=[ + BalBalanceChange( + block_access_index=2, post_balance=alice_final_balance + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] + ), + relayer: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + # Oracle MUST NOT appear - withdrawals don't execute recipient code, + # so delegation target is never accessed + oracle: None, + } + + block = Block( + txs=[tx1], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=alice, + amount=withdrawal_amount_gwei, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post = { + alice: Account( + nonce=1, + code=Spec7702.delegation_designation(oracle), + balance=alice_final_balance, + ), + bob: Account(balance=10), + relayer: Account(nonce=1), + } + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py index d94f7eaed4..f819c64143 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py @@ -4,6 +4,8 @@ These tests verify that clients properly reject blocks with corrupted BALs. """ +from typing import Callable + import pytest from execution_testing import ( Account, @@ -11,6 +13,7 @@ BalAccountChange, BalAccountExpectation, BalBalanceChange, + BalCodeChange, BalNonceChange, BalStorageChange, BalStorageSlot, @@ -24,6 +27,8 @@ ) from execution_testing.test_types.block_access_list.modifiers import ( append_account, + append_change, + append_storage, duplicate_account, modify_balance, modify_nonce, @@ -32,7 +37,7 @@ remove_balances, remove_nonces, reverse_accounts, - swap_tx_indices, + swap_bal_indices, ) from .spec import ref_spec_7928 @@ -75,7 +80,9 @@ def test_bal_invalid_missing_nonce( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), } @@ -118,11 +125,13 @@ def test_bal_invalid_nonce_value( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), } - ).modify(modify_nonce(sender, tx_index=1, nonce=42)), + ).modify(modify_nonce(sender, block_access_index=1, nonce=42)), ) ], ) @@ -171,7 +180,8 @@ def test_bal_invalid_storage_value( slot=0x01, slot_changes=[ BalStorageChange( - tx_index=1, post_value=0x01 + block_access_index=1, + post_value=0x01, ) ], ), @@ -179,7 +189,8 @@ def test_bal_invalid_storage_value( slot=0x02, slot_changes=[ BalStorageChange( - tx_index=1, post_value=0x02 + block_access_index=1, + post_value=0x02, ) ], ), @@ -187,7 +198,8 @@ def test_bal_invalid_storage_value( slot=0x03, slot_changes=[ BalStorageChange( - tx_index=1, post_value=0x03 + block_access_index=1, + post_value=0x03, ) ], ), @@ -196,7 +208,9 @@ def test_bal_invalid_storage_value( } ).modify( # Corrupt storage value for slot 0x02 - modify_storage(contract, tx_index=1, slot=0x02, value=0xFF) + modify_storage( + contract, block_access_index=1, slot=0x02, value=0xFF + ) ), ) ], @@ -246,26 +260,31 @@ def test_bal_invalid_tx_order( account_expectations={ sender1: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), sender2: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=2, post_nonce=1) + BalNonceChange( + block_access_index=2, post_nonce=1 + ) ], ), receiver: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=10**15 + block_access_index=1, post_balance=10**15 ), BalBalanceChange( - tx_index=2, post_balance=3 * 10**15 + block_access_index=2, + post_balance=3 * 10**15, ), ], ), } - ).modify(swap_tx_indices(1, 2)), + ).modify(swap_bal_indices(1, 2)), ) ], ) @@ -307,7 +326,9 @@ def test_bal_invalid_account( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), } @@ -316,7 +337,9 @@ def test_bal_invalid_account( BalAccountChange( address=phantom, nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ) ) @@ -360,13 +383,15 @@ def test_bal_invalid_duplicate_account( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), receiver: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=10**15 + block_access_index=1, post_balance=10**15 ) ], ), @@ -410,13 +435,15 @@ def test_bal_invalid_account_order( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), receiver: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=10**15 + block_access_index=1, post_balance=10**15 ) ], ), @@ -471,8 +498,12 @@ def test_bal_invalid_complex_corruption( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=2, post_nonce=2), + BalNonceChange( + block_access_index=1, post_nonce=1 + ), + BalNonceChange( + block_access_index=2, post_nonce=2 + ), ], ), contract: BalAccountExpectation( @@ -481,7 +512,8 @@ def test_bal_invalid_complex_corruption( slot=0x01, slot_changes=[ BalStorageChange( - tx_index=1, post_value=0x01 + block_access_index=1, + post_value=0x01, ) ], ), @@ -489,7 +521,8 @@ def test_bal_invalid_complex_corruption( slot=0x02, slot_changes=[ BalStorageChange( - tx_index=1, post_value=0x02 + block_access_index=1, + post_value=0x02, ) ], ), @@ -498,7 +531,7 @@ def test_bal_invalid_complex_corruption( receiver: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=2, post_balance=10**15 + block_access_index=2, post_balance=10**15 ) ], ), @@ -506,10 +539,10 @@ def test_bal_invalid_complex_corruption( ).modify( remove_nonces(sender), modify_storage( - contract, tx_index=1, slot=0x01, value=0xFF + contract, block_access_index=1, slot=0x01, value=0xFF ), remove_balances(receiver), - swap_tx_indices(1, 2), + swap_bal_indices(1, 2), ), ) ], @@ -549,13 +582,15 @@ def test_bal_invalid_missing_account( account_expectations={ sender: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), receiver: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=10**15 + block_access_index=1, post_balance=10**15 ) ], ), @@ -600,12 +635,204 @@ def test_bal_invalid_balance_value( receiver: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=10**15 + block_access_index=1, post_balance=10**15 + ) + ], + ), + } + ).modify( + modify_balance( + receiver, block_access_index=1, balance=999999 + ) + ), + ) + ], + ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.exception_test +@pytest.mark.parametrize( + "modifier", + [ + pytest.param( + lambda idx, **actors: append_change( + account=actors["oracle"], + change=BalNonceChange(block_access_index=idx, post_nonce=999), + ), + id="extra_nonce", + ), + pytest.param( + lambda idx, **actors: append_account( + BalAccountChange( + address=actors["charlie"], + balance_changes=[ + BalBalanceChange( + block_access_index=idx, post_balance=999 + ) + ], + ) + ), + id="extra_balance", + ), + pytest.param( + lambda idx, **actors: append_change( + account=actors["oracle"], + change=BalCodeChange( + block_access_index=idx, new_code=b"Amsterdam" + ), + ), + id="extra_code", + ), + pytest.param( + lambda idx, **actors: append_storage( + address=actors["oracle"], + slot=0, + change=BalStorageChange( + block_access_index=idx, post_value=0xCAFE + ), + ), + id="extra_storage_write_touched", + ), + pytest.param( + lambda idx, **actors: append_storage( + address=actors["oracle"], + slot=1, + change=BalStorageChange( + block_access_index=idx, post_value=0xCAFE + ), + ), + id="extra_storage_write_untouched", + ), + pytest.param( + lambda idx, **actors: append_account( + BalAccountChange( + address=actors["charlie"], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=idx, + post_value=0xDEAD, + ) + ], + ) + ], + ) + ), + id="extra_storage_write_uninvolved_account", + ), + pytest.param( + lambda idx, **actors: append_account( # noqa: ARG005 + BalAccountChange( + address=actors["charlie"], + ) + ), + id="extra_account_access", + ), + pytest.param( + lambda idx, **actors: append_storage( # noqa: ARG005 + address=actors["oracle"], + slot=999, + read=True, + ), + id="extra_storage_read", + ), + ], +) +@pytest.mark.parametrize( + "bal_index", + [ + pytest.param(1, id="same_tx"), + pytest.param(2, id="system_tx"), + pytest.param(3, id="out_of_bounds"), + ], +) +def test_bal_invalid_extraneous_entries( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + modifier: Callable, + bal_index: int, +) -> None: + """ + Test that clients reject blocks where BAL contains extraneous entries. + + Alice sends 100 wei to Oracle (1 transaction). Oracle reads storage slot 0. + Charlie is uninvolved in this transaction. + A valid BAL is created containing nonce change for Alice, balance change + and storage read for Oracle which is further modified as: + + - extra_nonce: Extra nonce change for Oracle. + - extra_balance: Extra balance change for uninvolved Charlie. + - extra_code: Extra code change for Oracle. + - extra_storage_write_touched: Extra storage write for an already read slot + (slot 0) for Oracle. + - extra_storage_write_untouched: Extra storage write for an unread slot + (slot 1) for Oracle. + - extra_storage_write_uninvolved_account: Extra storage write for + uninvolved account (Charlie) that isn't accessed at all. + - extra_account_access: Uninvolved account (Charlie) added to BAL entirely. + - extra_storage_read: Extra storage read for Oracle (slot 999). + + BAL is corrupted with extraneous entries at various block_access_index + values: + - bal_index=1: current transaction + - bal_index=2: system transaction (tx_count + 1) + - bal_index=3: beyond system transaction (tx_count + 2) + """ + transfer_value = 100 + + alice = pre.fund_eoa() + oracle = pre.deploy_contract(code=Op.SLOAD(0), storage={0: 42}) + charlie = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=oracle, + value=transfer_value, + gas_limit=1_000_000, + ) + + blockchain_test( + pre=pre, + # The block reverts and the post state remains unchanged. + post=pre, + blocks=[ + Block( + txs=[tx], + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + expected_block_access_list=BlockAccessListExpectation( + # Valid BAL expectation: nonce change for Alice, + # balance change and storage read for Oracle. + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=1 + ) + ], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=transfer_value, ) ], + storage_reads=[0], ), } - ).modify(modify_balance(receiver, tx_index=1, balance=999999)), + ).modify( + # The parameterized modifier is applied to the BAL + # which adds an extraneous entry. + modifier( + idx=bal_index, + alice=alice, + oracle=oracle, + charlie=charlie, + ) + ), ) ], ) 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 5e5ebaefe3..c0e8886d35 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 @@ -15,12 +15,17 @@ """ from enum import Enum +from typing import Callable, Dict import pytest from execution_testing import ( + AccessList, Account, + Address, Alloc, BalAccountExpectation, + BalBalanceChange, + BalNonceChange, BalStorageChange, BalStorageSlot, Block, @@ -28,8 +33,10 @@ BlockchainTestFiller, Bytecode, Fork, + Initcode, Op, Transaction, + compute_create_address, ) from .spec import ref_spec_7928 @@ -51,6 +58,36 @@ class OutOfGasAt(Enum): EXACT_GAS_MINUS_1 = "oog_at_exact_gas_minus_1" +class OutOfGasBoundary(Enum): + """ + OOG boundary scenarios for call-type opcodes with 7702 delegation. + + For 7702 targets, there's ALWAYS a gap between static gas check and + second check (delegation_cost). All 4 scenarios test + distinct boundaries. + + Gas check order: + 1. oog_before_target_access: access + transfer (if applicable) + memory. + OOG with not enough for this check - no state access. + 2. oog_after_target_access: only enough for static check, state access + reads target into BAL, not enough for anything else. + 3. oog_success_minus_1: exact gas minus 1. OOG here means target is in + BAL, but we have enough information to calculate delegation cost + AND the message call gas and not read if we don't have enough for + both - delegation target NOT in BAL. + 4. success: target and delegation target both in BAL. + + OOG_SUCCESS_MINUS_1 tests that even when we have enough for delegation + access cost, if we don't have enough for the total (missing subcall_gas), + we don't read the delegation. + """ + + OOG_BEFORE_TARGET_ACCESS = "oog_before_target_access" + OOG_AFTER_TARGET_ACCESS = "oog_after_target_access" + OOG_SUCCESS_MINUS_1 = "oog_success_minus_1" + SUCCESS = "success" + + @pytest.mark.parametrize( "out_of_gas_at", [ @@ -131,7 +168,9 @@ def test_bal_sstore_and_oog( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ), ] @@ -369,240 +408,2323 @@ def test_bal_extcodesize_and_oog( @pytest.mark.parametrize( - "fails_at_call", [True, False], ids=["oog_at_call", "successful_call"] + "oog_boundary", + [OutOfGasBoundary.SUCCESS, OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS], + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "target_is_empty", [False, True], ids=["existing_target", "empty_target"] +) +@pytest.mark.parametrize("value", [0, 1], ids=["no_value", "with_value"]) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] ) -def test_bal_call_and_oog( +def test_bal_call_no_delegation_and_oog_before_target_access( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, - fails_at_call: bool, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + target_is_empty: bool, + value: int, + memory_expansion: bool, ) -> None: - """Ensure BAL handles CALL and OOG during CALL appropriately.""" - alice = pre.fund_eoa() - bob = pre.fund_eoa() + """ + CALL without 7702 delegation - test SUCCESS and OOG before target access. + + When target_is_warm=True, we use EIP-2930 tx access list to warm the + target. Access list warming does NOT add to BAL - only EVM access does. + """ gas_costs = fork.gas_costs() + alice = pre.fund_eoa() - # Create contract that attempts to call Bob - call_contract_code = Bytecode( - Op.PUSH1(0) # retSize - + Op.PUSH1(0) # retOffset - + Op.PUSH1(0) # argsSize - + Op.PUSH1(0) # argsOffset - + Op.PUSH1(0) # value - + Op.PUSH20(bob) # address - + Op.PUSH2(0xFFFF) # gas (provide enough for the call) - + Op.CALL # Call (cold account access) - + Op.STOP + target = ( + pre.empty_account() + if target_is_empty + else pre.deploy_contract(code=Op.STOP) ) - call_contract = pre.deploy_contract(code=call_contract_code) + ret_size = 32 if memory_expansion else 0 - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + call_code = Op.CALL( + gas=0, address=target, value=value, ret_size=ret_size, ret_offset=0 + ) + caller = pre.deploy_contract(code=call_code, balance=value) - # Costs: - # - 7 PUSH operations = G_VERY_LOW * 7 - # - CALL cold = G_COLD_ACCOUNT_ACCESS (minimum for account access) - push_cost = gas_costs.G_VERY_LOW * 7 - call_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS - tx_gas_limit = intrinsic_gas_cost + push_cost + call_cold_cost - - if fails_at_call: - # subtract 1 gas to ensure OOG at CALL - tx_gas_limit -= 1 + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + bytecode_cost = gas_costs.G_VERY_LOW * 7 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + transfer_cost = gas_costs.G_CALL_VALUE if value > 0 else 0 + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + + # Create cost: only if value > 0 AND target is empty + create_cost = ( + gas_costs.G_NEW_ACCOUNT if (value > 0 and target_is_empty) else 0 + ) + + # static gas (before state access): access + transfer + memory + static_gas_cost = access_cost + transfer_cost + memory_cost + # second check includes create_cost + second_check_cost = static_gas_cost + create_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + else: # SUCCESS + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost tx = Transaction( sender=alice, - to=call_contract, - gas_limit=tx_gas_limit, + to=caller, + gas_limit=gas_limit, + access_list=access_list, ) - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - call_contract: BalAccountExpectation.empty(), - # Bob should only appear if CALL succeeded - **( - {bob: None} - if fails_at_call - else {bob: BalAccountExpectation.empty()} + # BAL expectations + account_expectations: Dict[Address, BalAccountExpectation | None] + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + # Target NOT in BAL - we OOG before state access + account_expectations = { + caller: BalAccountExpectation.empty(), + target: None, + } + elif value > 0: + account_expectations = { + caller: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ] + ), + target: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=value) + ] + ), + } + else: + account_expectations = { + caller: BalAccountExpectation.empty(), + target: BalAccountExpectation.empty(), + } + + value_transferred = value > 0 and oog_boundary == OutOfGasBoundary.SUCCESS + + post_state: Dict[Address, Account | None] = {alice: Account(nonce=1)} + + if value_transferred: + post_state[target] = Account(balance=value) + post_state[caller] = Account(balance=0) + else: + post_state[caller] = Account(balance=value) + post_state[target] = ( + Account.NONEXISTENT + if target_is_empty + else Account(balance=0, code=Op.STOP) + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations ), - } - ), + ) + ], + post=post_state, + ) + + +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_call_no_delegation_oog_after_target_access( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + target_is_warm: bool, + memory_expansion: bool, +) -> None: + """ + CALL without 7702 delegation - OOG after state access. + + When target_is_warm=True, uses EIP-2930 tx access list to warm the target. + Access list warming does NOT add targets to BAL - only EVM access does. + + This test is only meaningful when there's a gap between gas check before + state access and after state access. This only happens if create cost + (empty target) and value transfer cost are both non-zero. + + Note: + - target is always empty - required for create cost + - value=1 (greater than 0) - required for create cost + + The create_cost (G_NEW_ACCOUNT = 25000) is charged only for value transfers + to empty accounts, creating the gap tested here. + + """ + gas_costs = fork.gas_costs() + alice = pre.fund_eoa() + + # empty target required for create_cost gap + target = pre.empty_account() + # value > 0 required for create_cost + value = 1 + + # memory expansion / no expansion + ret_size = 32 if memory_expansion else 0 + + # caller contract - no warmup code, we use tx access list instead + call_code = Op.CALL( + gas=0, address=target, value=value, ret_size=ret_size, ret_offset=0 + ) + caller = pre.deploy_contract(code=call_code, balance=value) + + # Access list for warming target (if needed) + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list ) + # Bytecode cost: 7 pushes for Op.CALL (no warmup code) + bytecode_cost = gas_costs.G_VERY_LOW * 7 + + # Access cost for CALL - warm if in tx access list + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + transfer_cost = gas_costs.G_CALL_VALUE # value > 0, so always charged + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + + # static gas cost (before state access): access + transfer + memory + static_gas_cost = access_cost + transfer_cost + memory_cost + + # Pass static check, fail at second check due to create cost + # (create_cost = G_NEW_ACCOUNT = 25000 for empty target + value > 0) + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=gas_limit, + access_list=access_list, + ) + + # Target is always in BAL after state access but value transfer fails + # (no balance changes) + account_expectations: Dict[Address, BalAccountExpectation | None] = { + caller: BalAccountExpectation.empty(), + target: BalAccountExpectation.empty(), + } + + post_state = { + alice: Account(nonce=1), + caller: Account(balance=value), + target: Account.NONEXISTENT, + } + blockchain_test( pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - call_contract: Account(), - }, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post=post_state, ) @pytest.mark.parametrize( - "fails_at_delegatecall", - [True, False], - ids=["oog_at_delegatecall", "successful_delegatecall"], + "oog_boundary", + list(OutOfGasBoundary), + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "delegation_is_warm", + [False, True], + ids=["cold_delegation", "warm_delegation"], +) +@pytest.mark.parametrize("value", [0, 1], ids=["no_value", "with_value"]) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] ) -def test_bal_delegatecall_and_oog( +def test_bal_call_7702_delegation_and_oog( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, - fails_at_delegatecall: bool, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + delegation_is_warm: bool, + value: int, + memory_expansion: bool, ) -> None: """ - Ensure BAL handles DELEGATECALL and OOG during DELEGATECALL - appropriately. + CALL with 7702 delegation - test all OOG boundaries. + + When target_is_warm or delegation_is_warm, we use EIP-2930 tx access list. + Access list warming does NOT add targets to BAL - only EVM access does. """ - alice = pre.fund_eoa() gas_costs = fork.gas_costs() + alice = pre.fund_eoa() - # Create target contract - target_contract = pre.deploy_contract(code=Bytecode(Op.STOP)) + delegation_target = pre.deploy_contract(code=Op.STOP) + target = pre.fund_eoa(amount=0, delegation=delegation_target) - # Create contract that attempts delegatecall to target - delegatecall_contract_code = Bytecode( - Op.PUSH1(0) # retSize - + Op.PUSH1(0) # retOffset - + Op.PUSH1(0) # argsSize - + Op.PUSH1(0) # argsOffset - + Op.PUSH20(target_contract) # address - + Op.PUSH2(0xFFFF) # gas (provide enough for the call) - + Op.DELEGATECALL # Delegatecall (cold account access) - + Op.STOP + # memory expansion / no expansion + ret_size = 32 if memory_expansion else 0 + + call_code = Op.CALL( + gas=0, + address=target, + value=value, + ret_size=ret_size, + ret_offset=0, + ) + caller = pre.deploy_contract(code=call_code, balance=value) + + # Build access list for warming + access_list: list[AccessList] = [] + if target_is_warm: + access_list.append(AccessList(address=target, storage_keys=[])) + if delegation_is_warm: + access_list.append( + AccessList(address=delegation_target, storage_keys=[]) + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list ) - delegatecall_contract = pre.deploy_contract( - code=delegatecall_contract_code + bytecode_cost = gas_costs.G_VERY_LOW * 7 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + transfer_cost = gas_costs.G_CALL_VALUE if value > 0 else 0 + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + delegation_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if delegation_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS ) - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + static_gas_cost = access_cost + transfer_cost + memory_cost - # Costs: - # - 6 PUSH operations = G_VERY_LOW * 6 - # - DELEGATECALL cold = G_COLD_ACCOUNT_ACCESS - push_cost = gas_costs.G_VERY_LOW * 6 - delegatecall_cold_cost = gas_costs.G_COLD_ACCOUNT_ACCESS - tx_gas_limit = intrinsic_gas_cost + push_cost + delegatecall_cold_cost - - if fails_at_delegatecall: - # subtract 1 gas to ensure OOG at DELEGATECALL - tx_gas_limit -= 1 + # The EVM's second check cost is static_gas + delegation_cost. + second_check_cost = static_gas_cost + delegation_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: + # Enough for static_gas only - not enough for delegation_cost + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + elif oog_boundary == OutOfGasBoundary.OOG_SUCCESS_MINUS_1: + # One less than second_check_cost - not enough for full call + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost - 1 + else: + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost tx = Transaction( sender=alice, - to=delegatecall_contract, - gas_limit=tx_gas_limit, + to=caller, + gas_limit=gas_limit, + access_list=access_list, ) - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - delegatecall_contract: BalAccountExpectation.empty(), - # Target should only appear if DELEGATECALL succeeded - **( - {target_contract: None} - if fails_at_delegatecall - else {target_contract: BalAccountExpectation.empty()} - ), - } + # Access list warming does NOT add to BAL - only EVM execution does + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + target_in_bal = False + delegation_in_bal = False + elif oog_boundary in ( + OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS, + OutOfGasBoundary.OOG_SUCCESS_MINUS_1, + ): + # Both cases: target accessed but not enough gas for full call + # so delegation is NOT read (static check optimization) + target_in_bal = True + delegation_in_bal = False + else: + target_in_bal = True + delegation_in_bal = True + + value_transferred = value > 0 and oog_boundary == OutOfGasBoundary.SUCCESS + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + caller: ( + BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ] + ) + if value_transferred + else BalAccountExpectation.empty() ), - ) + delegation_target: ( + BalAccountExpectation.empty() if delegation_in_bal else None + ), + } + + if target_in_bal: + if value_transferred: + account_expectations[target] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=value) + ] + ) + else: + account_expectations[target] = BalAccountExpectation.empty() + else: + account_expectations[target] = None + + # Post-state balance checks verify value transfer only happened on success + post_state: Dict[Address, Account] = {alice: Account(nonce=1)} + if value > 0: + post_state[target] = Account(balance=value if value_transferred else 0) + post_state[caller] = Account(balance=0 if value_transferred else value) blockchain_test( pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - delegatecall_contract: Account(), - target_contract: Account(), - }, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post=post_state, ) @pytest.mark.parametrize( - "fails_at_extcodecopy", - [True, False], - ids=["oog_at_extcodecopy", "successful_extcodecopy"], + "oog_boundary", + [OutOfGasBoundary.SUCCESS, OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS], + ids=lambda x: x.value, ) -def test_bal_extcodecopy_and_oog( +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_delegatecall_no_delegation_and_oog_before_target_access( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, - fails_at_extcodecopy: bool, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + memory_expansion: bool, ) -> None: """ - Ensure BAL handles EXTCODECOPY and OOG during EXTCODECOPY appropriately. + DELEGATECALL without 7702 delegation - test SUCCESS and OOG boundaries. + + When target_is_warm=True, we use EIP-2930 tx access list to warm the + target. Access list warming does NOT add to BAL - only EVM access does. """ alice = pre.fund_eoa() gas_costs = fork.gas_costs() - # Create target contract with some code - target_contract = pre.deploy_contract( - code=Bytecode(Op.PUSH1(0x42) + Op.STOP) + target = pre.deploy_contract(code=Op.STOP) + + ret_size = 32 if memory_expansion else 0 + ret_offset = 0 + + delegatecall_code = Op.DELEGATECALL( + address=target, + gas=0, + ret_size=ret_size, + ret_offset=ret_offset, ) - # Create contract that attempts to copy code from target - extcodecopy_contract_code = Bytecode( - Op.PUSH1(0) # size - copy 0 bytes to minimize memory expansion cost - + Op.PUSH1(0) # codeOffset - + Op.PUSH1(0) # destOffset - + Op.PUSH20(target_contract) # address - + Op.EXTCODECOPY # Copy code (cold access + base cost) - + Op.STOP + caller = pre.deploy_contract(code=delegatecall_code) + + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None ) - extcodecopy_contract = pre.deploy_contract(code=extcodecopy_contract_code) + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + # 6 pushes: retSize, retOffset, argsSize, argsOffset, address, gas + bytecode_cost = gas_costs.G_VERY_LOW * 6 - # Costs: - # - 4 PUSH operations = G_VERY_LOW * 4 - # - EXTCODECOPY cold = G_COLD_ACCOUNT_ACCESS + (G_COPY * words) - # where words = ceil32(size) // 32 = ceil32(0) // 32 = 0 - push_cost = gas_costs.G_VERY_LOW * 4 - extcodecopy_cold_cost = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - ) # + (G_COPY * 0) = 0 - tx_gas_limit = intrinsic_gas_cost + push_cost + extcodecopy_cold_cost + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) - if fails_at_extcodecopy: - # subtract 1 gas to ensure OOG at EXTCODECOPY - tx_gas_limit -= 1 + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + + # static gas (before state access) == second check (no delegation cost) + static_gas_cost = access_cost + memory_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + else: # SUCCESS + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost tx = Transaction( sender=alice, - to=extcodecopy_contract, - gas_limit=tx_gas_limit, + to=caller, + gas_limit=gas_limit, + access_list=access_list, ) - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - extcodecopy_contract: BalAccountExpectation.empty(), - # Target should only appear if EXTCODECOPY succeeded - **( - {target_contract: None} - if fails_at_extcodecopy - else {target_contract: BalAccountExpectation.empty()} + # BAL expectations + account_expectations: Dict[Address, BalAccountExpectation | None] + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + # Target NOT in BAL - we OOG before state access + account_expectations = { + caller: BalAccountExpectation.empty(), + target: None, + } + else: # SUCCESS - target in BAL + account_expectations = { + caller: BalAccountExpectation.empty(), + target: BalAccountExpectation.empty(), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations ), - } - ), + ) + ], + post={alice: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "oog_boundary", + list(OutOfGasBoundary), + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "delegation_is_warm", + [False, True], + ids=["cold_delegation", "warm_delegation"], +) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_delegatecall_7702_delegation_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + delegation_is_warm: bool, + memory_expansion: bool, +) -> None: + """ + DELEGATECALL with 7702 delegation - test all OOG boundaries. + + When target_is_warm or delegation_is_warm, we use EIP-2930 tx access list. + Access list warming does NOT add targets to BAL - only EVM access does. + + For 7702 delegation, there's ALWAYS a gap between static gas and + second check (delegation_cost) - all 3 scenarios produce distinct + behaviors. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + delegation_target = pre.deploy_contract(code=Op.STOP) + target = pre.fund_eoa(amount=0, delegation=delegation_target) + + # memory expansion / no expansion + ret_size = 32 if memory_expansion else 0 + ret_offset = 0 + + delegatecall_code = Op.DELEGATECALL( + gas=0, + address=target, + ret_size=ret_size, + ret_offset=ret_offset, + ) + + caller = pre.deploy_contract(code=delegatecall_code) + + # Build access list for warming + access_list: list[AccessList] = [] + if target_is_warm: + access_list.append(AccessList(address=target, storage_keys=[])) + if delegation_is_warm: + access_list.append( + AccessList(address=delegation_target, storage_keys=[]) + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + bytecode_cost = gas_costs.G_VERY_LOW * 6 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + delegation_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if delegation_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + + static_gas_cost = access_cost + memory_cost + + # The EVM's second check cost is static_gas + delegation_cost. + second_check_cost = static_gas_cost + delegation_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: + # Enough for static_gas only - not enough for delegation_cost + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + elif oog_boundary == OutOfGasBoundary.OOG_SUCCESS_MINUS_1: + # One less than second_check_cost - not enough for full call + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost - 1 + else: + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=gas_limit, + access_list=access_list, ) + # Access list warming does NOT add to BAL - only EVM execution does + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + target_in_bal = False + delegation_in_bal = False + elif oog_boundary in ( + OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS, + OutOfGasBoundary.OOG_SUCCESS_MINUS_1, + ): + # Both cases: target accessed but not enough gas for full call + # so delegation is NOT read (static check optimization) + target_in_bal = True + delegation_in_bal = False + else: + target_in_bal = True + delegation_in_bal = True + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + caller: BalAccountExpectation.empty(), + delegation_target: ( + BalAccountExpectation.empty() if delegation_in_bal else None + ), + } + + if target_in_bal: + account_expectations[target] = BalAccountExpectation.empty() + else: + account_expectations[target] = None + blockchain_test( pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - extcodecopy_contract: Account(), - target_contract: Account(), + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={alice: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "oog_boundary", + [OutOfGasBoundary.SUCCESS, OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS], + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize("value", [0, 1], ids=["no_value", "with_value"]) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_callcode_no_delegation_and_oog_before_target_access( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + value: int, + memory_expansion: bool, +) -> None: + """ + CALLCODE without 7702 delegation - test SUCCESS and OOG boundaries. + + When target_is_warm=True, we use EIP-2930 tx access list to warm the + target. Access list warming does NOT add to BAL - only EVM access does. + CALLCODE has no balance transfer to target (runs in caller's context). + """ + gas_costs = fork.gas_costs() + alice = pre.fund_eoa() + + target = pre.deploy_contract(code=Op.STOP) + + ret_size = 32 if memory_expansion else 0 + + callcode_code = Op.CALLCODE( + gas=0, address=target, value=value, ret_size=ret_size, ret_offset=0 + ) + caller = pre.deploy_contract(code=callcode_code, balance=value) + + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + bytecode_cost = gas_costs.G_VERY_LOW * 7 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + transfer_cost = gas_costs.G_CALL_VALUE if value > 0 else 0 + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + + # static gas: access + transfer + memory (== second check, no delegation) + static_gas_cost = access_cost + transfer_cost + memory_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + else: # SUCCESS + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=gas_limit, + access_list=access_list, + ) + + # BAL expectations + account_expectations: Dict[Address, BalAccountExpectation | None] + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + # Target NOT in BAL - we OOG before state access + account_expectations = { + caller: BalAccountExpectation.empty(), + target: None, + } + else: # SUCCESS - target in BAL (no balance changes, CALLCODE no transfer) + account_expectations = { + caller: BalAccountExpectation.empty(), + target: BalAccountExpectation.empty(), + } + + # Post-state: CALLCODE runs in caller's context, so value transfer is + # caller-to-caller (net-zero). Caller keeps its balance regardless. + post_state: Dict[Address, Account] = { + alice: Account(nonce=1), + caller: Account(balance=value), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post=post_state, + ) + + +@pytest.mark.parametrize( + "oog_boundary", + list(OutOfGasBoundary), + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "delegation_is_warm", + [False, True], + ids=["cold_delegation", "warm_delegation"], +) +@pytest.mark.parametrize("value", [0, 1], ids=["no_value", "with_value"]) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_callcode_7702_delegation_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + delegation_is_warm: bool, + value: int, + memory_expansion: bool, +) -> None: + """ + CALLCODE with 7702 delegation - test all OOG boundaries. + + When target_is_warm or delegation_is_warm, we use EIP-2930 tx access list. + Access list warming does NOT add targets to BAL - only EVM access does. + + For 7702 delegation, there's ALWAYS a gap between static gas and + second check (delegation_cost) - all 3 scenarios produce distinct + behaviors. + """ + gas_costs = fork.gas_costs() + alice = pre.fund_eoa() + + delegation_target = pre.deploy_contract(code=Op.STOP) + target = pre.fund_eoa(amount=0, delegation=delegation_target) + + # memory expansion / no expansion + ret_size = 32 if memory_expansion else 0 + + callcode_code = Op.CALLCODE( + gas=0, + address=target, + value=value, + ret_size=ret_size, + ret_offset=0, + ) + caller = pre.deploy_contract(code=callcode_code, balance=value) + + # Build access list for warming + access_list: list[AccessList] = [] + if target_is_warm: + access_list.append(AccessList(address=target, storage_keys=[])) + if delegation_is_warm: + access_list.append( + AccessList(address=delegation_target, storage_keys=[]) + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + bytecode_cost = gas_costs.G_VERY_LOW * 7 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + transfer_cost = gas_costs.G_CALL_VALUE if value > 0 else 0 + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + delegation_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if delegation_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + + static_gas_cost = access_cost + transfer_cost + memory_cost + + # The EVM's second check cost is static_gas + delegation_cost. + second_check_cost = static_gas_cost + delegation_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: + # Enough for static_gas only - not enough for delegation_cost + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + elif oog_boundary == OutOfGasBoundary.OOG_SUCCESS_MINUS_1: + # One less than second_check_cost - not enough for full call + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost - 1 + else: + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=gas_limit, + access_list=access_list, + ) + + # Access list warming does NOT add to BAL - only EVM execution does + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + target_in_bal = False + delegation_in_bal = False + elif oog_boundary in ( + OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS, + OutOfGasBoundary.OOG_SUCCESS_MINUS_1, + ): + # Both cases: target accessed but not enough gas for full call + # so delegation is NOT read (static check optimization) + target_in_bal = True + delegation_in_bal = False + else: + target_in_bal = True + delegation_in_bal = True + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + caller: BalAccountExpectation.empty(), + delegation_target: ( + BalAccountExpectation.empty() if delegation_in_bal else None + ), + } + + if target_in_bal: + account_expectations[target] = BalAccountExpectation.empty() + else: + account_expectations[target] = None + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={alice: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "oog_boundary", + [OutOfGasBoundary.SUCCESS, OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS], + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_staticcall_no_delegation_and_oog_before_target_access( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + memory_expansion: bool, +) -> None: + """ + STATICCALL without 7702 delegation - test SUCCESS and OOG boundaries. + + When target_is_warm=True, we use EIP-2930 tx access list to warm the + target. Access list warming does NOT add to BAL - only EVM access does. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + target = pre.deploy_contract(code=Op.STOP) + + ret_size = 32 if memory_expansion else 0 + ret_offset = 0 + + staticcall_code = Op.STATICCALL( + address=target, + gas=0, + ret_size=ret_size, + ret_offset=ret_offset, + ) + + caller = pre.deploy_contract(code=staticcall_code) + + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + # 6 pushes: retSize, retOffset, argsSize, argsOffset, address, gas + bytecode_cost = gas_costs.G_VERY_LOW * 6 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + + # static gas (before state access) == second check (no delegation cost) + static_gas_cost = access_cost + memory_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + else: # SUCCESS + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=gas_limit, + access_list=access_list, + ) + + # BAL expectations + account_expectations: Dict[Address, BalAccountExpectation | None] + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + # Target NOT in BAL - we OOG before state access + account_expectations = { + caller: BalAccountExpectation.empty(), + target: None, + } + else: # SUCCESS - target in BAL + account_expectations = { + caller: BalAccountExpectation.empty(), + target: BalAccountExpectation.empty(), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={alice: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "oog_boundary", + list(OutOfGasBoundary), + ids=lambda x: x.value, +) +@pytest.mark.parametrize( + "target_is_warm", [False, True], ids=["cold_target", "warm_target"] +) +@pytest.mark.parametrize( + "delegation_is_warm", + [False, True], + ids=["cold_delegation", "warm_delegation"], +) +@pytest.mark.parametrize( + "memory_expansion", [False, True], ids=["no_memory", "with_memory"] +) +def test_bal_staticcall_7702_delegation_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + delegation_is_warm: bool, + memory_expansion: bool, +) -> None: + """ + STATICCALL with 7702 delegation - test all OOG boundaries. + + When target_is_warm or delegation_is_warm, we use EIP-2930 tx access list. + Access list warming does NOT add targets to BAL - only EVM access does. + + For 7702 delegation, there's ALWAYS a gap between static gas and + second check (delegation_cost) - all 3 scenarios produce distinct + behaviors. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + delegation_target = pre.deploy_contract(code=Op.STOP) + target = pre.fund_eoa(amount=0, delegation=delegation_target) + + # memory expansion / no expansion + ret_size = 32 if memory_expansion else 0 + ret_offset = 0 + + staticcall_code = Op.STATICCALL( + gas=0, + address=target, + ret_size=ret_size, + ret_offset=ret_offset, + ) + + caller = pre.deploy_contract(code=staticcall_code) + + # Build access list for warming + access_list: list[AccessList] = [] + if target_is_warm: + access_list.append(AccessList(address=target, storage_keys=[])) + if delegation_is_warm: + access_list.append( + AccessList(address=delegation_target, storage_keys=[]) + ) + + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) + + bytecode_cost = gas_costs.G_VERY_LOW * 6 + + access_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if target_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + memory_cost = fork.memory_expansion_gas_calculator()(new_bytes=ret_size) + delegation_cost = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + if delegation_is_warm + else gas_costs.G_COLD_ACCOUNT_ACCESS + ) + + static_gas_cost = access_cost + memory_cost + + # The EVM's second check cost is static_gas + delegation_cost + second_check_cost = static_gas_cost + delegation_cost + + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost - 1 + elif oog_boundary == OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS: + # Enough for static_gas only - not enough for delegation_cost + gas_limit = intrinsic_cost + bytecode_cost + static_gas_cost + elif oog_boundary == OutOfGasBoundary.OOG_SUCCESS_MINUS_1: + # One less than second_check_cost - not enough for full call + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost - 1 + else: + gas_limit = intrinsic_cost + bytecode_cost + second_check_cost + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=gas_limit, + access_list=access_list, + ) + + # Access list warming does NOT add to BAL - only EVM execution does + if oog_boundary == OutOfGasBoundary.OOG_BEFORE_TARGET_ACCESS: + target_in_bal = False + delegation_in_bal = False + elif oog_boundary in ( + OutOfGasBoundary.OOG_AFTER_TARGET_ACCESS, + OutOfGasBoundary.OOG_SUCCESS_MINUS_1, + ): + # Both cases: target accessed but not enough gas for full call + # so delegation is NOT read (static check optimization) + target_in_bal = True + delegation_in_bal = False + else: + target_in_bal = True + delegation_in_bal = True + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + caller: BalAccountExpectation.empty(), + delegation_target: ( + BalAccountExpectation.empty() if delegation_in_bal else None + ), + } + + if target_in_bal: + account_expectations[target] = BalAccountExpectation.empty() + else: + account_expectations[target] = None + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={alice: Account(nonce=1)}, + ) + + +@pytest.mark.parametrize( + "oog_scenario,memory_offset,copy_size", + [ + pytest.param("success", 0, 0, id="successful_extcodecopy"), + pytest.param("oog_at_cold_access", 0, 0, id="oog_at_cold_access"), + pytest.param( + "oog_at_memory_large_offset", + 0x10000, + 32, + id="oog_at_memory_large_offset", + ), + pytest.param( + "oog_at_memory_boundary", + 256, + 32, + id="oog_at_memory_boundary", + ), + ], +) +def test_bal_extcodecopy_and_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_scenario: str, + memory_offset: int, + copy_size: int, +) -> None: + """ + Ensure BAL handles EXTCODECOPY and OOG during EXTCODECOPY appropriately. + + Tests various OOG scenarios: + - success: EXTCODECOPY completes, target appears in BAL + - oog_at_cold_access: OOG before cold access, target NOT in BAL + - oog_at_memory_large_offset: OOG at memory expansion (large offset), + target NOT in BAL + - oog_at_memory_boundary: OOG at memory expansion (boundary case), + target NOT in BAL + + Gas for all components (cold access + copy + memory expansion) must be + checked BEFORE recording account access. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + # Create target contract with some code + target_contract = pre.deploy_contract( + code=Bytecode(Op.PUSH1(0x42) + Op.STOP) + ) + + # Build EXTCODECOPY contract with appropriate PUSH sizes + if memory_offset <= 0xFF: + dest_push = Op.PUSH1(memory_offset) + elif memory_offset <= 0xFFFF: + dest_push = Op.PUSH2(memory_offset) + else: + dest_push = Op.PUSH3(memory_offset) + + extcodecopy_contract_code = Bytecode( + Op.PUSH1(copy_size) + + Op.PUSH1(0) # codeOffset + + dest_push # destOffset + + Op.PUSH20(target_contract) + + Op.EXTCODECOPY + + Op.STOP + ) + + extcodecopy_contract = pre.deploy_contract(code=extcodecopy_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + # Calculate costs + push_cost = gas_costs.G_VERY_LOW * 4 + cold_access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + copy_cost = gas_costs.G_COPY * ((copy_size + 31) // 32) + + if oog_scenario == "success": + # Provide enough gas for everything including memory expansion + memory_cost = fork.memory_expansion_gas_calculator()( + new_bytes=memory_offset + copy_size + ) + execution_cost = push_cost + cold_access_cost + copy_cost + memory_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost + target_in_bal = True + elif oog_scenario == "oog_at_cold_access": + # Provide gas for pushes but 1 less than cold access cost + execution_cost = push_cost + cold_access_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost - 1 + target_in_bal = False + elif oog_scenario == "oog_at_memory_large_offset": + # Provide gas for push + cold access + copy, but NOT memory expansion + execution_cost = push_cost + cold_access_cost + copy_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost + target_in_bal = False + elif oog_scenario == "oog_at_memory_boundary": + # Calculate memory cost and provide exactly 1 less than needed + memory_cost = fork.memory_expansion_gas_calculator()( + new_bytes=memory_offset + copy_size + ) + execution_cost = push_cost + cold_access_cost + copy_cost + memory_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost - 1 + target_in_bal = False + else: + raise ValueError(f"Invariant: unknown oog_scenario {oog_scenario}") + + tx = Transaction( + sender=alice, + to=extcodecopy_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + extcodecopy_contract: BalAccountExpectation.empty(), + **( + {target_contract: BalAccountExpectation.empty()} + if target_in_bal + else {target_contract: None} + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + extcodecopy_contract: Account(), + target_contract: Account(), + }, + ) + + +def test_bal_storage_write_read_same_frame( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures write precedence over read in same call frame. + + Oracle writes to slot 0x01, then reads from slot 0x01 in same call. + The write shadows the read - only the write appears in BAL. + """ + alice = pre.fund_eoa() + + oracle_code = ( + Op.SSTORE(0x01, 0x42) # Write 0x42 to slot 0x01 + + Op.SLOAD(0x01) # Read from slot 0x01 + + Op.STOP + ) + oracle = pre.deploy_contract(code=oracle_code, storage={0x01: 0x99}) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x42 + ) + ], + ) + ], + storage_reads=[], # Empty! Write shadows the read + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(storage={0x01: 0x42}), + }, + ) + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param( + lambda target: Op.CALL(100_000, target, 0, 0, 0, 0, 0), id="call" + ), + pytest.param( + lambda target: Op.DELEGATECALL(100_000, target, 0, 0, 0, 0), + id="delegatecall", + ), + pytest.param( + lambda target: Op.CALLCODE(100_000, target, 0, 0, 0, 0, 0), + id="callcode", + ), + ], +) +def test_bal_storage_write_read_cross_frame( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Callable[[Bytecode], Bytecode], +) -> None: + """ + Ensure BAL captures write precedence over read across call frames. + + Frame 1: Read slot 0x01 (0x99), write 0x42, then call itself. + Frame 2: Read slot 0x01 (0x42), see it's 0x42 and return. + Both reads are shadowed by the write - only write appears in BAL. + """ + alice = pre.fund_eoa() + + # Oracle code: + # 1. Read slot 0x01 (initial: 0x99, recursive: 0x42) + # 2. If value == 0x42, return (exit recursion) + # 3. Write 0x42 to slot 0x01 + # 4. Call itself recursively + oracle_code = ( + Op.SLOAD(0x01) # Load value from slot 0x01 + + Op.PUSH1(0x42) # Push 0x42 for comparison + + Op.EQ # Check if loaded value == 0x42 + + Op.PUSH1(0x1D) # Jump destination (after SSTORE + CALL) + + Op.JUMPI # If equal, jump to end (exit recursion) + + Op.PUSH1(0x42) # Value to write + + Op.PUSH1(0x01) # Slot 0x01 + + Op.SSTORE # Write 0x42 to slot 0x01 + + call_opcode(Op.ADDRESS) # Call itself + + Op.JUMPDEST # Jump destination for exit + + Op.STOP + ) + + oracle = pre.deploy_contract(code=oracle_code, storage={0x01: 0x99}) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x42 + ) + ], + ) + ], + storage_reads=[], # Empty! Write shadows both reads + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(storage={0x01: 0x42}), + }, + ) + + +def test_bal_create_oog_code_deposit( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL correctly handles CREATE that runs out of gas during code + deposit. The contract address should appear with empty changes (read + during collision check) but no nonce or code changes (rolled back). + """ + alice = pre.fund_eoa() + + # create init code that returns a very large contract to force OOG + deposited_len = 10_000 + initcode = Op.RETURN(0, deposited_len) + + factory = pre.deploy_contract( + code=Op.MSTORE(0, Op.PUSH32(bytes(initcode))) + + Op.SSTORE( + 1, Op.CREATE(offset=32 - len(initcode), size=len(initcode)) + ) + + Op.STOP, + storage={1: 0xDEADBEEF}, + ) + + contract_address = compute_create_address(address=factory, nonce=1) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_gas_calculator( + calldata=b"", + contract_creation=False, + access_list=[], + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=intrinsic_gas + 500_000, # insufficient for deposit + ) + + # BAL expectations: + # - Alice: nonce change (tx sender) + # - Factory: nonce change (CREATE increments factory nonce) + # - Contract address: empty changes (read during collision check, + # nonce/code changes rolled back on OOG) + 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=1, + slot_changes=[ + # SSTORE saves 0 (CREATE failed) + BalStorageChange(block_access_index=1, post_value=0), + ], + ) + ], + ), + contract_address: BalAccountExpectation.empty(), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + factory: Account(nonce=2, storage={1: 0}), + contract_address: Account.NONEXISTENT, + }, + ) + + +def test_bal_sstore_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL does not record storage reads when SSTORE fails in static + context. + + Contract A makes STATICCALL to Contract B. Contract B attempts SSTORE, + which should fail immediately without recording any storage reads. + """ + alice = pre.fund_eoa() + + contract_b = pre.deploy_contract(code=Op.SSTORE(0, 5)) + + # 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) + ) + + tx = Transaction( + sender=alice, + to=contract_a, + gas_limit=2_000_000, + ) + + 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 + ) + ], + ), + contract_a: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ), + ], + ), + ], + ), + contract_b: BalAccountExpectation.empty(), + } + ), + ) + ], + post={ + contract_a: Account(storage={0: 1}), + contract_b: Account(storage={0: 0}), # SSTORE failed + }, + ) + + +def test_bal_call_with_value_in_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> 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. + """ + 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 + static_caller = pre.deploy_contract( + code=Op.STATICCALL(gas=500_000, address=caller) + + Op.SSTORE(0, 1) # prove we continued after STATICCALL returned + ) + + tx = Transaction( + sender=alice, + to=static_caller, + gas_limit=1_000_000, + ) + + 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 + } + ), + ) + ], + 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), + }, + ) + + +def test_bal_create_contract_init_revert( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that BAL does not include nonce/code changes when CREATE happens + in a call that then REVERTs. + """ + alice = pre.fund_eoa(amount=10**18) + + # Simple init code that returns STOP as deployed code + init_code_bytes = bytes(Op.RETURN(0, 1) + Op.STOP) + + # Factory that does CREATE then REVERTs + factory = pre.deploy_contract( + code=Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + + Op.POP(Op.CREATE(0, 32 - len(init_code_bytes), len(init_code_bytes))) + + Op.REVERT(0, 0) + ) + + # A caller that CALLs factory to CREATE then REVERT + caller = pre.deploy_contract(code=Op.CALL(address=factory)) + + created_address = compute_create_address(address=factory, nonce=1) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + ) + + 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 + ) + ], + ), + caller: BalAccountExpectation.empty(), + factory: BalAccountExpectation.empty(), + created_address: BalAccountExpectation.empty(), + } + ), + ) + ], + post={ + alice: Account(nonce=1), + caller: Account(nonce=1), + factory: Account(nonce=1), + created_address: Account.NONEXISTENT, + }, + ) + + +def test_bal_call_revert_insufficient_funds( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with CALL failure due to insufficient balance (not OOG). + + Contract (balance=100): SLOAD(0x01)โ†’CALL(target, value=1000)โ†’SSTORE(0x02). + CALL fails because 1000 > 100. Target is 0xDEAD. + + Expected BAL: + - Contract: storage_reads [0x01], storage_changes slot 0x02 (value=0) + - Target: appears in BAL (accessed before balance check fails) + """ + alice = pre.fund_eoa() + + contract_balance = 100 + transfer_amount = 1000 # More than contract has + + # Target address that should be warmed but not receive funds + # Give it a small balance so it's not considered "empty" and pruned + target_balance = 1 + target_address = pre.fund_eoa(amount=target_balance) + + # Contract that: + # 1. SLOAD slot 0x01 + # 2. CALL target with value=1000 (will fail - insufficient funds) + # 3. SSTORE slot 0x02 with CALL result (0 = failure) + contract_code = ( + Op.SLOAD(0x01) # Read from slot 0x01, push to stack + + Op.POP # Discard value + # CALL(gas, addr, value, argsOffset, argsSize, retOffset, retSize) + + Op.CALL(100_000, target_address, transfer_amount, 0, 0, 0, 0) + # CALL result is on stack (0 = failure, 1 = success) + # Stack: [result] + + Op.PUSH1(0x02) # Push slot number + # Stack: [0x02, result] + + Op.SSTORE # SSTORE pops slot (0x02), then value (result) + + Op.STOP + ) + + contract = pre.deploy_contract( + code=contract_code, + balance=contract_balance, + storage={ + 0x02: 0xDEAD + }, # Non-zero initial value so SSTORE(0) is a change + ) + + tx = Transaction( + sender=alice, + to=contract, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + contract: BalAccountExpectation( + # Storage read for slot 0x01 + storage_reads=[0x01], + # Storage change for slot 0x02 (CALL result = 0) + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0 + ) + ], + ) + ], + ), + # Target appears in BAL - accessed before balance check fails + target_address: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account( + balance=contract_balance, # Unchanged - transfer failed + storage={0x02: 0}, # CALL returned 0 (failure) + ), + target_address: Account(balance=target_balance), # Unchanged + }, + ) + + +def test_bal_create_selfdestruct_to_self_with_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with init code that CALLs Oracle, writes storage, then + SELFDESTRUCTs to self. + + Factory CREATE2(endowment=100). + Init: CALL(Oracle)โ†’SSTORE(0x01)โ†’SELFDESTRUCT(SELF). + + Expected BAL: + - Factory: nonce_changes, balance_changes (loses 100) + - Oracle: storage_changes slot 0x01 + - Created address: storage_reads [0x01] (aborted writeโ†’read), + MUST NOT have nonce/code/storage/balance changes (ephemeral) + """ + alice = pre.fund_eoa() + factory_balance = 1000 + + # Oracle contract that writes to slot 0x01 when called + oracle_code = Op.SSTORE(0x01, 0x42) + Op.STOP + oracle = pre.deploy_contract(code=oracle_code) + + endowment = 100 + + # Init code that: + # 1. Calls Oracle (which writes to its slot 0x01) + # 2. Writes 0x42 to own slot 0x01 + # 3. Selfdestructs to self + initcode_runtime = ( + # CALL(gas, Oracle, value=0, ...) + Op.CALL(100_000, oracle, 0, 0, 0, 0, 0) + + Op.POP + # Write to own storage slot 0x01 + + Op.SSTORE(0x01, 0x42) + # SELFDESTRUCT to self (ADDRESS returns own address) + + Op.SELFDESTRUCT(Op.ADDRESS) + ) + init_code = Initcode(deploy_code=Op.STOP, initcode_prefix=initcode_runtime) + init_code_bytes = bytes(init_code) + init_code_size = len(init_code_bytes) + + # Factory code with embedded initcode (no template contract needed) + # Structure: [execution code] [initcode bytes] + # CODECOPY copies initcode from factory's own code to memory + # + # Two-pass approach: build with placeholder, measure, rebuild + placeholder_offset = 0xFF # Placeholder (same byte size as final value) + factory_execution_template = ( + Op.CODECOPY(0, placeholder_offset, init_code_size) + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=endowment, + offset=0, + size=init_code_size, + salt=0, + ), + ) + + Op.STOP + ) + # Measure execution code size + execution_code_size = len(bytes(factory_execution_template)) + + # Rebuild with actual offset value + factory_execution = ( + Op.CODECOPY(0, execution_code_size, init_code_size) + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=endowment, + offset=0, + size=init_code_size, + salt=0, + ), + ) + + Op.STOP + ) + # Combine execution code with embedded initcode + factory_code = bytes(factory_execution) + init_code_bytes + + factory = pre.deploy_contract(code=factory_code, balance=factory_balance) + + # Calculate the CREATE2 target address + created_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=Op.CREATE2, + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + 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) + ], + # Balance changes: loses endowment (100) + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=factory_balance - endowment, + ) + ], + ), + # Oracle: storage changes for slot 0x01 + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x42 + ) + ], + ) + ], + ), + # Created address: ephemeral (created and destroyed same tx) + # - storage_reads for slot 0x01 (aborted write becomes read) + # - NO nonce/code/storage/balance changes + created_address: BalAccountExpectation( + storage_reads=[0x01], + storage_changes=[], + nonce_changes=[], + code_changes=[], + balance_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + factory: Account(nonce=2, balance=factory_balance - endowment), + oracle: Account(storage={0x01: 0x42}), + # Created address doesn't exist - destroyed in same tx + created_address: Account.NONEXISTENT, + }, + ) + + +def test_bal_create2_collision( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with CREATE2 collision against pre-existing contract. + + Pre-existing contract has code=STOP, nonce=1. + Factory (nonce=1, slot[0]=0xDEAD) executes CREATE2 targeting it. + + Expected BAL: + - Factory: nonce_changes (1โ†’2), storage_changes slot 0 (0xDEADโ†’0) + - Collision address: empty (accessed during collision check) + - Collision address MUST NOT have nonce_changes or code_changes + """ + alice = pre.fund_eoa() + + # Init code that deploys simple STOP contract + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + # Factory code: CREATE2 and store result in slot 0 + factory_code = ( + # Push init code to memory + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + # SSTORE(0, CREATE2(...)) - stores CREATE2 result in slot 0 + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=0, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + salt=0, + ), + ) + + Op.STOP + ) + + # Deploy factory - it starts with nonce=1 by default + factory = pre.deploy_contract( + code=factory_code, + storage={0x00: 0xDEAD}, # Initial value to prove SSTORE works + ) + + # Calculate the CREATE2 target address + collision_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=Op.CREATE2, + ) + + # Set up the collision by pre-populating the target address + # This contract has code (STOP) and nonce=1, causing collision + pre[collision_address] = Account( + code=Op.STOP, + nonce=1, + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation( + # Nonce incremented 1โ†’2 even on failed CREATE2 + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + # Storage changes: slot 0 = 0xDEAD โ†’ 0 (CREATE2 returned 0) + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0 + ) + ], + ) + ], + ), + # Collision address: empty (accessed but no state changes) + # Explicitly verify ALL fields are empty + collision_address: BalAccountExpectation( + nonce_changes=[], # MUST NOT have nonce changes + balance_changes=[], # MUST NOT have balance changes + code_changes=[], # MUST NOT have code changes + storage_changes=[], # MUST NOT have storage changes + storage_reads=[], # MUST NOT have storage reads + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + factory: Account(nonce=2, storage={0x00: 0}), + # Collision address unchanged - contract still exists + collision_address: Account(code=bytes(Op.STOP), nonce=1), + }, + ) + + +def test_bal_transient_storage_not_tracked( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL excludes EIP-1153 transient storage (TSTORE/TLOAD). + + Contract: TSTORE(0x01, 0x42)โ†’TLOAD(0x01)โ†’SSTORE(0x02, result). + + Expected BAL: + - storage_changes: slot 0x02 (persistent) + - MUST NOT include slot 0x01 (transient storage not persisted) + """ + alice = pre.fund_eoa() + + # Contract that uses transient storage then persists to regular storage + contract_code = ( + # TSTORE slot 0x01 with value 0x42 (transient storage) + Op.TSTORE(0x01, 0x42) + # TLOAD slot 0x01 (transient storage read) + + Op.TLOAD(0x01) + # Result (0x42) is on stack, store it in persistent slot 0x02 + + Op.PUSH1(0x02) + + Op.SSTORE # SSTORE pops slot (0x02), then value (0x42) + + Op.STOP + ) + + contract = pre.deploy_contract(code=contract_code) + + tx = Transaction( + sender=alice, + to=contract, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + contract: BalAccountExpectation( + # Persistent storage change for slot 0x02 + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0x42 + ) + ], + ) + ], + # MUST NOT include slot 0x01 in storage_reads + # Transient storage operations don't pollute BAL + storage_reads=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account(storage={0x02: 0x42}), + }, + ) + + +def test_bal_create_early_failure( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with CREATE failure due to insufficient endowment. + + Factory (balance=50) attempts CREATE(value=100). + Fails before nonce increment (before track_address). + Distinct from collision where address IS accessed. + + Expected BAL: + - Alice: nonce_changes + - Factory: storage_changes slot 0 (0xDEADโ†’0), NO nonce_changes + - Contract address: MUST NOT appear (never accessed) + """ + alice = pre.fund_eoa() + + factory_balance = 50 + endowment = 100 # More than factory has + + # Simple init code that deploys STOP + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + # Factory code: CREATE(value=endowment) and store result in slot 0 + factory_code = ( + # Push init code to memory + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + # SSTORE(0, CREATE(value, offset, size)) + + Op.SSTORE( + 0x00, + Op.CREATE( + value=endowment, # 100 > 50, will fail + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + ), + ) + + Op.STOP + ) + + # Deploy factory with insufficient balance for the CREATE endowment + factory = pre.deploy_contract( + code=factory_code, + balance=factory_balance, + storage={0x00: 0xDEAD}, # Initial value to prove SSTORE works + ) + + # Calculate what the contract address WOULD be (but it won't be created) + would_be_contract_address = compute_create_address( + address=factory, nonce=1 + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + factory: BalAccountExpectation( + # NO nonce_changes - CREATE failed before increment_nonce + nonce_changes=[], + # Storage changes: slot 0 = 0xDEAD โ†’ 0 (CREATE returned 0) + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=0 + ) + ], + ) + ], + ), + # Contract address MUST NOT appear in BAL - never accessed + # (CREATE failed before track_address was called) + would_be_contract_address: None, + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + # Factory nonce unchanged (still 1), balance unchanged + factory: Account( + nonce=1, balance=factory_balance, storage={0x00: 0} + ), + # Contract was never created + would_be_contract_address: Account.NONEXISTENT, }, ) 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 945330b628..29d77ea46a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -5,10 +5,18 @@ | `test_bal_nonce_changes` | Ensure BAL captures changes to nonce | Alice sends 100 wei to Bob | BAL MUST include changes to Alice's nonce. | โœ… Completed | | `test_bal_balance_changes` | Ensure BAL captures changes to balance | Alice sends 100 wei to Bob | BAL MUST include balance change for Alice, Bob, and Coinbase | โœ… Completed | | `test_bal_code_changes` | Ensure BAL captures changes to account code | Alice deploys factory contract that creates new contract | BAL MUST include code changes for newly deployed contract | โœ… Completed | -| `test_bal_self_destruct` | Ensure BAL captures storage access and balance changes caused by `SELFDESTRUCT` | Parameterized test: Alice interacts with a contract (either existing or created same-tx) that reads from storage slot 0x01, writes to storage slot 0x02, then executes `SELFDESTRUCT` with Bob as recipient. Contract may be pre-funded with 10 wei | BAL MUST include Alice's nonce change (increment) and Bob's balance change (100 or 110 depending on pre-funding). For the self-destructing contract: storage_reads=[0x01], empty storage_changes=[], and if pre-funded, balance_changes with post_balance=0; if not pre-funded, no balance change recorded. MUST NOT have code_changes or nonce_changes entries | โœ… Completed | +| `test_selfdestruct_to_account` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT success boundary for account beneficiaries | Victim executes `SELFDESTRUCT(beneficiary)` at exact gas boundary. Tests final gas boundary where operation completes. Parametrized: is_success (exact_gas/exact_gas_minus_1), beneficiary (EOA/contract), warm (cold/warm where warm=Berlin+), same_tx (pre_deploy/same_tx), originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Beneficiary in BAL with `balance_changes`, victim destroyed (pre-Cancun/same_tx) or preserved (>=Cancun). exact_gas_minus_1: OOG, beneficiary in BAL only if G_NEW_ACCOUNT was part of gas calculation. | โœ… Completed | +| `test_selfdestruct_state_access_boundary` (TangerineWhistle) | Ensure BAL correctly tracks beneficiary access at state access boundary (consensus check) | Victim executes `SELFDESTRUCT(beneficiary)` at state access boundary (base + cold). Verifies beneficiary is accessed before G_NEW_ACCOUNT check. Parametrized: is_success (exact_gas/exact_gas_minus_1), beneficiary (EOA/contract), warm (cold/warm), same_tx, originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Beneficiary **IN** BAL (state accessed). exact_gas_minus_1: Beneficiary **NOT** in BAL (OOG before state access). Operation may succeed at exact_gas if no G_NEW_ACCOUNT needed. | โœ… Completed | +| `test_selfdestruct_to_self` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT where beneficiary is self at gas boundary | Victim executes `SELFDESTRUCT(ADDRESS)` - selfdestructs to itself. Always warm, always alive (no G_NEW_ACCOUNT, no cold access). Gas = G_BASE + G_SELF_DESTRUCT. Parametrized: is_success (exact_gas/exact_gas_minus_1), originator_balance (0/1), same_tx (pre_deploy/same_tx). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas_minus_1: Victim in BAL with unchanged state. exact_gas: Pre-Cancun/same_tx: destroyed, balance=0. >=Cancun pre-existing: preserved with original balance. | โœ… Completed | +| `test_selfdestruct_to_precompile` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT success boundary for precompile beneficiaries | Victim executes `SELFDESTRUCT(precompile)` at exact gas boundary. Precompiles are always warm (no cold access charge). Parametrized: is_success (exact_gas/exact_gas_minus_1), all precompiles via `@pytest.mark.with_all_precompiles`, same_tx (pre_deploy/same_tx), originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Precompile in BAL with `balance_changes`, victim destroyed (pre-Cancun/same_tx) or preserved (>=Cancun). exact_gas_minus_1: OOG, precompile in BAL only if G_NEW_ACCOUNT was part of gas calculation. | โœ… Completed | +| `test_selfdestruct_to_precompile_state_access_boundary` (TangerineWhistle) | Ensure BAL correctly tracks precompile access at state access boundary (consensus check) | Victim executes `SELFDESTRUCT(precompile)` at state access boundary (base only, precompiles always warm). Verifies precompile is accessed before G_NEW_ACCOUNT check. Parametrized: is_success (exact_gas/exact_gas_minus_1), all precompiles, same_tx, originator_balance (0/1), beneficiary_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: Precompile **IN** BAL (state accessed). exact_gas_minus_1: Precompile **NOT** in BAL (OOG before state access). Operation may succeed at exact_gas if no G_NEW_ACCOUNT needed. | โœ… Completed | +| `test_selfdestruct_to_system_contract` (Cancun) | Ensure BAL captures SELFDESTRUCT success boundary for system contract beneficiaries | Victim executes `SELFDESTRUCT(system_contract)` at exact gas boundary. System contracts are always warm (no cold access charge) and always have code (no G_NEW_ACCOUNT charge). Gas = G_VERY_LOW + G_SELF_DESTRUCT. Parametrized: is_success (exact_gas/exact_gas_minus_1), all system contracts via `@pytest.mark.with_all_system_contracts`, same_tx (pre_deploy/same_tx), originator_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | exact_gas: System contract in BAL with `balance_changes` if originator had balance, victim destroyed (same_tx) or balance=0 (pre-existing). exact_gas_minus_1: OOG, system contract not in BAL (no state access). | โœ… Completed | +| `test_initcode_selfdestruct_to_self` (TangerineWhistle) | Ensure BAL captures SELFDESTRUCT during initcode where beneficiary is self | Initcode executes `SELFDESTRUCT(ADDRESS)` during CREATE, before any code is deployed. Contract has nonce=1 (post-EIP-161), making it non-empty. Always warm (executing contract), no G_NEW_ACCOUNT (nonce > 0). Gas boundary testing not possible (CREATE uses all available gas). Parametrized: originator_balance (0/1). File: `tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py`. | Contract created and destroyed in same tx - victim has empty BAL changes. Caller has `nonce_changes` (incremented by CREATE) and `balance_changes` if originator had balance. Victim is NONEXISTENT in post state. | โœ… Completed | | `test_bal_account_access_target` | Ensure BAL captures target addresses of account access opcodes | Alice calls `Oracle` contract which uses account access opcodes (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`) on `TargetContract`. | BAL MUST include Alice, `Oracle`, and `TargetContract` with empty changes for `TargetContract` and nonce changes for Alice. | โœ… Completed | -| `test_bal_call_with_value_transfer` | Ensure BAL captures balance changes from `CALL` opcode with value transfer | Alice calls `Oracle` contract (200 wei balance) which uses `CALL` opcode to transfer 100 wei to Bob (0 wei balance). | BAL MUST include Alice (nonce changes), Oracle (balance change to 100 wei), and Bob (balance change to 100 wei). | โœ… Completed | -| `test_bal_callcode_with_value_transfer` | Ensure BAL captures balance changes from `CALLCODE` opcode with value transfer | Alice calls `Oracle` contract (200 wei balance) which uses `CALLCODE` opcode to execute `TargetContract`'s code with 100 wei value transfer to Bob (0 wei balance). | BAL MUST include Alice (nonce changes), `Oracle` (balance change to 100 wei), Bob (balance change to 100 wei), and `TargetContract` (empty changes). | โœ… Completed | +| `test_bal_call_no_delegation_and_oog_before_target_access` | Ensure BAL handles OOG before target access and success for non-delegated CALL | Parametrized: target warm/cold, target empty/existing, value 0/1, memory expansion, OOG boundary (before_target_access/success). | OOG: target in BAL ONLY if pre-warmed. Success: target always in BAL with balance changes when value > 0. | โœ… Completed | +| `test_bal_call_no_delegation_oog_after_target_access` | Ensure BAL includes target but excludes value transfer when OOG after target access | Hardcoded: empty target, value=1 (required for create_cost gap). Parametrized: warm/cold, memory expansion. | Target always in BAL. No balance changes (value transfer fails after G_NEW_ACCOUNT check). | โœ… Completed | +| `test_bal_call_7702_delegation_and_oog` | Ensure BAL handles OOG at all 4 boundaries for CALL to 7702 delegated accounts | Parametrized: target warm/cold, delegation warm/cold, value 0/1, memory expansion, OOG boundary (before_target_access/after_target_access/success_minus_1/success). | OOG before: neither in BAL. OOG after & success_minus_1: target in BAL, delegation NOT in BAL (static check optimization). Success: all in BAL. | โœ… Completed | +| `test_bal_callcode_nested_value_transfer` | Ensure BAL captures balance changes from nested value transfers when CALLCODE executes target code that itself makes CALL with value | Alice calls `Oracle` contract (200 wei balance) which uses `CALLCODE` to execute `TargetContract`'s code; that code makes a nested CALL transferring 100 wei to Bob. | BAL MUST include Alice (nonce changes), `Oracle` (balance change to 100 wei), Bob (balance change to 100 wei), and `TargetContract` (empty changes). | โœ… Completed | | `test_bal_delegated_storage_writes` | Ensure BAL captures delegated storage writes via `DELEGATECALL` and `CALLCODE` | Alice calls `Oracle` contract which uses `DELEGATECALL`/`CALLCODE` to `TargetContract` that writes `0x42` to slot `0x01`. | BAL MUST include Alice (nonce changes), `Oracle` (storage changes for slot `0x01` = `0x42`), and `TargetContract` (empty changes). | โœ… Completed | | `test_bal_delegated_storage_reads` | Ensure BAL captures delegated storage reads via `DELEGATECALL` and `CALLCODE` | Alice calls `Oracle` contract (with slot `0x01` = `0x42`) which uses `DELEGATECALL`/`CALLCODE` to `TargetContract` that reads from slot `0x01`. | BAL MUST include Alice (nonce changes), `Oracle` (storage reads for slot `0x01`), and `TargetContract` (empty changes). | โœ… Completed | | `test_bal_block_rewards` | BAL tracks fee recipient balance changes from block rewards | Alice sends 100 wei to Bob with Charlie as fee recipient | BAL MUST include fee recipient Charlie with `balance_changes` reflecting transaction fees collected from the block. | โœ… Completed | @@ -21,16 +29,14 @@ | `test_bal_noop_storage_write` | Ensure BAL includes storage read but not write for no-op writes where pre-state equals post-state | Contract with pre-existing storage value `0x42` in slot `0x01`; transaction executes `SSTORE(0x01, 0x42)` (writing same value) | BAL **MUST** include the contract address with `storage_reads` for slot `0x01` since it was accessed, but **MUST NOT** include it in `storage_changes` (no actual state change). | โœ… Completed | | `test_bal_fully_unmutated_account` | Ensure BAL captures account that has zero net mutations | Alice sends 0 wei to `Oracle` which writes same pre-existing value to storage | BAL MUST include Alice with `nonce_changes` and balance changes (gas), `Oracle` with `storage_reads` for accessed slot but empty `storage_changes`. | โœ… Completed | | `test_bal_net_zero_balance_transfer` | BAL includes accounts with net-zero balance change but excludes them from balance changes | Contract receives and sends same amount to recipient using CALL or SELFDESTRUCT | BAL **MUST** include contract in `account_changes` without `balance_changes` (net zero). BAL **MUST** record non-zero `balance_changes` for recipient. | โœ… Completed | -| `test_bal_system_contracts_2935_4788` | BAL includes pre-exec system writes for parent hash & beacon root | Build a block with `N` normal txs; 2935 & 4788 active | BAL MUST include `HISTORY_STORAGE_ADDRESS` (EIP-2935) and `BEACON_ROOTS_ADDRESS` (EIP-4788) with `storage_changes` to ring-buffer slots; each write uses `tx_index = N` (system op). | ๐ŸŸก Planned | -| `test_bal_system_dequeue_withdrawals_eip7002` | BAL tracks post-exec system dequeues for withdrawals | Pre-populate EIP-7002 withdrawal requests; produce a block where dequeues occur | BAL MUST include the 7002 system contract with `storage_changes` (queue head/tail slots 0โ€“3) using `tx_index = len(txs)` and balance changes for withdrawal recipients. | ๐ŸŸก Planned | -| `test_bal_system_dequeue_consolidations_eip7251` | BAL tracks post-exec system dequeues for consolidations | Pre-populate EIP-7251 consolidation requests; produce a block where dequeues occur | BAL MUST include the 7251 system contract with `storage_changes` (queue slots 0โ€“3) using `tx_index = len(txs)`. | ๐ŸŸก Planned | +| `test_bal_system_dequeue_consolidations_eip7251` | BAL tracks post-exec system dequeues for consolidations | Pre-populate EIP-7251 consolidation requests; produce a block where dequeues occur | BAL MUST include the 7251 system contract with `storage_changes` (queue slots 0โ€“3) using `block_access_index = len(txs)`. | ๐ŸŸก Planned | | `test_bal_aborted_storage_access` | Ensure BAL captures storage access in aborted transactions correctly | Alice calls contract that reads storage slot `0x01`, writes to slot `0x02`, then aborts with `REVERT`/`INVALID` | BAL MUST include storage_reads for slots `0x01` and `0x02` (aborted writes become reads), empty storage_changes. Only nonce changes for Alice. | โœ… Completed | | `test_bal_aborted_account_access` | Ensure BAL captures account access in aborted transactions for all account accessing opcodes | Alice calls `AbortContract` that performs account access operations (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`) on `TargetContract` and aborts via `REVERT`/`INVALID` | BAL MUST include Alice, `TargetContract`, and `AbortContract` in account_changes and nonce changes for Alice. | โœ… Completed | | `test_bal_pure_contract_call` | Ensure BAL captures contract access for pure computation calls | Alice calls `PureContract` that performs pure arithmetic (ADD operation) without storage or balance changes | BAL MUST include Alice and `PureContract` in `account_changes`, and `nonce_changes` for Alice. | โœ… Completed | -| `test_bal_create2_to_A_read_then_selfdestruct` | BAL records balance change for A and storage access (no persistent change) | Tx0: Alice sends ETH to address **A**. Tx1: Deployer `CREATE2` a contract **at A**; contract does `SLOAD(B)` and immediately `SELFDESTRUCT(beneficiary=X)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (funding in Tx0 and transfer on selfdestruct in Tx1). BAL **MUST** include storage key **B** as an accessed `StorageKey`, and **MUST NOT** include **B** under `storage_changes` (no persistence due to same-tx create+destruct). | ๐ŸŸก Planned | +| `test_bal_create2_to_A_read_then_selfdestruct` | BAL records balance change for A and storage access (no persistent change) | Tx0: Alice sends ETH to address **A**. Tx1: Deployer `CREATE2` a contract **at A**; contract does `SLOAD(B)` and immediately `SELFDESTRUCT(beneficiary=X)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (funding in Tx0 and transfer on selfdestruct in Tx1). BAL **MUST** include storage key **B** as an accessed `StorageKey`, and **MUST NOT** include **B** under `storage_changes` (no persistence due to same-tx create+destruct). | ๐ŸŸก Planned | | `test_bal_create2_to_A_write_then_selfdestruct` | BAL records balance change for A and storage access even if a write occurred (no persistent change) | Tx0: Alice sends ETH to **A**. Tx1: Deployer `CREATE2` contract **at A**; contract does `SSTORE(B, v)` (optionally `SLOAD(B)`), then `SELFDESTRUCT(beneficiary=Y)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (Tx0 fund; Tx1 outflow to `Y`). BAL **MUST** include **B** as `StorageKey` accessed, and **MUST NOT** include **B** under `storage_changes` (ephemeral write discarded because the contract was created and destroyed in the same tx). | ๐ŸŸก Planned | -| `test_bal_precompile_funded_then_called` | BAL records precompile with balance change (fund) and access (call) | **Tx0**: Alice sends `1 ETH` to `ecrecover` (0x01). **Tx1**: Alice (or Bob) calls `ecrecover` with valid input and `0 ETH`. | BAL **MUST** include address `0x01` with `balance_changes` (from Tx0). No `storage_changes` or `code_changes`. | ๐ŸŸก Planned | -| `test_bal_precompile_call_only` | BAL records precompile when called with no balance change | Alice calls `ecrecover` (0x01) with a valid input, sending **0 ETH**. | BAL **MUST** include address `0x01` in access list, with **no** `balance_changes`, `storage_changes`, or `code_changes`. | ๐ŸŸก Planned | +| `test_bal_precompile_funded` | BAL records precompile value transfer with or without balance change | Alice sends value to precompile (all precompiles) via direct transaction. Parameterized: (1) with value (1 ETH), (2) without value (0 ETH). | For with_value: BAL **MUST** include precompile with `balance_changes`. For no_value: BAL **MUST** include precompile with empty `balance_changes`. No `storage_changes` or `code_changes` in either case. | โœ… Completed | +| `test_bal_precompile_call` | BAL records precompile when called via contract | Alice calls Oracle contract which calls precompile (all precompiles) via CALL opcode with 0 ETH | BAL **MUST** include Alice with `nonce_changes`, Oracle with empty changes, and precompile with empty changes. No `balance_changes`, `storage_changes`, or `code_changes` for precompile. | โœ… Completed | | `test_bal_7702_delegated_create` | BAL tracks EIP-7702 delegation indicator write and contract creation | Alice sends a type-4 (7702) tx authorizing herself to delegate to `Deployer` code which executes `CREATE` | BAL MUST include for **Alice**: `code_changes` (delegation indicator), `nonce_changes` (increment from 7702 processing), and `balance_changes` (post-gas). For **Child**: `code_changes` (runtime bytecode) and `nonce_changes = 1`. | ๐ŸŸก Planned | | `test_bal_7702_delegation_create` | Ensure BAL captures creation of EOA delegation | Alice authorizes delegation to contract `Oracle`. Transaction sends 10 wei to Bob. Two variants: (1) Self-funded: Alice sends 7702 tx herself. (2) Sponsored: `Relayer` sends 7702 tx on Alice's behalf. | BAL **MUST** include Alice: `code_changes` (delegation designation `0xef0100\|\|address(Oracle)`),`nonce_changes` (increment). Bob: `balance_changes` (receives 10 wei). For sponsored variant, BAL **MUST** also include `Relayer`:`nonce_changes`.`Oracle` **MUST NOT** be present in BAL - the account is never accessed. | โœ… Completed | | `test_bal_7702_delegation_update` | Ensure BAL captures update of existing EOA delegation | Alice first delegates to `Oracle1`, then in second tx updates delegation to `Oracle2`. Each transaction sends 10 wei to Bob. Two variants: (1) Self-funded: Alice sends both 7702 txs herself. (2) Sponsored: `Relayer` sends both 7702 txs on Alice's behalf. | BAL **MUST** include Alice: first tx has `code_changes` (delegation designation `0xef0100\|\|address(Oracle1)`),`nonce_changes`. Second tx has`code_changes` (delegation designation `0xef0100\|\|address(Oracle2)`),`nonce_changes`. Bob:`balance_changes` (receives 10 wei on each tx). For sponsored variant, BAL **MUST** also include `Relayer`:`nonce_changes` for both transactions. `Oracle1` and `Oracle2` **MUST NOT** be present in BAL - accounts are never accessed. | โœ… Completed | @@ -39,15 +45,30 @@ | `test_bal_7702_invalid_nonce_authorization` | Ensure BAL handles failed authorization due to wrong nonce | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect nonce, causing silent authorization failure | BAL **MUST** include Alice with empty changes (account access), Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include `Oracle` (authorization failed, no delegation) | โœ… Completed | | `test_bal_7702_invalid_chain_id_authorization` | Ensure BAL handles failed authorization due to wrong chain id | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect chain id, causing authorization failure before account access | BAL **MUST** include Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include Alice (authorization fails before loading account) or `Oracle` (authorization failed, no delegation) | โœ… Completed | | `test_bal_7702_delegated_via_call_opcode` | Ensure BAL captures delegation target when a contract uses *CALL opcodes to call a delegated account | Pre-deployed contract `Alice` delegated to `Oracle`. `Caller` contract uses CALL/CALLCODE/DELEGATECALL/STATICCALL to call `Alice`. Bob sends transaction to `Caller`. | BAL **MUST** include Bob: `nonce_changes`. `Caller`: empty changes (account access). `Alice`: empty changes (account access - delegated account being called). `Oracle`: empty changes (delegation target access). | โœ… Completed | +| `test_bal_7702_null_address_delegation` | Ensure BAL does not record spurious code changes for net-zero code operations | Alice sends transaction with authorization delegating to NULL_ADDRESS (0x0), which sets code to `b""` on an account that already has `b""` code. Transaction sends 10 wei to Bob. | BAL **MUST** include Alice with `nonce_changes` (tx nonce + auth nonce increment) but **MUST NOT** include `code_changes` (setting `b"" -> b""` is net-zero and filtered out). Bob: `balance_changes` (receives 10 wei). This ensures net-zero code change is not recorded. | โœ… Completed | +| `test_bal_7702_double_auth_reset` | Ensure BAL captures net code change when double auth resets delegation | `Relayer` sends transaction with two authorizations for Alice: (1) First auth sets delegation to `CONTRACT_A` at nonce=0, (2) Second auth resets delegation to empty (address 0) 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) but **MUST NOT** include `code_changes` (net change is empty โ†’ empty). Bob: `balance_changes` (receives 10 wei). Relayer: `nonce_changes`. `CONTRACT_A` **MUST NOT** be in BAL (never accessed). This is a regression test for the bug where BAL showed first auth's code despite final state being empty. | โœ… 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_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 | -| `test_bal_call_and_oog` | Ensure BAL handles OOG during CALL opcode execution correctly | Alice calls contract that attempts `CALL` to cold target contract. Parameterized: (1) OOG at CALL opcode (insufficient gas), (2) Successful CALL 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 | -| `test_bal_delegatecall_and_oog` | Ensure BAL handles OOG during DELEGATECALL opcode execution correctly | Alice calls contract that attempts `DELEGATECALL` to cold target contract. Parameterized: (1) OOG at DELEGATECALL opcode (insufficient gas), (2) Successful DELEGATECALL 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 | -| `test_bal_extcodecopy_and_oog` | Ensure BAL handles OOG during EXTCODECOPY opcode execution correctly | Alice calls contract that attempts `EXTCODECOPY` from cold target contract. Parameterized: (1) OOG at EXTCODECOPY opcode (insufficient gas), (2) Successful EXTCODECOPY 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 | +| `test_bal_delegatecall_no_delegation_and_oog_before_target_access` | Ensure BAL handles OOG before target access and success for non-delegated DELEGATECALL | Parametrized: target warm/cold, memory expansion, OOG boundary (before_target_access/success). | OOG: target in BAL ONLY if pre-warmed. Success: target always in BAL. | โœ… Completed | +| `test_bal_delegatecall_7702_delegation_and_oog` | Ensure BAL handles OOG at all 4 boundaries for DELEGATECALL to 7702 delegated accounts | Parametrized: target warm/cold, delegation warm/cold, memory expansion, OOG boundary (before_target_access/after_target_access/success_minus_1/success). | OOG before: neither in BAL. OOG after & success_minus_1: target in BAL, delegation NOT in BAL (static check optimization). Success: all in BAL. | โœ… Completed | +| `test_bal_callcode_no_delegation_and_oog_before_target_access` | Ensure BAL handles OOG before target access and success for non-delegated CALLCODE | Parametrized: target warm/cold, value 0/1, memory expansion, OOG boundary (before_target_access/success). | OOG: target in BAL ONLY if pre-warmed. Success: target always in BAL. | โœ… Completed | +| `test_bal_callcode_7702_delegation_and_oog` | Ensure BAL handles OOG at all 4 boundaries for CALLCODE to 7702 delegated accounts | Parametrized: target warm/cold, delegation warm/cold, value 0/1, memory expansion, OOG boundary (before_target_access/after_target_access/success_minus_1/success). | OOG before: neither in BAL. OOG after & success_minus_1: target in BAL, delegation NOT in BAL (static check optimization). Success: all in BAL. | โœ… Completed | +| `test_bal_staticcall_no_delegation_and_oog_before_target_access` | Ensure BAL handles OOG before target access and success for non-delegated STATICCALL | Parametrized: target warm/cold, memory expansion, OOG boundary (before_target_access/success). | OOG: target in BAL ONLY if pre-warmed. Success: target always in BAL. | โœ… Completed | +| `test_bal_staticcall_7702_delegation_and_oog` | Ensure BAL handles OOG at all 4 boundaries for STATICCALL to 7702 delegated accounts | Parametrized: target warm/cold, delegation warm/cold, memory expansion, OOG boundary (before_target_access/after_target_access/success_minus_1/success). | OOG before: neither in BAL. OOG after & success_minus_1: target in BAL, delegation NOT in BAL (static check optimization). Success: all in BAL. | โœ… Completed | +| `test_bal_extcodecopy_and_oog` | Ensure BAL handles OOG during EXTCODECOPY at various failure points | Alice calls contract that attempts `EXTCODECOPY` from cold target contract. Parameterized: (1) Successful EXTCODECOPY, (2) OOG at cold access (insufficient gas for account access), (3) OOG at memory expansion with large offset (64KB offset, gas covers cold access + copy but NOT memory expansion), (4) OOG at memory expansion boundary (256 byte offset, gas is exactly 1 less than needed). | For success case: BAL **MUST** include target contract. For all OOG cases: BAL **MUST NOT** include target contract. Gas for ALL components (cold access + copy + memory expansion) must be checked BEFORE recording account access. | โœ… Completed | | `test_bal_oog_7702_delegated_cold_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when both accounts are cold | Alice calls cold delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (first cold load succeeds) but **MUST NOT** include `TargetContract` (second cold load fails due to OOG) | ๐ŸŸก Planned | | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | ๐ŸŸก Planned | +| `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 โ†’ funding_amount โ†’ 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | โœ… Completed | +| `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 โ†’ 1 โ†’ 2 โ†’ 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | โœ… Completed | +| `test_bal_nested_delegatecall_storage_writes_net_zero` | Ensure BAL correctly filters net-zero storage changes across nested DELEGATECALL frames | Parametrized by nesting depth (1-3). Root contract has slot 0 = 1. Each frame writes a different intermediate value via DELEGATECALL chain, deepest frame writes back to original value (1). Example depth=2: 1 โ†’ 2 โ†’ 3 โ†’ 1 | BAL **MUST** include root contract with `storage_reads` for slot 0 but **MUST NOT** include `storage_changes` (net-zero). All delegate contracts **MUST** have empty changes. Tests that frame merging correctly removes parent's intermediate writes when child reverts to pre-tx value. | โœ… Completed | +| `test_bal_cross_tx_storage_revert_to_zero` | Ensure BAL captures storage changes when tx2 reverts slot back to original value (blobhash regression test) | Alice sends tx1 writing slot 0=0xABCD (from 0x0), then tx2 writing slot 0=0x0 (back to original) | BAL **MUST** include contract with slot 0 having two `slot_changes`: txIndex=1 (0xABCD) and txIndex=2 (0x0). Cross-transaction net-zero **MUST NOT** be filtered. | โœ… Completed | +| `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | โœ… Completed | +| `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | โœ… Completed | +| `test_bal_create_early_failure` | Ensure BAL correctly handles CREATE that fails before accessing contract address | Factory (balance=50) attempts CREATE(value=100). CREATE fails due to insufficient endowment (100 > 50). Factory stores CREATE result (0) in slot 0. | BAL **MUST** include Alice with `nonce_changes`. Factory with `storage_changes` (slot 0 = 0) but **MUST NOT** have `nonce_changes` (CREATE failed before nonce increment). Contract address **MUST NOT** appear in BAL (never accessed - CREATE failed before `track_address`). This is distinct from collision/OOG failures where contract address IS in BAL. | โœ… Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | โœ… Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | โœ… Completed | | `test_bal_invalid_storage_value` | Verify clients reject blocks with incorrect storage values in BAL | Alice calls contract that writes to storage; BAL modifier changes storage value to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate storage change values match actual state transitions. | โœ… Completed | @@ -58,5 +79,51 @@ | `test_bal_invalid_complex_corruption` | Verify clients reject blocks with multiple BAL corruptions | Alice calls contract with storage writes; BAL has multiple issues: wrong account, missing nonce, wrong storage value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect any corruption regardless of other issues. | โœ… Completed | | `test_bal_invalid_missing_account` | Verify clients reject blocks with missing required account entries in BAL | Alice sends transaction to Bob; BAL modifier removes Bob's account entry (recipient should be included) | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate all accessed accounts are present. | โœ… Completed | | `test_bal_invalid_balance_value` | Verify clients reject blocks with incorrect balance values in BAL | Alice sends value to Bob; BAL modifier changes balance to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate balance change values match actual state transitions. | โœ… Completed | -| `test_bal_empty_block_no_coinbase` | Verify BAL correctly handles empty blocks without including coinbase | Block with 0 transactions, no withdrawals. System contracts may perform operations (EIP-2935 parent hash, EIP-4788 beacon root if active). | BAL **MUST NOT** include the coinbase/fee recipient (receives no fees). BAL **MAY** include system contract addresses (EIP-2935 `HISTORY_STORAGE_ADDRESS`, EIP-4788 `BEACON_ROOTS_ADDRESS`) with `storage_changes` at `tx_index=0` (pre-execution system operations). Maximum 4 system contract addresses if all active. | ๐ŸŸก Planned | -| `test_bal_empty_block_withdrawal_to_coinbase` | Verify BAL includes coinbase when it receives EIP-4895 withdrawal even in empty block | Block with 0 transactions but contains EIP-4895 withdrawal(s) with coinbase as recipient. System contracts may perform operations. | BAL **MUST** include coinbase with `balance_changes` at `tx_index=1` (post-execution: len(txs)+1 = 0+1). BAL **MAY** include system contract addresses with `storage_changes` at `tx_index=0` (pre-execution system operations). This confirms that coinbase inclusion depends on actual state changes, not transaction presence. | ๐ŸŸก Planned | +| `test_bal_empty_block_no_coinbase` | Ensure BAL correctly handles empty blocks without including coinbase | Block with 0 transactions, no withdrawals. System contracts may perform operations (EIP-2935 parent hash, EIP-4788 beacon root if active). | BAL **MUST NOT** include the coinbase/fee recipient (receives no fees). BAL **MAY** include system contract addresses (EIP-2935 `HISTORY_STORAGE_ADDRESS`, EIP-4788 `BEACON_ROOTS_ADDRESS`) with `storage_changes` at `block_access_index=0` (pre-execution system operations). | โœ… Completed | +| `test_bal_coinbase_zero_tip` | Ensure BAL includes coinbase even when priority fee is zero | Block with 1 transaction: Alice sends 5 wei to Bob with priority fee = 0 (base fee burned post-EIP-1559) | BAL **MUST** include Alice with `balance_changes` (gas cost) and `nonce_changes`. BAL **MUST** include Bob with `balance_changes`. BAL **MUST** include coinbase with empty changes. | โœ… Completed | +| `test_bal_withdrawal_empty_block` | Ensure BAL captures withdrawal balance changes in empty block | Charlie starts with 1 gwei. Block with 0 transactions and 1 withdrawal of 10 gwei to Charlie | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1`. Charlie's `balance_changes` **MUST** show final balance of 11 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_and_transaction` | Ensure BAL captures both transaction and withdrawal balance changes | Block with 1 transaction: Alice sends 5 wei to Bob. 1 withdrawal of 10 gwei to Charlie | BAL **MUST** include Alice with `nonce_changes` and `balance_changes` at `block_access_index = 1`. BAL **MUST** include Bob with `balance_changes` at `block_access_index = 1`. BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 2` showing final balance after receiving 10 gwei. All other fields for Charlie **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_to_nonexistent_account` | Ensure BAL captures withdrawal to non-existent account | Block with 1 withdrawal of 10 gwei to non-existent account Charlie | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1` showing final balance of 10 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_no_evm_execution` | Ensure BAL captures withdrawal without triggering EVM execution | Contract `Oracle` with storage slot 0x01 = 0x42. `Oracle` code writes to slot 0x01 when called. Block with 1 withdrawal of 10 gwei to `Oracle` | BAL **MUST** include `Oracle` with `balance_changes` at `block_access_index = 1` showing final balance after receiving 10 gwei. Storage slot 0x01 **MUST** remain 0x42 and all other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_and_state_access_same_account` | Ensure BAL captures both state access and withdrawal to same address | Contract `Oracle` with storage slot 0x01 = 0x42. Block with 1 transaction: Alice calls `Oracle` (reads from slot 0x01, writes to slot 0x02). 1 withdrawal of 10 gwei to `Oracle` | BAL **MUST** include `Oracle` with `storage_reads` for slot 0x01 and `storage_changes` for slot 0x02 at `block_access_index = 1`. `Oracle` **MUST** also have `balance_changes` at `block_access_index = 2` showing final balance after receiving 10 gwei. Both state access and withdrawal **MUST** be captured. | โœ… Completed | +| `test_bal_withdrawal_and_value_transfer_same_address` | Ensure BAL captures both transaction value transfer and withdrawal to same address | Block with 1 transaction: Alice sends 5 gwei to Bob. 1 withdrawal of 10 gwei to Bob | BAL **MUST** include Alice with `nonce_changes` and `balance_changes` at `block_access_index = 1`. BAL **MUST** include Bob with `balance_changes` at `block_access_index = 1` showing balance after receiving 5 gwei. Bob **MUST** also have `balance_changes` at `block_access_index = 2` showing balance after receiving 10 gwei withdrawal. Bob's final post-state balance **MUST** be 15 gwei (cumulative). | โœ… Completed | +| `test_bal_multiple_withdrawals_same_address` | Ensure BAL accumulates multiple withdrawals to same address | Block with 3 withdrawals to Charlie: 5 gwei, 10 gwei, 15 gwei | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1` showing final balance of 30 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_and_selfdestruct` | Ensure BAL captures withdrawal to self-destructed contract address | Contract `Oracle` with 100 gwei balance. Block with 1 transaction: `Oracle` self-destructs sending balance to Bob. 1 withdrawal of 50 gwei to `Oracle`'s address | BAL **MUST** include `Oracle` with `balance_changes` showing 0 balance at `block_access_index = 1` (after self-destruct). BAL **MUST** include Bob with `balance_changes` showing 100 gwei received from self-destruct at `block_access_index = 1`. `Oracle` **MUST** also have `balance_changes` at `block_access_index = 2` showing 50 gwei after withdrawal. Both self-destruct and withdrawal **MUST** be captured. | โœ… Completed | +| `test_bal_withdrawal_and_new_contract` | Ensure BAL captures withdrawal to newly created contract | Block with 1 transaction: Alice deploys contract `Oracle` with 5 gwei initial balance. 1 withdrawal of 10 gwei to `Oracle` | BAL **MUST** include `Oracle` with `code_changes` and `balance_changes` showing 5 gwei at `block_access_index = 1`. `Oracle` **MUST** also have `balance_changes` at `block_access_index = 2` showing balance after receiving 10 gwei withdrawal. `Oracle`'s final post-state balance **MUST** be 15 gwei (cumulative). | โœ… Completed | +| `test_bal_zero_withdrawal` | Ensure BAL handles zero-amount withdrawal correctly | Block with 0 transactions and 1 zero-amount withdrawal (0 gwei) to Charlie. Two variations: Charlie has existing balance (5 gwei) or Charlie is non-existent. | BAL **MUST** include Charlie at `block_access_index = 1` with empty changes. Balance remains unchanged. | โœ… Completed | +| `test_bal_withdrawal_to_precompiles` | Ensure BAL captures withdrawal to precompile addresses | Block with 1 withdrawal of 10 gwei to precompile address (all precompiles) | BAL **MUST** include precompile address with `balance_changes` at `block_access_index = 1` showing final balance of 10 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_largest_amount` | Ensure BAL captures withdrawal with largest amount | Block with 1 withdrawal of maximum uint64 value (2^64-1 gwei) to Charlie | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1` showing final balance of (2^64-1) * 10^9 wei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_withdrawal_to_coinbase` | Ensure BAL captures withdrawal to coinbase address | Block with 1 transaction: Alice sends 5 wei to Bob. 1 withdrawal of 10 gwei to coinbase/fee recipient | BAL **MUST** include coinbase with `balance_changes` at `block_access_index = 1` showing balance after transaction fees. Coinbase **MUST** also have `balance_changes` at `block_access_index = 2` showing balance after receiving 10 gwei withdrawal. Coinbase's final post-state balance **MUST** include both transaction fees and withdrawal. | โœ… Completed | +| `test_bal_withdrawal_to_coinbase_empty_block` | Ensure BAL captures withdrawal to coinbase even when there are no transactions (no fees) | Block with 0 transactions and 1 withdrawal of 10 gwei to coinbase/fee recipient | BAL **MUST** include coinbase with `balance_changes` at `block_access_index = 1` showing final balance of 10 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | โœ… Completed | +| `test_bal_nonexistent_value_transfer` | Ensure BAL captures non-existent account on value transfer | Alice sends value (0 wei or 1 ETH) to non-existent account Bob (address never funded or accessed before) via direct transfer | For zero value: BAL **MUST** include Alice with `nonce_changes` and Bob (non-existent) with empty changes. For positive value: BAL **MUST** include Bob with `balance_changes` showing received amount. | โœ… Completed | +| `test_bal_nonexistent_account_access_read_only` | Ensure BAL captures non-existent account accessed via read-only account-reading opcodes | Alice calls `Oracle` contract which uses read-only account access opcodes (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `STATICCALL`, `DELEGATECALL`) on non-existent account Bob. | BAL **MUST** include Alice with `nonce_changes`, `Oracle` with empty changes, and Bob with empty changes (account accessed but no state modifications). | โœ… Completed | +| `test_bal_nonexistent_account_access_value_transfer` | Ensure BAL captures non-existent account accessed via CALL/CALLCODE with value transfers | Alice calls `Oracle` contract which uses `CALL` or `CALLCODE` on non-existent account Bob. Tests both zero and positive value transfers. | BAL **MUST** include Alice with `nonce_changes`. For CALL with positive value: `Oracle` with `balance_changes` (loses value), Bob with `balance_changes` (receives value). For CALLCODE with value or zero value transfers: `Oracle` and Bob with empty changes (CALLCODE self-transfer = net zero). | โœ… Completed | +| `test_bal_storage_write_read_same_frame` | Ensure BAL captures write precedence over read in same call frame (writes shadow reads) | Alice calls `Oracle` which writes (`SSTORE`) value `0x42` to slot `0x01`, then reads (`SLOAD`) from slot `0x01` in the same call frame | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows the subsequent read in same frame). | โœ… Completed | +| `test_bal_storage_write_read_cross_frame` | Ensure BAL captures write precedence over read across call frames (writes shadow reads cross-frame) | Alice calls `Oracle`. First call reads slot `0x01` (sees initial value), writes `0x42` to slot `0x01`, then calls itself (via `CALL`, `DELEGATECALL`, or `CALLCODE`). Second call reads slot `0x01` (sees `0x42`) and exits. | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows both the read before it in same frame and the read in the recursive call). | โœ… Completed | +| `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract (setting `b"" -> b""` is net-zero). | โœ… Completed | +| `test_bal_cross_block_precompile_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | โœ… Completed | +| `test_bal_all_transaction_types` | Ensure BAL correctly captures state changes from all transaction types in a single block | Single block with 5 transactions: Type 0 (Legacy), Type 1 (EIP-2930 Access List), Type 2 (EIP-1559), Type 3 (EIP-4844 Blob), Type 4 (EIP-7702 Set Code). Each tx writes to contract storage. Note: Access list addresses are pre-warmed but NOT recorded in BAL (no state access). | BAL **MUST** include: (1) All 5 senders with `nonce_changes`. (2) Contracts 0-3 with `storage_changes`. (3) Alice (7702 target) with `nonce_changes`, `code_changes` (delegation), `storage_changes`. (4) Oracle (delegation source) with empty changes. | โœ… Completed | +| `test_bal_create2_collision` | Ensure BAL handles CREATE2 address collision correctly | Factory contract (nonce=1, storage slot 0=0xDEAD) executes `CREATE2(salt=0, initcode)` targeting address that already has `code=STOP, nonce=1`. Pre-deploy contract at calculated CREATE2 target address before factory deployment. | BAL **MUST** include: (1) Factory with `nonce_changes` (1โ†’2, incremented even on failed CREATE2), `storage_changes` for slot 0 (0xDEADโ†’0, stores failure). (2) Collision address with empty changes (accessed during collision check, no state changes). CREATE2 returns 0. Collision address **MUST NOT** have `nonce_changes` or `code_changes`. | โœ… Completed | +| `test_bal_create_selfdestruct_to_self_with_call` | Ensure BAL handles init code that calls external contract then selfdestructs to itself | Factory executes `CREATE2` with endowment=100. Init code (embedded in factory via CODECOPY): (1) `CALL(Oracle, 0)` - Oracle writes to its storage slot 0x01. (2) `SSTORE(0x01, 0x42)` - write to own storage. (3) `SELFDESTRUCT(SELF)` - selfdestruct to own address. Contract created and destroyed in same tx. | BAL **MUST** include: (1) Factory with `nonce_changes`, `balance_changes` (loses 100). (2) Oracle with `storage_changes` for slot 0x01 (external call succeeded). (3) Created address with `storage_reads` for slot 0x01 (aborted write becomes read) - **MUST NOT** have `nonce_changes`, `code_changes`, `storage_changes`, or `balance_changes` (ephemeral contract, balance burned via SELFDESTRUCT to self). | โœ… Completed | +| `test_bal_selfdestruct_to_7702_delegation` | Ensure BAL correctly handles SELFDESTRUCT to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Tx2: Victim contract (balance=100) executes `SELFDESTRUCT(Alice)`. Two separate transactions in same block. Note: Alice starts with initial balance which accumulates with selfdestruct. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives selfdestruct). (3) Victim at block_access_index=2 with `balance_changes` (100โ†’0). **Oracle MUST NOT appear in tx2** - per EVM spec, SELFDESTRUCT transfers balance without executing recipient code, so delegation target is never accessed. | โœ… Completed | +| `test_bal_call_revert_insufficient_funds` | Ensure BAL handles CALL failure due to insufficient balance (not OOG) | Contract (balance=100, storage slot 0x02=0xDEAD) executes: `SLOAD(0x01), CALL(target, value=1000), SSTORE(0x02, result)`. CALL fails because 1000 > 100. Target address 0xDEAD (pre-existing with non-zero balance to avoid pruning). Note: slot 0x02 must start non-zero so SSTORE(0) is a change. | BAL **MUST** include: (1) Contract with `storage_reads` for slot 0x01, `storage_changes` for slot 0x02 (value=0, CALL returned failure). (2) Target (0xDEAD) **MUST** appear in BAL with empty changes - target is accessed before balance check fails. | โœ… Completed | +| `test_bal_lexicographic_address_ordering` | Ensure BAL enforces strict lexicographic byte-wise ordering | Pre-fund three addresses with specific byte patterns: `addr_low = 0x0000...0001`, `addr_mid = 0x0000...0100`, `addr_high = 0x0100...0000`. Contract touches them in reverse order: `BALANCE(addr_high), BALANCE(addr_low), BALANCE(addr_mid)`. Additionally, include two endian-trap addresses that are byte-reversals of each other: `addr_endian_low = 0x0100000000000000000000000000000000000002`, `addr_endian_high = 0x0200000000000000000000000000000000000001`. Note: `reverse(addr_endian_low) = addr_endian_high`. Correct lexicographic order: `addr_endian_low < addr_endian_high` (0x01 < 0x02 at byte 0). If implementation incorrectly reverses bytes before comparing, it would get `addr_endian_low > addr_endian_high` (wrong). | BAL account list **MUST** be sorted lexicographically by address bytes: `addr_low` < `addr_mid` < `addr_high` < `addr_endian_low` < `addr_endian_high`, regardless of access order. The endian-trap addresses specifically catch byte-reversal bugs where addresses are compared with wrong byte order. Complements `test_bal_invalid_account_order` which tests rejection; this tests correct generation. | โœ… Completed | +| `test_bal_transient_storage_not_tracked` | Ensure BAL excludes EIP-1153 transient storage operations | Contract executes: `TSTORE(0x01, 0x42)` (transient write), `TLOAD(0x01)` (transient read), `SSTORE(0x02, result)` (persistent write using transient value). | BAL **MUST** include slot 0x02 in `storage_changes` (persistent storage was modified). BAL **MUST NOT** include slot 0x01 in `storage_reads` or `storage_changes` (transient storage is not persisted, not needed for stateless execution). This verifies TSTORE/TLOAD don't pollute BAL. | โœ… Completed | +| `test_bal_withdrawal_to_7702_delegation` | Ensure BAL correctly handles withdrawal to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Withdrawal: 10 gwei sent to Alice. Single block with tx + withdrawal. | BAL **MUST** include: (1) Alice at block_access_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at block_access_index=2 with `balance_changes` (receives withdrawal). **Oracle MUST NOT appear** - withdrawals credit balance without executing recipient code, so delegation target is never accessed. This complements `test_bal_selfdestruct_to_7702_delegation` (selfdestruct) and `test_bal_withdrawal_no_evm_execution` (withdrawal to contract). | โœ… Completed | +| `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | โœ… Completed | +| `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | โœ… Completed | +| `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | โœ… Completed | +| `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account is read. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | โœ… Completed | +| `test_bal_call_with_value_in_static_context` | Ensure BAL does NOT include target when CALL with value fails in static context | `static_caller` uses `STATICCALL` to call `caller`. `caller` attempts `CALL(target, value=1)` which must fail due to static context. Target is an empty account. | BAL **MUST NOT** include target because static context check (`is_static && value > 0`) must happen BEFORE any account access or BAL tracking. BAL **MUST** include `static_caller` with `storage_changes` (STATICCALL succeeded), `caller` with empty changes. | โœ… Completed | +| `test_bal_4788_simple` | Ensure BAL captures beacon root storage writes during pre-execution system call | Block with 2 normal user transactions: Alice sends 10 wei to Charlie, Bob sends 10 wei to Charlie. At block start (pre-execution), `SYSTEM_ADDRESS` calls `BEACON_ROOTS_ADDRESS` to store parent beacon root | BAL **MUST** include at `block_access_index=0`: `BEACON_ROOTS_ADDRESS` with two `storage_changes` (timestamp slot and beacon root slot); `SYSTEM_ADDRESS` **MUST NOT** be included in BAL. At `block_access_index=1`: Alice with `nonce_changes`, Charlie with `balance_changes` (10 wei). At `block_access_index=2`: Bob with `nonce_changes`, Charlie with `balance_changes` (20 wei total). | โœ… Completed | +| `test_bal_4788_empty_block` | Ensure BAL captures beacon root storage writes in empty block | Block with no transactions. At block start (pre-execution), `SYSTEM_ADDRESS` calls `BEACON_ROOTS_ADDRESS` to store parent beacon root | BAL **MUST** include at `block_access_index=0`: `BEACON_ROOTS_ADDRESS` with two `storage_changes` (timestamp slot and beacon root slot); `SYSTEM_ADDRESS` **MUST NOT** be included in BAL. No transaction-related BAL entries. | โœ… Completed | +| `test_bal_4788_query` | Ensure BAL captures storage reads when querying beacon root (valid and invalid queries) with optional value transfer | Parameterized test: Block 1 stores beacon root at timestamp 12. Block 2 queries with three timestamp scenarios (valid=12, invalid non-zero=42, invalid zero=0) and value (0 or 100 wei). Valid query (timestamp=12): reads both timestamp and root slots, writes returned value. If value > 0, beacon root contract receives balance. Invalid query with non-zero timestamp (timestamp=42): reads only timestamp slot before reverting, query contract has implicit SLOAD recorded (SSTORE reverts), no value transferred. Invalid query with zero timestamp (timestamp=0): reverts immediately without any storage access, query contract has implicit SLOAD recorded, no value transferred. | Block 1 BAL: System call writes. Block 2 BAL **MUST** include at `block_access_index=0`: System call writes for block 2. Valid case (timestamp=12) at `block_access_index=1`: `BEACON_ROOTS_ADDRESS` with `storage_reads` [timestamp_slot, root_slot] and `balance_changes` if value > 0, query contract with `storage_changes`. Invalid non-zero case (timestamp=42) at `block_access_index=1`: `BEACON_ROOTS_ADDRESS` with `storage_reads` [timestamp_slot only] and NO `balance_changes` (reverted), query contract with `storage_reads` [0] and NO `storage_changes`. Invalid zero case (timestamp=0) at `block_access_index=1`: `BEACON_ROOTS_ADDRESS` with NO `storage_reads` (reverts before access) and NO `balance_changes`, query contract with `storage_reads` [0] and NO `storage_changes`. | โœ… Completed | +| `test_bal_4788_selfdestruct_to_beacon_root` | Ensure BAL captures `SELFDESTRUCT` to beacon root address alongside system call storage writes | Single block: Pre-execution system call writes beacon root to storage. Transaction: Alice calls contract (pre-funded with 100 wei) that selfdestructs with `BEACON_ROOTS_ADDRESS` as beneficiary. | BAL **MUST** include at `block_access_index=0`: `BEACON_ROOTS_ADDRESS` with `storage_changes` (timestamp and root slots from system call). At `block_access_index=1`: Alice with `nonce_changes`, contract with `balance_changes` (100โ†’0), `BEACON_ROOTS_ADDRESS` with `balance_changes` (receives 100 wei). | โœ… Completed | +| `test_bal_7702_double_auth_reset_minimal` | 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. | ๐ŸŸก Planned | +| `test_bal_selfdestruct_send_to_sender` | Ensure BAL tracks SELFDESTRUCT sending all funds back to the tx sender (no burn) | Pre-state: contract `C` exists from a prior transaction with non-empty code and balance = 100 wei. EOA `Alice` sends a transaction calling `C`. `C`โ€™s code executes `SELFDESTRUCT(Alice)`. Under EIP-6780, because `C` was not created in this transaction, SELFDESTRUCT does not delete code or storage; it only transfers the entire 100 wei balance from `C` to `Alice`. Final post-state: `C` still exists with the same code and balance = 0; `Alice`โ€™s balance increased by 100 wei (ignoring gas for this test). | BAL **MUST** include `Alice` with `nonce_changes` (tx sender) and `balance_changes` reflecting receipt of 100 wei, and **MUST** include `C` with `balance_changes` 100โ†’0 and no `code_changes`. BAL **MUST NOT** include any other accounts. This test ensures SELFDESTRUCT-to-sender is modeled as a pure value transfer (no burn, no code deletion). | ๐ŸŸก Planned | +| `test_bal_7002_clean_sweep` | Ensure BAL correctly tracks "clean sweep" where all withdrawal requests are dequeued in same block (requests โ‰ค MAX). Parameterized: (1) pubkey first 32 bytes zero / non-zero, (2) amount zero / non-zero | Alice sends transaction to `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` with 1 withdrawal request. Validator pubkey has either first 32 bytes zero or non-zero. Amount is either zero or non-zero. Since 1 โ‰ค MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, post-execution system call dequeues all requests ("clean sweep"), resetting head and tail to 0. | BAL **MUST** include Alice with `nonce_changes` at `block_access_index=1`. `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` **MUST** have: `balance_changes` at `block_access_index=1` (receives fee), `storage_reads` for excess, head, and slot 5 (first 32 bytes of pubkey) if zero. At `block_access_index=1` (tx enqueue): `storage_changes` for count (0โ†’1), tail (0โ†’1), slot 4 (source address), slot 5 (first 32 bytes, **ONLY** if non-zero), slot 6. At `block_access_index=2` (post-exec dequeue): `storage_changes` for count (1โ†’0), tail (1โ†’0). Clean sweep invariant: when all requests dequeued, both head and tail reset to 0. | โœ… Completed | +| `test_bal_7002_partial_sweep` | Ensure BAL correctly tracks queue overflow when requests exceed MAX, demonstrating partial sweep in block 1 and cleanup in block 2 | Block 1: 20 different EOAs each send withdrawal request to `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS`. Since 20 > MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, only first MAX requests dequeued ("partial sweep"), leaving 4 in queue. Block 2: Empty block (no transactions), remaining 4 requests dequeued ("clean sweep"), queue becomes empty. | Block 1 BAL **MUST** include all 20 senders with `nonce_changes` at respective `block_access_index` (1-20). `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` at each tx: `storage_changes` for count (increments to 20), tail (increments to 20). At `block_access_index=21` (post-exec partial dequeue): `storage_changes` for count (20โ†’0), head (0โ†’MAX). Partial sweep: head advances by MAX, tail stays 20, queue has 4 remaining (tail - head = 4). Block 2 BAL **MUST** include `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` at `block_access_index=1` (post-exec clean sweep): `storage_changes` for head (MAXโ†’0), tail (20โ†’0). Clean sweep: both head and tail reset to 0, queue empty. |โœ… Completed | +| `test_bal_7002_no_withdrawal_requests` | Ensure BAL captures EIP-7002 system contract dequeue operation even when block has no withdrawal requests | Block with 1 transaction: Alice sends 10 wei to Bob. No withdrawal requests submitted. | BAL **MUST** include Alice with `nonce_changes` at `block_access_index=1`. BAL **MUST** include Bob with `balance_changes` at `block_access_index=1`. BAL **MUST** include EIP-7002 system contract (`WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS`) with `storage_reads` for slots: excess (slot 0), count (slot 1), head (slot 2), tail (slot 3). System contract **MUST NOT** have `storage_changes` (no writes occur when queue is empty). This test demonstrates that the post-execution dequeue operation always runs and reads queue state, even when no requests are present. | โœ… Completed | +| `test_bal_7002_request_from_contract` | Ensure BAL captures withdrawal request from contract with correct source address | Alice calls `RelayContract` which internally calls EIP-7002 system contract with withdrawal request. Withdrawal request should have `source_address = RelayContract` (not Alice). | BAL **MUST** include Alice with `nonce_changes` at `block_access_index=1`. BAL **MUST** include `RelayContract` with `balance_changes` (fee paid to system contract) at `block_access_index=1`. BAL **MUST** include system contract with `balance_changes`, `storage_reads`, and `storage_changes` (queue modified). Source address in withdrawal request **MUST** be `RelayContract`. Clean sweep: count and tail reset to 0 at `block_access_index=2`. | โœ… Completed | +| `test_bal_7002_request_invalid` | Ensure BAL correctly handles invalid withdrawal request scenarios | Parameterized test with 8 invalid scenarios: (1) insufficient_fee (fee=0), (2) calldata_too_short (55 bytes), (3) calldata_too_long (57 bytes), (4) oog (insufficient gas), (5-7) invalid_call_type (DELEGATECALL/STATICCALL/CALLCODE), (8) contract_reverts. Tests both EOA and contract-based withdrawal requests. | BAL **MUST** include sender with `nonce_changes` at `block_access_index=1`. BAL **MUST** include system contract with `storage_reads` for slots: excess (slot 0), count (slot 1), head (slot 2), tail (slot 3). System contract **MUST NOT** have `storage_changes` (transaction failed, no queue modification). | โœ… Completed | +| `test_bal_invalid_extraneous_entries` | Verify clients reject blocks with any type of extraneous BAL entries | Alice sends 100 wei to Oracle contract (which reads storage slot 0). Charlie is uninvolved in this transaction. A valid BAL is created containing nonce change for Alice, balance change and storage read for Oracle. The BAL is corrupted by adding various extraneous entries: (1) extra_nonce, (2) extra_balance, (3) extra_code, (4) extra_storage_write_touched (slot 0 - already read), (5) extra_storage_write_untouched (slot 1 - not accessed), (6) extra_storage_write_uninvolved_account (Charlie - uninvolved account), (7) extra_account_access (Charlie), (8) extra_storage_read (slot 999). Each tested at block_access_index 1 (same tx), 2 (system tx), 3 (out of bounds). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect any extraneous entries in BAL. | โœ… Completed | diff --git a/tests/berlin/eip2930_access_list/test_tx_type.py b/tests/berlin/eip2930_access_list/test_tx_type.py index c9e22e55d9..4eb7434abe 100644 --- a/tests/berlin/eip2930_access_list/test_tx_type.py +++ b/tests/berlin/eip2930_access_list/test_tx_type.py @@ -13,7 +13,6 @@ TransactionException, ) from execution_testing import Opcodes as Op -from execution_testing.forks import Byzantium from .spec import ref_spec_2930 @@ -62,7 +61,7 @@ def test_eip2930_tx_validity( sender=sender, gas_limit=100_000, access_list=[], - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), error=TransactionException.TYPE_1_TX_PRE_FORK if not valid else None, ) diff --git a/tests/byzantium/eip196_ec_add_mul/test_gas.py b/tests/byzantium/eip196_ec_add_mul/test_gas.py index 59f84e5aa8..a82e622da9 100644 --- a/tests/byzantium/eip196_ec_add_mul/test_gas.py +++ b/tests/byzantium/eip196_ec_add_mul/test_gas.py @@ -8,7 +8,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -55,7 +54,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: 1 if enough_gas else 0})} diff --git a/tests/byzantium/eip197_ec_pairing/test_gas.py b/tests/byzantium/eip197_ec_pairing/test_gas.py index 1efddcb85c..3a79c0db85 100644 --- a/tests/byzantium/eip197_ec_pairing/test_gas.py +++ b/tests/byzantium/eip197_ec_pairing/test_gas.py @@ -8,7 +8,6 @@ Transaction, ) from execution_testing.base_types.base_types import Address -from execution_testing.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -49,7 +48,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: 1 if enough_gas else 0})} diff --git a/tests/cancun/create/__init__.py b/tests/cancun/create/__init__.py new file mode 100644 index 0000000000..5297fcc089 --- /dev/null +++ b/tests/cancun/create/__init__.py @@ -0,0 +1 @@ +"""Create tests starting at Cancun.""" diff --git a/tests/cancun/create/test_create_oog_from_eoa_refunds.py b/tests/cancun/create/test_create_oog_from_eoa_refunds.py new file mode 100644 index 0000000000..050f54c91e --- /dev/null +++ b/tests/cancun/create/test_create_oog_from_eoa_refunds.py @@ -0,0 +1,423 @@ +""" +Tests for CREATE OOG scenarios from EOA refunds. + +Tests that verify refunds are not applied on contract creation +when the creation runs out of gas. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Fork, + Op, + Transaction, + compute_create2_address, + compute_create_address, +) + +pytestmark = pytest.mark.valid_from("Cancun") + + +class OogScenario(Enum): + """Different ways a CREATE can run out of gas or succeed.""" + + NO_OOG = "no_oog" + OOG_CODE_DEPOSIT = "oog_code_deposit" # OOG due to code deposit cost + OOG_INVALID = "oog_invalid_opcode" # OOG due to INVALID opcode + + +class RefundType(Enum): + """Different refund mechanisms tested.""" + + SSTORE_DIRECT = "sstore_in_init_code" + SSTORE_CALL = "sstore_via_call" + SSTORE_DELEGATECALL = "sstore_via_delegatecall" + SSTORE_CALLCODE = "sstore_via_callcode" + SELFDESTRUCT = "selfdestruct_via_call" + LOG_OP = "log_operations" + NESTED_CREATE = "nested_create_in_init_code" + NESTED_CREATE2 = "nested_create2_in_init_code" + + +@dataclass +class HelperContracts: + """Container for deployed helper contract addresses.""" + + sstore_refund: Address + selfdestruct: Address + log_op: Address + init_code: Address + + +def deploy_helper_contracts(pre: Alloc) -> HelperContracts: + """Deploy all helper contracts needed for the tests.""" + # Simple contract to reset sstore and get refund: sstore(1, 0) + sstore_refund_code = Op.SSTORE(1, 0) + Op.STOP + sstore_refund = pre.deploy_contract( + code=sstore_refund_code, + storage={1: 1}, + ) + + # Simple contract that self-destructs to refund + selfdestruct_code = Op.SELFDESTRUCT(Op.ORIGIN) + Op.STOP + selfdestruct = pre.deploy_contract( + code=selfdestruct_code, + storage={1: 1}, + ) + + # Simple contract that performs log operations + log_op_code = ( + Op.MSTORE(0, 0xFF) + + Op.LOG0(0, 32) + + Op.LOG1(0, 32, 0xFA) + + Op.LOG2(0, 32, 0xFA, 0xFB) + + Op.LOG3(0, 32, 0xFA, 0xFB, 0xFC) + + Op.LOG4(0, 32, 0xFA, 0xFB, 0xFC, 0xFD) + + Op.STOP + ) + log_op = pre.deploy_contract( + code=log_op_code, + storage={1: 1}, + ) + + # Init code that successfully creates contract but contains a refund + # sstore(0, 1); sstore(0, 0); return(0, 1) + init_code_with_refund = Op.SSTORE(0, 1) + Op.SSTORE(0, 0) + Op.RETURN(0, 1) + init_code = pre.deploy_contract( + code=init_code_with_refund, + ) + + return HelperContracts( + sstore_refund=sstore_refund, + selfdestruct=selfdestruct, + log_op=log_op, + init_code=init_code, + ) + + +def build_init_code( + refund_type: RefundType, + oog_scenario: OogScenario, + helpers: HelperContracts, +) -> bytes: + """ + Build init code based on refund type and OOG scenario. + + All init codes: + - Write to storage slot 0 + - Optionally trigger refund mechanism + - End with either small return (success) or large return/INVALID (OOG) + """ + # Common prefix: sstore(0, 1) to mark storage access + prefix = Op.SSTORE(0, 1) + + # Build the refund-triggering portion based on type + if refund_type == RefundType.SSTORE_DIRECT: + # Direct sstore refund: sstore(1, 1); sstore(1, 0) + refund_code = Op.SSTORE(1, 1) + Op.SSTORE(1, 0) + + elif refund_type == RefundType.SSTORE_CALL: + # Call to sstore refund helper + refund_code = Op.POP( + Op.CALL(Op.GAS, helpers.sstore_refund, 0, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.SSTORE_DELEGATECALL: + # Delegatecall to sstore refund helper (needs local storage setup) + refund_code = Op.SSTORE(1, 1) + Op.POP( + Op.DELEGATECALL(Op.GAS, helpers.sstore_refund, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.SSTORE_CALLCODE: + refund_code = Op.SSTORE(1, 1) + Op.POP( + Op.CALLCODE(Op.GAS, helpers.sstore_refund, 0, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.SELFDESTRUCT: + refund_code = Op.POP( + Op.CALL(Op.GAS, helpers.selfdestruct, 0, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.LOG_OP: + # call to log op helper + refund_code = Op.POP(Op.CALL(Op.GAS, helpers.log_op, 0, 0, 0, 0, 0)) + + elif refund_type == RefundType.NESTED_CREATE: + # Nested CREATE with refund in init code + # extcodecopy the init code helper and CREATE from it + refund_code = ( + Op.SSTORE(1, 1) + + Op.SSTORE(1, 0) + + Op.EXTCODECOPY( + helpers.init_code, 0, 0, Op.EXTCODESIZE(helpers.init_code) + ) + + Op.POP(Op.CREATE(0, 0, Op.EXTCODESIZE(helpers.init_code))) + ) + + elif refund_type == RefundType.NESTED_CREATE2: + # Nested CREATE2 with refund in init code + refund_code = ( + Op.SSTORE(1, 1) + + Op.SSTORE(1, 0) + + Op.EXTCODECOPY( + helpers.init_code, 0, 0, Op.EXTCODESIZE(helpers.init_code) + ) + + Op.POP(Op.CREATE2(0, 0, Op.EXTCODESIZE(helpers.init_code), 0)) + ) + else: + refund_code = Op.STOP + + # Build the ending based on OOG scenario + if oog_scenario == OogScenario.NO_OOG: + # Return 1 byte of code (cheap code deposit) + if refund_type in ( + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ): + # For nested creates, return after init code length + ending = Op.RETURN(Op.ADD(Op.EXTCODESIZE(helpers.init_code), 1), 1) + else: + ending = Op.RETURN(0, 1) + + elif oog_scenario == OogScenario.OOG_CODE_DEPOSIT: + # Return 5000 bytes of code - code deposit cost exceeds available gas + if refund_type in ( + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ): + ending = Op.RETURN( + Op.ADD(Op.EXTCODESIZE(helpers.init_code), 1), 5000 + ) + else: + ending = Op.RETURN(0, 5000) + + elif oog_scenario == OogScenario.OOG_INVALID: + # INVALID opcode causes OOG (all gas consumed, no refund) + ending = Op.INVALID + + else: + ending = Op.STOP + + return bytes(prefix + refund_code + ending) + + +@pytest.mark.parametrize( + "oog_scenario", + [ + pytest.param(OogScenario.NO_OOG, id="no_oog"), + pytest.param(OogScenario.OOG_CODE_DEPOSIT, id="oog_code_deposit"), + pytest.param(OogScenario.OOG_INVALID, id="oog_invalid_opcode"), + ], +) +@pytest.mark.parametrize( + "refund_type", + [ + pytest.param(RefundType.SSTORE_DIRECT, id="sstore_direct"), + pytest.param(RefundType.SSTORE_CALL, id="sstore_call"), + pytest.param(RefundType.SSTORE_DELEGATECALL, id="sstore_delegatecall"), + pytest.param(RefundType.SSTORE_CALLCODE, id="sstore_callcode"), + pytest.param(RefundType.SELFDESTRUCT, id="selfdestruct"), + pytest.param(RefundType.LOG_OP, id="log_op"), + pytest.param(RefundType.NESTED_CREATE, id="nested_create"), + pytest.param(RefundType.NESTED_CREATE2, id="nested_create2"), + ], +) +@pytest.mark.ported_from( + [ + "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stCreateTest/CreateOOGFromEOARefundsFiller.yml", + ], + pr=["https://github.com/ethereum/execution-specs/pull/1831"], +) +def test_create_oog_from_eoa_refunds( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + refund_type: RefundType, + oog_scenario: OogScenario, + fork: Fork, +) -> None: + """ + Test CREATE from EOA with various refund mechanisms and OOG scenarios. + + Verifies that: + 1. Refunds are not applied when contract creation runs Out of Gas + 2. When OOG occurs, the sender's balance is fully consumed (no refund) + 3. When OOG occurs, the contract is not created + + For BAL (Block Access List) tracking: + - NoOoG: Storage writes should be recorded as `storage_changes` + - OoG: Storage writes should be converted to `storage_reads` since + the CREATE failed and all state changes were reverted + """ + helpers = deploy_helper_contracts(pre) + sender = pre.fund_eoa(amount=4_000_000) + init_code = build_init_code(refund_type, oog_scenario, helpers) + created_address = compute_create_address(address=sender, nonce=0) + + tx = Transaction( + sender=sender, + to=None, + data=init_code, + gas_limit=400_000, + ) + + post: Dict[Address, Account | None] = { + sender: Account(nonce=1), + } + + if oog_scenario == OogScenario.NO_OOG: + # contract created with code 0x00 (1 byte from memory) + if refund_type == RefundType.NESTED_CREATE: + # Nested CREATE increments the created contract's nonce to 2 + post[created_address] = Account( + nonce=2, + code=b"\x00", + storage={0: 1}, # successful write + ) + + nested_created = compute_create_address( + address=created_address, nonce=1 + ) + post[nested_created] = Account( + nonce=1, + code=b"\x00", + storage={}, + ) + elif refund_type == RefundType.NESTED_CREATE2: + # nested create2 increments the created contract's nonce to 2 + post[created_address] = Account( + nonce=2, + code=b"\x00", + storage={0: 1}, + ) + + nested_created = compute_create2_address( + address=created_address, + salt=0, + initcode=Op.SSTORE(0, 1) + Op.SSTORE(0, 0) + Op.RETURN(0, 1), + ) + post[nested_created] = Account( + nonce=1, + code=b"\x00", + storage={}, + ) + else: + post[created_address] = Account( + nonce=1, + code=b"\x00", + storage={0: 1}, + ) + post[sender] = Account(nonce=1) + else: + # OOG case: contract not created, sender balance is fully consumed + post[created_address] = Account.NONEXISTENT + post[sender] = Account( + nonce=1, + balance=0, + ) + + if refund_type == RefundType.SELFDESTRUCT: + selfdestruct_code = Op.SELFDESTRUCT(Op.ORIGIN) + Op.STOP + if oog_scenario == OogScenario.NO_OOG: + # selfdestruct succeeded, balance is 0 + post[helpers.selfdestruct] = Account( + balance=0, + nonce=1, + ) + else: + # OOG: selfdestruct reverted, helper unchanged + post[helpers.selfdestruct] = Account( + code=bytes(selfdestruct_code), + nonce=1, + storage={1: 1}, + ) + + bal_expectation = None + if fork.header_bal_hash_required(): + if oog_scenario == OogScenario.NO_OOG: + # Success: storage write to slot 0 persists + expected_nonce = ( + 2 + if refund_type + in (RefundType.NESTED_CREATE, RefundType.NESTED_CREATE2) + else 1 + ) + created_bal = BalAccountExpectation( + nonce_changes=[ + BalNonceChange( + block_access_index=1, post_nonce=expected_nonce + ) + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ) + ], + ), + ], + storage_reads=( + # noop write 0 -> 1 -> 0 + [1] + if refund_type + in ( + RefundType.SSTORE_DIRECT, + RefundType.SSTORE_DELEGATECALL, + RefundType.SSTORE_CALLCODE, + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ) + else [] + ), + ) + else: + # OOG case: storage writes converted to reads + # All refund types write to slot 0, most also write to slot 1 + if refund_type in ( + RefundType.SSTORE_DIRECT, + RefundType.SSTORE_DELEGATECALL, + RefundType.SSTORE_CALLCODE, + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ): + # write to both slot 0 and slot 1 (noop write 0 -> 1 -> 0) + created_bal = BalAccountExpectation( + storage_changes=[], + storage_reads=[0, 1], + ) + else: + # SSTORE_CALL, SELFDESTRUCT, LOG_OP only write to slot 0 + created_bal = BalAccountExpectation( + storage_changes=[], + storage_reads=[0], + ) + bal_expectation = BlockAccessListExpectation( + account_expectations={ + sender: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + created_address: created_bal, + } + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=bal_expectation)], + post=post, + ) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index e0bfa59ec2..634bfac822 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -8,8 +8,16 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessListExpectation, Bytecode, Environment, + Fork, Initcode, Op, StateTestFiller, @@ -343,6 +351,7 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 selfdestruct_with_transfer_initcode_copy_from_address: Address, recursive_revert_contract_address: Address, recursive_revert_contract_code: Bytecode, + fork: Fork, ) -> None: """ Given: @@ -427,7 +436,75 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 gas_limit=500_000, ) - state_test(env=env, pre=pre, post=post, tx=tx) + expected_block_access_list = None + if fork.header_bal_hash_required(): + account_expectations = {} + + if selfdestruct_on_outer_call > 0: + account_expectations[ + selfdestruct_with_transfer_contract_address + ] = BalAccountExpectation( + storage_reads=[0, 1], # Storage was accessed + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_changes=[], + ) + account_expectations[selfdestruct_recipient_address] = ( + BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=1 + if selfdestruct_on_outer_call == 1 + else 2, + ) + ], + ) + ) + else: + account_expectations[ + selfdestruct_with_transfer_contract_address + ] = BalAccountExpectation( + storage_reads=[1], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=selfdestruct_with_transfer_contract_code, + ), + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ), + ], + ), + ], + ) + account_expectations[selfdestruct_recipient_address] = ( + BalAccountExpectation.empty() + ) + + expected_block_access_list = BlockAccessListExpectation( + account_expectations=account_expectations + ) + + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + expected_block_access_list=expected_block_access_list, + ) @pytest.mark.parametrize( diff --git a/tests/frontier/create/test_create_deposit_oog.py b/tests/frontier/create/test_create_deposit_oog.py index 18932d3ca3..fac46ba629 100644 --- a/tests/frontier/create/test_create_deposit_oog.py +++ b/tests/frontier/create/test_create_deposit_oog.py @@ -12,7 +12,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium, Frontier +from execution_testing.forks import Frontier, TangerineWhistle SLOT_CREATE_RESULT = 1 SLOT_CREATE_RESULT_PRE = 0xDEADBEEF @@ -63,7 +63,7 @@ def test_create_deposit_oog( create_gas = return_code.gas_cost(fork) + expand_memory_code.gas_cost(fork) if not enough_gas: create_gas -= 1 - if fork >= Byzantium: + if fork >= TangerineWhistle: # Increment the gas for the 63/64 rule create_gas = (create_gas * 64) // 63 call_gas = create_gas + factory_code.gas_cost(fork) @@ -86,7 +86,7 @@ def test_create_deposit_oog( gas_limit=10_000_000, to=caller_address, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) created_account: Account | None = Account(code=b"\x00" * deposited_len) diff --git a/tests/frontier/create/test_create_one_byte.py b/tests/frontier/create/test_create_one_byte.py index a0baf294cc..cede8b537a 100644 --- a/tests/frontier/create/test_create_one_byte.py +++ b/tests/frontier/create/test_create_one_byte.py @@ -17,7 +17,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium, London +from execution_testing.forks import London @pytest.mark.ported_from( @@ -100,7 +100,7 @@ def test_create_one_byte( data=b"", nonce=0, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/create/test_create_suicide_during_init.py b/tests/frontier/create/test_create_suicide_during_init.py index e05f521468..dbd7febbce 100644 --- a/tests/frontier/create/test_create_suicide_during_init.py +++ b/tests/frontier/create/test_create_suicide_during_init.py @@ -14,7 +14,6 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium class Operation(Enum): @@ -93,7 +92,7 @@ def test_create_suicide_during_transaction_create( data=contract_initcode, value=tx_value, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/create/test_create_suicide_store.py b/tests/frontier/create/test_create_suicide_store.py index 7d3e661035..34cc31d53f 100644 --- a/tests/frontier/create/test_create_suicide_store.py +++ b/tests/frontier/create/test_create_suicide_store.py @@ -19,7 +19,6 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium class Operation(IntEnum): @@ -147,7 +146,7 @@ def test_create_suicide_store( to=create_contract, data=suicide_initcode, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/opcodes/test_all_opcodes.py b/tests/frontier/opcodes/test_all_opcodes.py index 022a1f4968..bf87d21836 100644 --- a/tests/frontier/opcodes/test_all_opcodes.py +++ b/tests/frontier/opcodes/test_all_opcodes.py @@ -21,7 +21,6 @@ UndefinedOpcodes, gas_test, ) -from execution_testing.forks import Byzantium REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -183,7 +182,7 @@ def test_stack_overflow( gas_limit=100_000, to=contract, sender=pre.fund_eoa(), - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) expected_storage = { slot_code_worked: value_code_failed if fails else value_code_worked diff --git a/tests/frontier/opcodes/test_blockhash.py b/tests/frontier/opcodes/test_blockhash.py index 9c42639ed7..de0b7a3034 100644 --- a/tests/frontier/opcodes/test_blockhash.py +++ b/tests/frontier/opcodes/test_blockhash.py @@ -9,7 +9,6 @@ Op, Transaction, ) -from execution_testing.forks import Byzantium from execution_testing.forks.helpers import Fork @@ -60,7 +59,7 @@ def test_genesis_hash_available( sender=sender, to=contract, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) ] if not setup_blocks_empty @@ -76,7 +75,7 @@ def test_genesis_hash_available( sender=sender, to=contract, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) ] ) diff --git a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py index abc9d9a28c..3c178fb506 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -40,13 +40,22 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessListExpectation, Bytecode, Environment, Op, StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Berlin, Byzantium, Homestead +from execution_testing.forks.forks.forks import ( + Berlin, + Byzantium, + Homestead, +) from execution_testing.forks.helpers import Fork @@ -71,33 +80,42 @@ def sufficient_gas( Calculate the sufficient gas for the nested call opcode with positive value transfer. """ - # memory_exp_cost is zero for our case. + gas_costs = fork.gas_costs() + cost = 0 if fork >= Berlin: - cost += 2600 # call and address_access_cost + cost += gas_costs.G_COLD_ACCOUNT_ACCESS elif Byzantium <= fork < Berlin: - cost += 700 # call + cost += 700 # Pre-Berlin warm call cost elif fork == Homestead: - cost += 40 # call + cost += 40 # Homestead call cost cost += 1 # mandatory callee gas allowance else: raise Exception("Only forks Homestead and >=Byzantium supported") is_value_call = callee_opcode in [Op.CALL, Op.CALLCODE] if is_value_call: - cost += 9000 # positive_value_cost + cost += gas_costs.G_CALL_VALUE if callee_opcode == Op.CALL: - cost += 25000 # empty_account_cost + cost += gas_costs.G_NEW_ACCOUNT + + sufficient = callee_init_stack_gas + cost - cost += callee_init_stack_gas + return sufficient - return cost + +@pytest.fixture +def empty_account(pre: Alloc) -> Address: + """A guaranteed-to-be-empty account.""" + return pre.empty_account() @pytest.fixture -def callee_code(pre: Alloc, callee_opcode: Op, fork: Fork) -> Bytecode: +def callee_code( + callee_opcode: Op, fork: Fork, empty_account: Address +) -> Bytecode: """ Code called by the caller contract: PUSH1 0x00 * 4 @@ -119,7 +137,7 @@ def callee_code(pre: Alloc, callee_opcode: Op, fork: Fork) -> Bytecode: return callee_opcode( unchecked=False, gas=1 if fork < Byzantium else Op.GAS, - address=pre.empty_account(), + address=empty_account, args_offset=0, args_size=0, ret_offset=0, @@ -182,7 +200,7 @@ def caller_tx(sender: EOA, caller_address: Address, fork: Fork) -> Transaction: value=1, gas_limit=500_000, sender=sender, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) @@ -197,6 +215,73 @@ def post( # noqa: D103 } +@pytest.fixture +def expected_block_access_list( + fork: Fork, + caller_address: Address, + callee_address: Address, + callee_opcode: Bytecode, + empty_account: Account, + gas_shortage: int, +) -> None | BlockAccessListExpectation: + """The expected block access list for >=Amsterdam cases.""" + if fork.header_bal_hash_required(): + if callee_opcode == Op.CALL: + if gas_shortage: + # call runs OOG after state access due to `is_account_alive` in + # `create_gas_cost` check + empty_account_expectation = BalAccountExpectation.empty() + else: + empty_account_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=1) + ] + ) + else: + if gas_shortage: + # runs OOG before accessing empty acct (not read) + empty_account_expectation = None + else: + # if successful, only read is recorded + empty_account_expectation = BalAccountExpectation.empty() + + return BlockAccessListExpectation( + account_expectations={ + empty_account: empty_account_expectation, + caller_address: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=4) + ], + storage_reads=[0] if gas_shortage else [], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + block_access_index=1, post_value=1 + ), + ], + ), + ] + if not gas_shortage + else [], + ), + callee_address: BalAccountExpectation( + balance_changes=( + [ + BalBalanceChange( + block_access_index=1, post_balance=2 + ) + ] + if not gas_shortage and callee_opcode == Op.CALL + else [] + ), + ), + } + ) + return None + + @pytest.mark.parametrize( "callee_opcode", [Op.CALL, Op.CALLCODE, Op.DELEGATECALL, Op.STATICCALL] ) @@ -207,12 +292,19 @@ def test_value_transfer_gas_calculation( pre: Alloc, caller_tx: Transaction, post: Dict[str, Account], + expected_block_access_list: BlockAccessListExpectation, ) -> None: """ Tests the nested CALL/CALLCODE/DELEGATECALL/STATICCALL opcode gas consumption with a positive value transfer. """ - state_test(env=Environment(), pre=pre, post=post, tx=caller_tx) + state_test( + env=Environment(), + pre=pre, + post=post, + tx=caller_tx, + expected_block_access_list=expected_block_access_list, + ) @pytest.mark.parametrize( diff --git a/tests/frontier/opcodes/test_calldatacopy.py b/tests/frontier/opcodes/test_calldatacopy.py index fae9897356..93012bb4dd 100644 --- a/tests/frontier/opcodes/test_calldatacopy.py +++ b/tests/frontier/opcodes/test_calldatacopy.py @@ -10,7 +10,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Byzantium @pytest.mark.ported_from( @@ -193,7 +192,7 @@ def test_calldatacopy( data=tx_data, gas_limit=100_000, gas_price=0x0A, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=to, value=0x01, diff --git a/tests/frontier/opcodes/test_calldataload.py b/tests/frontier/opcodes/test_calldataload.py index b22638ebdf..d9ee82225e 100644 --- a/tests/frontier/opcodes/test_calldataload.py +++ b/tests/frontier/opcodes/test_calldataload.py @@ -10,7 +10,6 @@ Transaction, ) from execution_testing import Macros as Om -from execution_testing.forks import Byzantium @pytest.mark.ported_from( @@ -92,7 +91,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=to, ) @@ -101,7 +100,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=contract_address, ) diff --git a/tests/frontier/opcodes/test_calldatasize.py b/tests/frontier/opcodes/test_calldatasize.py index 4adf1a8e4d..7b190f8b5c 100644 --- a/tests/frontier/opcodes/test_calldatasize.py +++ b/tests/frontier/opcodes/test_calldatasize.py @@ -10,7 +10,6 @@ Transaction, ) from execution_testing import Macros as Om -from execution_testing.forks import Byzantium @pytest.mark.ported_from( @@ -69,7 +68,7 @@ def test_calldatasize( tx = Transaction( gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=to, ) @@ -78,7 +77,7 @@ def test_calldatasize( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), sender=pre.fund_eoa(), to=contract_address, ) diff --git a/tests/frontier/opcodes/test_dup.py b/tests/frontier/opcodes/test_dup.py index 35fd3c428f..210c744072 100644 --- a/tests/frontier/opcodes/test_dup.py +++ b/tests/frontier/opcodes/test_dup.py @@ -5,12 +5,12 @@ Account, Alloc, Environment, + Fork, Op, StateTestFiller, Storage, Transaction, ) -from execution_testing.forks import Frontier, Homestead @pytest.mark.parametrize( @@ -38,7 +38,7 @@ @pytest.mark.with_all_evm_code_types def test_dup( state_test: StateTestFiller, - fork: str, + fork: Fork, dup_opcode: Op, pre: Alloc, ) -> None: @@ -71,7 +71,9 @@ def test_dup( ty=0x0, to=account, gas_limit=500000, - protected=False if fork in [Frontier, Homestead] else True, + gas_price=10, + protected=fork.supports_protected_txs(), + data="", sender=sender, ) diff --git a/tests/frontier/opcodes/test_push.py b/tests/frontier/opcodes/test_push.py index 707264aa07..c1e59bf9f0 100644 --- a/tests/frontier/opcodes/test_push.py +++ b/tests/frontier/opcodes/test_push.py @@ -18,7 +18,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Frontier, Homestead def get_input_for_push_opcode(opcode: Op) -> bytes: @@ -77,7 +76,7 @@ def test_push( sender=pre.fund_eoa(), to=contract, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) post = {} @@ -149,7 +148,7 @@ def test_stack_overflow( sender=pre.fund_eoa(), to=contract, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) post = {} diff --git a/tests/frontier/opcodes/test_swap.py b/tests/frontier/opcodes/test_swap.py index e271bec920..2de28b8c37 100644 --- a/tests/frontier/opcodes/test_swap.py +++ b/tests/frontier/opcodes/test_swap.py @@ -13,7 +13,6 @@ Bytecode, Environment, ) -from execution_testing.forks import Frontier, Homestead from execution_testing import Op from execution_testing import ( StateTestFiller, @@ -76,7 +75,7 @@ def test_swap( sender=pre.fund_eoa(), to=contract_address, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) # Calculate expected storage values after SWAP and storage operations @@ -146,7 +145,7 @@ def test_stack_underflow( sender=pre.fund_eoa(), to=contract, gas_limit=500_000, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork.supports_protected_txs(), ) # Define the expected post-state. diff --git a/tests/frontier/precompiles/test_ecrecover.py b/tests/frontier/precompiles/test_ecrecover.py index d248b67f4c..a6fa669bef 100644 --- a/tests/frontier/precompiles/test_ecrecover.py +++ b/tests/frontier/precompiles/test_ecrecover.py @@ -8,7 +8,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -388,7 +387,7 @@ def test_precompiles( to=account, sender=pre.fund_eoa(), gas_limit=1_000_000, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: output})} diff --git a/tests/frontier/precompiles/test_ripemd.py b/tests/frontier/precompiles/test_ripemd.py index dc34ce1ad8..129c555845 100644 --- a/tests/frontier/precompiles/test_ripemd.py +++ b/tests/frontier/precompiles/test_ripemd.py @@ -8,7 +8,6 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Byzantium from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -176,7 +175,7 @@ def test_precompiles( sender=pre.fund_eoa(), gas_limit=1_000_0000, data=msg, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: output if not oog else 0})} diff --git a/tests/homestead/yul/__init__.py b/tests/homestead/yul/__init__.py deleted file mode 100644 index 172309b311..0000000000 --- a/tests/homestead/yul/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests using Yul source for contracts.""" diff --git a/tests/json_infra/__init__.py b/tests/json_infra/__init__.py index fd2db5553f..605a843a16 100644 --- a/tests/json_infra/__init__.py +++ b/tests/json_infra/__init__.py @@ -27,9 +27,13 @@ class _FixtureSource(TypedDict): "fixture_path": "tests/json_infra/fixtures/ethereum_tests", }, "latest_fork_tests": { - "url": "https://github.com/ethereum/execution-spec-tests/releases/download/v5.0.0/fixtures_develop.tar.gz", + "url": "https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz", "fixture_path": "tests/json_infra/fixtures/latest_fork_tests", }, + "amsterdam_tests": { + "url": "https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v3.0.1/fixtures_bal.tar.gz", + "fixture_path": "tests/json_infra/fixtures/amsterdam_tests", + }, } diff --git a/tests/london/eip1559_fee_market_change/test_tx_type.py b/tests/london/eip1559_fee_market_change/test_tx_type.py index d4e2caaa84..c8ade2fb6b 100644 --- a/tests/london/eip1559_fee_market_change/test_tx_type.py +++ b/tests/london/eip1559_fee_market_change/test_tx_type.py @@ -13,7 +13,6 @@ TransactionException, ) from execution_testing import Opcodes as Op -from execution_testing.forks import Byzantium from .spec import ref_spec_1559 @@ -62,7 +61,7 @@ def test_eip1559_tx_validity( sender=sender, gas_limit=100_000, max_priority_fee_per_gas=1, - protected=fork >= Byzantium, + protected=fork.supports_protected_txs(), error=TransactionException.TYPE_2_TX_PRE_FORK if not valid else None, ) diff --git a/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py b/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py index af7e3191ba..fb062f413f 100644 --- a/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py +++ b/tests/osaka/eip7934_block_rlp_limit/test_max_block_rlp_size.py @@ -27,7 +27,6 @@ ) from execution_testing.fixtures.blockchain import ( FixtureBlockBase, - FixtureHeader, FixtureWithdrawal, ) @@ -71,43 +70,22 @@ def block_errors() -> List[BlockException]: return [BlockException.RLP_BLOCK_LIMIT_EXCEEDED] -def create_test_header(gas_used: int) -> FixtureHeader: - """Create a standard test header for RLP size calculations.""" - return FixtureHeader( - difficulty="0x0", - number="0x1", - gas_limit=hex(BLOCK_GAS_LIMIT), - timestamp=hex(HEADER_TIMESTAMP), - fee_recipient="0x" + "00" * 20, - parent_hash="0x" + "00" * 32, - ommers_hash="0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - state_root="0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - transactions_trie="0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - receipts_root="0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - logs_bloom="0x" + "00" * 256, - gas_used=hex(gas_used), - extra_data=EXTRA_DATA_AT_LIMIT.hex(), - prev_randao="0x" + "00" * 32, - nonce="0x0000000000000042", - base_fee_per_gas="0x0", - withdrawals_root="0x" + "00" * 32, - blob_gas_used="0x0", - excess_blob_gas="0x0", - parent_beacon_block_root="0x" + "00" * 32, - requests_hash="0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ) - - def get_block_rlp_size( + fork: Fork, transactions: List[Transaction], - gas_used: int, withdrawals: List[Withdrawal] | None = None, ) -> int: """ Calculate the RLP size of a block with given transactions and withdrawals. """ - header = create_test_header(gas_used) + header = fork.build_default_block_header( + block_number=1, + timestamp=HEADER_TIMESTAMP, + ) + header.gas_limit = ZeroPaddedHexNumber(BLOCK_GAS_LIMIT) + header.extra_data = Bytes(EXTRA_DATA_AT_LIMIT) + total_gas = sum((tx.gas_limit or 21000) for tx in transactions) header.gas_used = ZeroPaddedHexNumber(total_gas) @@ -323,7 +301,7 @@ def _exact_size_transactions_impl( total_gas_used += last_tx.gas_limit current_size = get_block_rlp_size( - transactions, gas_used=total_gas_used, withdrawals=withdrawals + fork, transactions, withdrawals=withdrawals ) remaining_bytes = block_size_limit - current_size remaining_gas = block_gas_limit - total_gas_used @@ -340,8 +318,8 @@ def _exact_size_transactions_impl( ) empty_block_size = get_block_rlp_size( + fork, transactions + [empty_tx], - gas_used=total_gas_used + empty_tx.gas_limit, withdrawals=withdrawals, ) empty_contribution = empty_block_size - current_size @@ -363,8 +341,8 @@ def _exact_size_transactions_impl( ) test_size = get_block_rlp_size( + fork, transactions + [test_tx], - gas_used=total_gas_used + target_gas, withdrawals=withdrawals, ) @@ -397,8 +375,8 @@ def _exact_size_transactions_impl( ) adjusted_test_size = get_block_rlp_size( + fork, transactions + [adjusted_tx], - gas_used=total_gas_used + adjusted_gas, withdrawals=withdrawals, ) @@ -421,8 +399,8 @@ def _exact_size_transactions_impl( transactions.append(empty_tx) final_size = get_block_rlp_size( + fork, transactions, - gas_used=sum(tx.gas_limit for tx in transactions), withdrawals=withdrawals, ) final_gas = sum(tx.gas_limit for tx in transactions) @@ -475,7 +453,7 @@ def test_block_at_rlp_size_limit_boundary( pre, env.gas_limit, ) - block_rlp_size = get_block_rlp_size(transactions, gas_used=gas_used) + block_rlp_size = get_block_rlp_size(fork, transactions) assert block_rlp_size == block_size_limit, ( f"Block RLP size {block_rlp_size} does not exactly match " f"limit {block_size_limit}, difference: " @@ -528,7 +506,7 @@ def test_block_rlp_size_at_limit_with_all_typed_transactions( env.gas_limit, specific_transaction_to_include=typed_transaction, ) - block_rlp_size = get_block_rlp_size(transactions, gas_used=gas_used) + block_rlp_size = get_block_rlp_size(fork, transactions) assert block_rlp_size == block_size_limit, ( f"Block RLP size {block_rlp_size} does not exactly match limit " f"{block_size_limit}, difference: {block_rlp_size - block_size_limit} " @@ -572,7 +550,7 @@ def test_block_at_rlp_limit_with_logs( emit_logs=True, ) - block_rlp_size = get_block_rlp_size(transactions, gas_used=gas_used) + block_rlp_size = get_block_rlp_size(fork, transactions) assert block_rlp_size == block_size_limit, ( f"Block RLP size {block_rlp_size} does not exactly match limit " f"{block_size_limit}, difference: {block_rlp_size - block_size_limit} " @@ -632,7 +610,7 @@ def test_block_at_rlp_limit_with_withdrawals( ) block_rlp_size = get_block_rlp_size( - transactions, gas_used=gas_used, withdrawals=withdrawals + fork, transactions, withdrawals=withdrawals ) assert block_rlp_size == block_size_limit, ( f"Block RLP size {block_rlp_size} does not exactly match limit " @@ -703,8 +681,8 @@ def test_fork_transition_block_rlp_limit( ) for fork_block_rlp_size in [ - get_block_rlp_size(transactions_before, gas_used=gas_used_before), - get_block_rlp_size(transactions_at_fork, gas_used=gas_used_at_fork), + get_block_rlp_size(fork, transactions_before), + get_block_rlp_size(fork, transactions_at_fork), ]: assert fork_block_rlp_size == block_size_limit, ( f"Block RLP size {fork_block_rlp_size} does not exactly match " diff --git a/tests/paris/eip7610_create_collision/test_initcollision.py b/tests/paris/eip7610_create_collision/test_initcollision.py index 2f5c42789f..8c25f64674 100644 --- a/tests/paris/eip7610_create_collision/test_initcollision.py +++ b/tests/paris/eip7610_create_collision/test_initcollision.py @@ -7,7 +7,10 @@ from execution_testing import ( Account, Alloc, + BalAccountExpectation, + BlockAccessListExpectation, Bytecode, + Fork, Initcode, Op, StateTestFiller, @@ -66,6 +69,7 @@ def test_init_collision_create_tx( collision_balance: int, collision_code: bytes, initcode: Bytecode, + fork: Fork, ) -> None: """ Test that a contract creation transaction exceptionally aborts when @@ -89,6 +93,14 @@ def test_init_collision_create_tx( code=collision_code, ) + expected_block_access_list = None + if fork.header_bal_hash_required(): + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + created_contract_address: BalAccountExpectation.empty() + } + ) + state_test( pre=pre, post={ @@ -97,6 +109,7 @@ def test_init_collision_create_tx( ), }, tx=tx, + expected_block_access_list=expected_block_access_list, ) diff --git a/tests/prague/eip7702_set_code_tx/test_gas.py b/tests/prague/eip7702_set_code_tx/test_gas.py index 048a84c44b..93c2747019 100644 --- a/tests/prague/eip7702_set_code_tx/test_gas.py +++ b/tests/prague/eip7702_set_code_tx/test_gas.py @@ -18,6 +18,9 @@ Address, Alloc, AuthorizationTuple, + BalAccountExpectation, + BalNonceChange, + BlockAccessListExpectation, Bytecode, Bytes, ChainConfig, @@ -1269,6 +1272,27 @@ def test_call_to_pre_authorized_oog( sender=pre.fund_eoa(), ) + expected_block_access_list = None + if fork.header_bal_hash_required(): + # Sender nonce changes, callee is accessed but storage unchanged (OOG) + # auth_signer is tracked (we read its code to check delegation) + # delegation is NOT tracked (OOG before reading it) + account_expectations = { + tx.sender: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + callee_address: BalAccountExpectation.empty(), + # read for calculating delegation access cost: + auth_signer: BalAccountExpectation.empty(), + # OOG - not enough gas for delegation access: + delegation: None, + } + expected_block_access_list = BlockAccessListExpectation( + account_expectations=account_expectations + ) + state_test( pre=pre, tx=tx, @@ -1277,4 +1301,5 @@ def test_call_to_pre_authorized_oog( auth_signer: Account(code=Spec.delegation_designation(delegation)), delegation: Account(storage=Storage()), }, + expected_block_access_list=expected_block_access_list, ) diff --git a/tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml b/tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml deleted file mode 100644 index 4ce14cc9fe..0000000000 --- a/tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml +++ /dev/null @@ -1,483 +0,0 @@ -CreateOOGFromEOARefunds: - # Test that verifies the refunds are not applied on contract creation when the creation runs Out of Gas - env: - currentCoinbase: 2adc25665018aa1fe0e6bc666dac8fc2697ff9ba - currentDifficulty: '0x20000' - currentGasLimit: 0x100000000 - currentNumber: "1" - currentTimestamp: "1000" - - pre: - #### MAIN CALLER - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - balance: '0x3d0900' - code: '0x' - nonce: '1' - storage: {} - - ### HELPER CONTRACTS - # Simple contract to reset sstore and refund - 00000000000000000000000000000000000c0deA: - balance: '0' - code: | - :yul berlin - { - // Simple SSTORE to zero to get a refund - sstore(1, 0) - } - nonce: '1' - storage: { - '1': '1' - } - - # Simple contract that self-destructs to refund - 00000000000000000000000000000000000c0deD: - balance: '0' - code: | - :yul berlin - { - selfdestruct(origin()) - } - nonce: '1' - storage: { - '1': '1' - } - - # Simple contract that performs log operations - 00000000000000000000000000000000000c0de0: - balance: '0' - code: | - :yul berlin - { - mstore(0, 0xff) - log0(0, 32) - log1(0, 32, 0xfa) - log2(0, 32, 0xfa, 0xfb) - log3(0, 32, 0xfa, 0xfb, 0xfc) - log4(0, 32, 0xfa, 0xfb, 0xfc, 0xfd) - } - nonce: '1' - storage: { - '1': '1' - } - - # Init code that successfully creates contract but contains a refund - 00000000000000000000000000000000000c0de1: - balance: '0' - code: | - :yul berlin - { - sstore(0, 1) - sstore(0, 0) - return(0, 1) - } - nonce: '1' - storage: {} - - - transaction: - data: - # Create from EOA, Sstore Refund in Init Code, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - return(0, 1) - } - - # Create from EOA, Sstore Refund in Init Code, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in Init Code, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - invalid() - } - - # Create from EOA, Sstore Refund in Call, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Sstore Refund in Call, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in Call, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Sstore Refund in DelegateCall, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Sstore Refund in DelegateCall, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in DelegateCall, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Sstore Refund in CallCode, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Sstore Refund in CallCode, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in CallCode, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Refund Self-destruct call, no OoG - - :label SelfDestruct_Refund_NoOoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Refund Self-destruct call, OoG on Code Deposit - - :label SelfDestruct_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Refund Self-destruct call, OoG on Invalid opcode - - :label SelfDestruct_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Log operation in call, no OoG - - :label LogOp_NoOoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Log operation in call, OoG on Code Deposit - - :label LogOp_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Log operation in call, OoG on Invalid opcode - - :label LogOp_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Refund within CREATE, no OoG - - :label SStore_Create_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create(0, 0, initcodelength)) - return(add(initcodelength, 1), 1) - } - - # Create from EOA, Refund within CREATE, OoG on Code Deposit - - :label SStore_Create_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create(0, 0, initcodelength)) - return(add(initcodelength, 1), 5000) - } - - # Create from EOA, Refund within CREATE, OoG on Invalid opcode - - :label SStore_Create_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create(0, 0, initcodelength)) - invalid() - } - - # Create2 from EOA, Refund within CREATE, no OoG - - :label SStore_Create2_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - //let initcodelength := extcodesize(initcodeaddr) - //extcodecopy(initcodeaddr, 0, 0, initcodelength) - - //protection from solc version changing the init code - - let initcodelength := 15 - mstore(0, 0x6001600055600060005560016000f30000000000000000000000000000000000) - - pop(create2(0, 0, initcodelength, 0)) - return(add(initcodelength, 1), 1) - } - - # Create2 from EOA, Refund within CREATE, OoG on Code Deposit - - :label SStore_Create2_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create2(0, 0, initcodelength, 0)) - return(add(initcodelength, 1), 5000) - } - - # Create2 from EOA, Refund within CREATE, OoG on Invalid opcode - - :label SStore_Create2_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create2(0, 0, initcodelength, 0)) - invalid() - } - - gasLimit: - - 0x61a80 - gasPrice: '10' - nonce: '1' - to: "" - secretKey: "45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" - value: - - 0 - - expect: - - - indexes: - data: - - :label SStore_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 1 - code: '0x00' - storage: { - '0': 1 - } - - indexes: - data: - - :label SStore_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - - - - indexes: - data: - - :label SelfDestruct_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 1 - code: '0x00' - storage: { - '0': 1 - } - 00000000000000000000000000000000000c0deD: - balance: 0 - nonce: 1 - - - indexes: - data: - - :label SelfDestruct_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - - 00000000000000000000000000000000000c0deD: - code: '0x32FF' - nonce: '1' - storage: { - '1': '1' - } - - - indexes: - data: - - :label LogOp_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 1 - code: '0x00' - storage: { - '0': 1 - } - - indexes: - data: - - :label LogOp_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - - - indexes: - data: - - :label SStore_Create_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 2 - code: '0x00' - storage: { - '0': 1 - } - e3476106159f87477ad639e3ddcbb6b240efe459: - nonce: 1 - code: '0x00' - storage: {} - - - indexes: - data: - - :label SStore_Create_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - e3476106159f87477ad639e3ddcbb6b240efe459: - shouldnotexist: 1 - - - indexes: - data: - - :label SStore_Create2_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 2 - code: '0x00' - storage: { - '0': 1 - } - 1eeb9ca3824a07c140fc01aa562a3a896f44e790: - nonce: 1 - code: '0x00' - storage: {} - - - indexes: - data: - - :label SStore_Create2_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - 1eeb9ca3824a07c140fc01aa562a3a896f44e790: - shouldnotexist: 1 diff --git a/tests/tangerine_whistle/__init__.py b/tests/tangerine_whistle/__init__.py new file mode 100644 index 0000000000..2d6e14d640 --- /dev/null +++ b/tests/tangerine_whistle/__init__.py @@ -0,0 +1,4 @@ +""" +Test cases for EVM functionality introduced in Tangerine, [EIP-608: Hardfork +Meta - Tangerine Whistle](https://eips.ethereum.org/EIPS/eip-608). +""" diff --git a/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py b/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py new file mode 100644 index 0000000000..87e9060643 --- /dev/null +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py @@ -0,0 +1 @@ +"""Tests for EIP-150 operation gas costs in the Tangerine Whistle fork.""" diff --git a/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py b/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py new file mode 100644 index 0000000000..edd24dd82d --- /dev/null +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py @@ -0,0 +1,21 @@ +""" +[EIP-150: Operation Gas Costs](https://eips.ethereum.org/EIPS/eip-150) +introduced changes to the gas costs of certain EVM operations to mitigate DOS +attacks. This module contains tests that verify the correct implementation +of these gas cost changes in the Ethereum Virtual Machine (EVM). +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_150 = ReferenceSpec( + "EIPS/eip-150.md", "34acf72522b989d86e76efcaf42eba4cdb0b31ad" +) diff --git a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py new file mode 100644 index 0000000000..df3674129b --- /dev/null +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -0,0 +1,1223 @@ +""" +Tests for EIP-150 SELFDESTRUCT operation gas costs. + +EIP-150 introduced G_SELF_DESTRUCT for SELFDESTRUCT and precise gas +boundaries for state access during the operation. +""" + +from typing import Dict + +import pytest +from execution_testing import ( + EOA, + AccessList, + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Initcode, + Op, + Transaction, + compute_create_address, +) +from execution_testing import ( + Macros as Om, +) +from execution_testing.forks import ( + Berlin, + Cancun, + SpuriousDragon, +) +from execution_testing.forks.helpers import Fork + +from .spec import ref_spec_150 + +REFERENCE_SPEC_GIT_PATH = ref_spec_150.git_path +REFERENCE_SPEC_VERSION = ref_spec_150.version + + +# --- helper functions --- # + + +def calculate_selfdestruct_gas( + fork: Fork, + beneficiary_warm: bool, + beneficiary_dead: bool, + originator_balance: int, +) -> int: + """Calculate exact gas needed for SELFDESTRUCT.""" + gas_costs = fork.gas_costs() + gas = ( + # PUSH + SELFDESTRUCT + gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + ) + + # Cold access cost (>=Berlin only) + if fork >= Berlin and not beneficiary_warm: + gas += gas_costs.G_COLD_ACCOUNT_ACCESS + + # G_NEW_ACCOUNT: + # - Pre-EIP-161 (TangerineWhistle): charged when beneficiary is dead + # - Post-EIP-161 (>=SpuriousDragon): charged when beneficiary is dead + # AND originator has balance > 0 + if beneficiary_dead: + if fork >= SpuriousDragon: + if originator_balance > 0: + gas += gas_costs.G_NEW_ACCOUNT + else: + # Pre-EIP-161: always charged when beneficiary is dead + gas += gas_costs.G_NEW_ACCOUNT + + return gas + + +def setup_selfdestruct_test( + pre: Alloc, + fork: Fork, + beneficiary: Address, + originator_balance: int, + same_tx: bool, + beneficiary_warm: bool, + inner_call_gas: int, +) -> tuple[Address, Address, Address, Transaction]: + """ + Set up SELFDESTRUCT test with caller contract pattern. + + Returns: (alice, caller, victim, tx) + """ + alice = pre.fund_eoa() + victim_code = Op.SELFDESTRUCT(beneficiary) + + if same_tx: + # Deploy and selfdestruct in same transaction via factory + initcode = Initcode(deploy_code=victim_code) + initcode_len = len(initcode) + + factory_code = Om.MSTORE(initcode, 0) + Op.CALL( + gas=inner_call_gas, + address=Op.CREATE( + value=originator_balance, offset=0, size=initcode_len + ), + ) + caller = pre.deploy_contract( + code=factory_code, balance=originator_balance + ) + victim = compute_create_address(address=caller, nonce=1) + else: + # Pre-existing contract + victim = pre.deploy_contract( + code=victim_code, balance=originator_balance + ) + caller = pre.deploy_contract( + code=Op.CALL(gas=inner_call_gas, address=victim) + ) + + # Warm beneficiary via access list (>=Berlin only, + # doesn't add to BAL >= Amsterdam) + access_list = ( + [AccessList(address=beneficiary, storage_keys=[])] + if beneficiary_warm and fork >= Berlin + else None + ) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + protected=fork.supports_protected_txs(), + access_list=access_list, + ) + + return alice, caller, victim, tx + + +def build_bal_expectations( + fork: Fork, + alice: Address, + caller: Address, + victim: Address, + beneficiary: Address, + originator_balance: int, + beneficiary_initial_balance: int, + same_tx: bool, + success: bool, + beneficiary_in_bal: bool, +) -> BlockAccessListExpectation | None: + """Build BAL expectations for >=Amsterdam.""" + if not fork.header_bal_hash_required(): + return None + + victim_code = Op.SELFDESTRUCT(beneficiary) + + # Beneficiary expectation + if not beneficiary_in_bal: + beneficiary_expectation: BalAccountExpectation | None = None + elif not success: + beneficiary_expectation = BalAccountExpectation.empty() + else: + # Success: balance transferred + final_balance = beneficiary_initial_balance + originator_balance + if final_balance > beneficiary_initial_balance: + beneficiary_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=final_balance + ) + ], + ) + else: + beneficiary_expectation = BalAccountExpectation.empty() + + # Victim expectation + if same_tx: + if success: + # Created and destroyed in same tx - no net changes + victim_expectation = BalAccountExpectation.empty() + else: + # OOG: CREATE succeeded but SELFDESTRUCT failed + # Only include balance_changes if originator_balance > 0 + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, new_code=bytes(victim_code) + ) + ], + ) + if originator_balance > 0: + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, post_balance=originator_balance + ) + ) + else: + if success and originator_balance > 0: + victim_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + victim_expectation = BalAccountExpectation.empty() + + # Caller expectation + if same_tx: + caller_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + ) + if originator_balance > 0: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + else: + caller_expectation = BalAccountExpectation.empty() + + return BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: victim_expectation, + beneficiary: beneficiary_expectation, + } + ) + + +def build_post_state( + fork: Fork, + alice: Address, + caller: Address, + victim: Address, + beneficiary: Address, + originator_balance: int, + beneficiary_initial_balance: int, + same_tx: bool, + success: bool, + beneficiary_has_code: bool = False, +) -> dict: + """Build expected post state.""" + victim_code = Op.SELFDESTRUCT(beneficiary) + caller_nonce = 2 if same_tx else 1 + + if success: + contract_destroyed = fork < Cancun or same_tx + final_beneficiary_balance = ( + beneficiary_initial_balance + originator_balance + ) + + if contract_destroyed: + post: dict = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account.NONEXISTENT, + } + else: + # >=Cancun pre-existing: code preserved, balance transferred + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=0, code=victim_code), + } + + # Beneficiary: verify balance if non-empty, NONEXISTENT if empty + # Pre-EIP-161: empty accounts touched during execution persist + if final_beneficiary_balance > 0 or beneficiary_has_code: + post[beneficiary] = Account(balance=final_beneficiary_balance) + elif fork >= SpuriousDragon: + # EIP-161 (>=SpuriousDragon): empty accounts are deleted + post[beneficiary] = Account.NONEXISTENT + else: + # Pre-EIP-161: empty accounts persist after being touched + post[beneficiary] = Account(balance=0) + else: + # OOG: SELFDESTRUCT failed + if same_tx: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce, balance=0), + victim: Account(balance=originator_balance, code=victim_code), + } + else: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=originator_balance, code=victim_code), + } + + return post + + +# --- tests --- # + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.parametrize( + "beneficiary", ["eoa", "contract"], ids=["eoa", "contract"] +) +@pytest.mark.parametrize( + "warm", + [ + pytest.param( + False, id="cold", marks=pytest.mark.valid_from("TangerineWhistle") + ), + pytest.param(True, id="warm", marks=pytest.mark.valid_from("Berlin")), + ], +) +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [0, 1], + ids=["dead_beneficiary", "alive_beneficiary"], +) +def test_selfdestruct_to_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + beneficiary: str, + warm: bool, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test SELFDESTRUCT success boundary for account beneficiaries. + + - exact_gas: succeeds, balance transferred, contract destroyed + - exact_gas_minus_1: OOG, operation fails + """ + # Create beneficiary + if beneficiary == "eoa": + beneficiary_addr: EOA | Address = pre.fund_eoa( + amount=beneficiary_initial_balance + ) + else: + beneficiary_addr = pre.deploy_contract( + code=Op.STOP, balance=beneficiary_initial_balance + ) + + # Determine if beneficiary is dead (for G_NEW_ACCOUNT calculation) + # Contract with code is NOT dead even with balance=0 + beneficiary_dead = ( + beneficiary_initial_balance == 0 and beneficiary == "eoa" + ) + + # Calculate exact gas for success (includes G_NEW_ACCOUNT if applicable) + inner_call_gas = calculate_selfdestruct_gas( + fork, + beneficiary_warm=warm, + beneficiary_dead=beneficiary_dead, + originator_balance=originator_balance, + ) + if not is_success: + inner_call_gas -= 1 + + # In BAL if: success OR G_NEW_ACCOUNT charged (OOG after access) + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + beneficiary_in_bal = is_success or needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + beneficiary_addr, + originator_balance, + same_tx, + beneficiary_warm=warm, + inner_call_gas=inner_call_gas, + ) + + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_in_bal=beneficiary_in_bal, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_has_code=(beneficiary == "contract"), + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.parametrize( + "beneficiary", ["eoa", "contract"], ids=["eoa", "contract"] +) +@pytest.mark.parametrize( + "warm", + [ + pytest.param( + False, id="cold", marks=pytest.mark.valid_from("TangerineWhistle") + ), + pytest.param(True, id="warm", marks=pytest.mark.valid_from("Berlin")), + ], +) +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [0, 1], + ids=["dead_beneficiary", "alive_beneficiary"], +) +def test_selfdestruct_state_access_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + beneficiary: str, + warm: bool, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test state access boundary for account beneficiaries. + + Consensus check: beneficiary must be accessed at base cost boundary, + before G_NEW_ACCOUNT is evaluated. + + - exact_gas: beneficiary IS accessed (in BAL) + - exact_gas_minus_1: beneficiary NOT accessed (not in BAL) + """ + # Create beneficiary + if beneficiary == "eoa": + beneficiary_addr: EOA | Address = pre.fund_eoa( + amount=beneficiary_initial_balance + ) + else: + beneficiary_addr = pre.deploy_contract( + code=Op.STOP, balance=beneficiary_initial_balance + ) + + # Determine if beneficiary is dead (for G_NEW_ACCOUNT calculation) + # Contract with code is NOT dead even with balance=0 + beneficiary_dead = ( + beneficiary_initial_balance == 0 and beneficiary == "eoa" + ) + + # Calculate gas for state access boundary only (base + cold access) + # Does NOT include G_NEW_ACCOUNT + gas_costs = fork.gas_costs() + inner_call_gas = gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + if fork >= Berlin and not warm: + inner_call_gas += gas_costs.G_COLD_ACCOUNT_ACCESS + + if not is_success: + inner_call_gas -= 1 + + # Determine if operation succeeds at this gas level + # At state access boundary, we have enough gas for base + cold access + # Operation succeeds if NO G_NEW_ACCOUNT is needed: + # - Beneficiary is alive (has balance or has code) + # - OR beneficiary is dead but originator_balance=0 (>=SpuriousDragon) + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + # At exact_gas: success if no G_NEW_ACCOUNT needed + # At exact_gas_minus_1: always OOG (before state access) + operation_success = is_success and not needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + beneficiary_addr, + originator_balance, + same_tx, + beneficiary_warm=warm, + inner_call_gas=inner_call_gas, + ) + + # Key difference: beneficiary_in_bal depends on is_success + # exact_gas: state accessed, beneficiary in BAL + # exact_gas_minus_1: OOG before state access, beneficiary NOT in BAL + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_in_bal=is_success, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + beneficiary_addr, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_has_code=(beneficiary == "contract"), + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.with_all_precompiles +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [ + pytest.param( + 0, + id="dead_beneficiary", + marks=pytest.mark.pre_alloc_group( + "eip150_selfdestruct_precompile_dead" + ), + ), + pytest.param( + 1, + id="alive_beneficiary", + marks=pytest.mark.pre_alloc_group( + "eip150_selfdestruct_precompile_alive" + ), + ), + ], +) +@pytest.mark.valid_from("TangerineWhistle") +def test_selfdestruct_to_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + precompile: Address, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test SELFDESTRUCT success boundary for precompile beneficiaries. + + Precompiles are always warm (no cold access charge). + + - exact_gas: succeeds, balance transferred, contract destroyed + - exact_gas_minus_1: OOG, operation fails + """ + # Fund precompile if needed + if beneficiary_initial_balance > 0: + pre.fund_address(precompile, beneficiary_initial_balance) + + # Precompiles are dead when they have no balance + beneficiary_dead = beneficiary_initial_balance == 0 + + # Calculate exact gas for success (includes G_NEW_ACCOUNT if applicable) + # Precompiles are always warm + inner_call_gas = calculate_selfdestruct_gas( + fork, + beneficiary_warm=True, # Precompiles are always warm + beneficiary_dead=beneficiary_dead, + originator_balance=originator_balance, + ) + if not is_success: + inner_call_gas -= 1 + + # In BAL if: success OR G_NEW_ACCOUNT charged (OOG after access) + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + beneficiary_in_bal = is_success or needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + precompile, + originator_balance, + same_tx, + beneficiary_warm=True, # Precompiles are always warm + inner_call_gas=inner_call_gas, + ) + + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_in_bal=beneficiary_in_bal, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=is_success, + beneficiary_has_code=False, # Precompiles don't have stored code + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.with_all_precompiles +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "beneficiary_initial_balance", + [ + pytest.param( + 0, + id="dead_beneficiary", + marks=pytest.mark.pre_alloc_group( + "eip150_selfdestruct_precompile_boundary_dead" + ), + ), + pytest.param( + 1, + id="alive_beneficiary", + marks=pytest.mark.pre_alloc_group( + "eip150_selfdestruct_precompile_boundary_alive" + ), + ), + ], +) +@pytest.mark.valid_from("TangerineWhistle") +def test_selfdestruct_to_precompile_state_access_boundary( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + precompile: Address, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, +) -> None: + """ + Test state access boundary for precompile beneficiaries. + + Consensus check: precompile must be accessed at base cost boundary, + before G_NEW_ACCOUNT is evaluated. Precompiles are always warm. + + - exact_gas: precompile IS accessed (in BAL) + - exact_gas_minus_1: precompile NOT accessed (not in BAL) + """ + # Fund precompile if needed + if beneficiary_initial_balance > 0: + pre.fund_address(precompile, beneficiary_initial_balance) + + beneficiary_dead = beneficiary_initial_balance == 0 + + # State access boundary: base cost only (no G_NEW_ACCOUNT) + gas_costs = fork.gas_costs() + inner_call_gas = gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + + if not is_success: + inner_call_gas -= 1 + + # Success at base cost if no G_NEW_ACCOUNT needed + needs_new_account = False + if beneficiary_dead: + if fork >= SpuriousDragon: + needs_new_account = originator_balance > 0 + else: + needs_new_account = True + + operation_success = is_success and not needs_new_account + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + precompile, + originator_balance, + same_tx, + beneficiary_warm=True, # Precompiles are always warm + inner_call_gas=inner_call_gas, + ) + + # Key difference: beneficiary_in_bal depends on is_success + # exact_gas: state accessed, precompile in BAL + # exact_gas_minus_1: OOG before state access, precompile NOT in BAL + expected_bal = build_bal_expectations( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_in_bal=is_success, + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + precompile, + originator_balance, + beneficiary_initial_balance, + same_tx, + success=operation_success, + beneficiary_has_code=False, # Precompiles don't have stored code + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.with_all_system_contracts +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.valid_from("Cancun") +def test_selfdestruct_to_system_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + system_contract: Address, + same_tx: bool, + originator_balance: int, +) -> None: + """ + Test SELFDESTRUCT success boundary for system contract beneficiaries. + + System contracts are always warm (no cold access charge) and always have + code (so beneficiary is never dead, no G_NEW_ACCOUNT charge). + + - exact_gas: succeeds, balance transferred + - exact_gas_minus_1: OOG, operation fails + """ + # Calculate exact gas for success + # System contracts are always warm and never dead (have code) + inner_call_gas = calculate_selfdestruct_gas( + fork, + beneficiary_warm=True, + beneficiary_dead=False, + originator_balance=originator_balance, + ) + if not is_success: + inner_call_gas -= 1 + + alice, caller, victim, tx = setup_selfdestruct_test( + pre, + fork, + system_contract, + originator_balance, + same_tx, + beneficiary_warm=True, + inner_call_gas=inner_call_gas, + ) + + # Build minimal BAL expectations for test-specific accounts only + expected_bal: BlockAccessListExpectation | None = None + if fork.header_bal_hash_required(): + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + } + + # Victim expectation + if same_tx: + if is_success: + # Created and destroyed in same tx - no net changes + victim_expectation = BalAccountExpectation.empty() + else: + # OOG: contract created but selfdestruct failed + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(Op.SELFDESTRUCT(system_contract)), + ) + ], + ) + if originator_balance > 0: + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, + post_balance=originator_balance, + ) + ) + # Caller nonce incremented for CREATE + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + ) + if originator_balance > 0 and is_success: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + account_expectations[caller] = caller_expectation + else: + # Pre-existing victim + if is_success and originator_balance > 0: + victim_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + victim_expectation = BalAccountExpectation.empty() + account_expectations[caller] = BalAccountExpectation.empty() + + account_expectations[victim] = victim_expectation + + # System contract receives balance if success and originator + # had balance + if is_success and originator_balance > 0: + account_expectations[system_contract] = BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=originator_balance + ) + ], + ) + + expected_bal = BlockAccessListExpectation( + account_expectations=account_expectations + ) + + post = build_post_state( + fork, + alice, + caller, + victim, + system_contract, + originator_balance, + beneficiary_initial_balance=0, + same_tx=same_tx, + success=is_success, + beneficiary_has_code=True, + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] +) +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.parametrize( + "same_tx", [False, True], ids=["pre_deploy", "same_tx"] +) +@pytest.mark.valid_from("TangerineWhistle") +def test_selfdestruct_to_self( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + is_success: bool, + originator_balance: int, + same_tx: bool, +) -> None: + """ + Test SELFDESTRUCT where beneficiary is the executing contract itself. + + Uses Op.SELFDESTRUCT(Op.ADDRESS) - the victim selfdestructs to itself. + + Key characteristics: + - Beneficiary is always warm (it's the executing contract) + - Beneficiary is always alive (EIP-161 nonce=1) + - No G_NEW_ACCOUNT charge + - No cold access charge (>=Berlin) + - Balance is "transferred" to self (no net change until destruction) + + Gas boundary: + - exact_gas: SELFDESTRUCT completes successfully + - exact_gas_minus_1: OOG, SELFDESTRUCT fails + + Post-destruction behavior (is_success=True only): + - Pre-Cancun or same_tx: contract destroyed, balance = 0 + - >=Cancun pre-existing: contract NOT destroyed, balance preserved + """ + alice = pre.fund_eoa() + victim_code = Op.SELFDESTRUCT(Op.ADDRESS) + + # Gas: ADDRESS + SELFDESTRUCT (no cold access, no G_NEW_ACCOUNT) + # Note: ADDRESS opcode costs G_BASE, not G_VERY_LOW like PUSH + gas_costs = fork.gas_costs() + base_gas = gas_costs.G_BASE + gas_costs.G_SELF_DESTRUCT + inner_call_gas = base_gas if is_success else base_gas - 1 + + if same_tx: + # Deploy and selfdestruct in same transaction via factory + initcode = Initcode(deploy_code=victim_code) + initcode_len = len(initcode) + + factory_code = Om.MSTORE(initcode, 0) + Op.CALL( + gas=inner_call_gas, + address=Op.CREATE( + value=originator_balance, offset=0, size=initcode_len + ), + ) + caller = pre.deploy_contract( + code=factory_code, + balance=originator_balance, + ) + victim = compute_create_address(address=caller, nonce=1) + else: + # Pre-existing contract + victim = pre.deploy_contract( + code=victim_code, balance=originator_balance + ) + caller_code = Op.CALL(gas=inner_call_gas, address=victim) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + protected=fork.supports_protected_txs(), + ) + + # Build BAL expectations + expected_bal: BlockAccessListExpectation | None = None + if fork.header_bal_hash_required(): + if same_tx: + if is_success: + # Created and destroyed in same tx - no net changes for victim + victim_expectation = BalAccountExpectation.empty() + else: + # OOG: CREATE succeeded but SELFDESTRUCT failed + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(victim_code), + ) + ], + ) + if originator_balance > 0: + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, + post_balance=originator_balance, + ) + ) + + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + ) + if originator_balance > 0: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + else: + # Pre-existing: victim in BAL + if not is_success: + # OOG: victim accessed but no state changes + victim_expectation = BalAccountExpectation.empty() + elif fork >= Cancun: + # >=Cancun success: contract survives with original balance + victim_expectation = BalAccountExpectation.empty() + elif originator_balance > 0: + # Pre-Cancun success: contract destroyed + victim_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + victim_expectation = BalAccountExpectation.empty() + caller_expectation = BalAccountExpectation.empty() + + expected_bal = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: victim_expectation, + } + ) + + # Build post state + caller_nonce = 2 if same_tx else 1 + + if not is_success: + # OOG: SELFDESTRUCT failed, contract survives + if same_tx: + post: dict = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce, balance=0), + victim: Account(balance=originator_balance, code=victim_code), + } + else: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=originator_balance, code=victim_code), + } + else: + contract_destroyed = fork < Cancun or same_tx + if contract_destroyed: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account.NONEXISTENT, + } + else: + # >=Cancun pre-existing: code preserved, balance preserved + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=originator_balance, code=victim_code), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) + + +@pytest.mark.parametrize( + "originator_balance", + [0, 1], + ids=["no_balance", "has_balance"], +) +@pytest.mark.valid_from("TangerineWhistle") +def test_initcode_selfdestruct_to_self( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + originator_balance: int, +) -> None: + """ + Test SELFDESTRUCT during initcode execution where beneficiary is self. + + Unlike test_selfdestruct_to_self, this tests the case where the initcode + itself executes SELFDESTRUCT(ADDRESS) during contract creation, before + any code is deployed. + + Key characteristics: + - During initcode, the contract has no code yet + - Contract has nonce=1 (post-EIP-161) making it non-empty + - Beneficiary is always warm (it's the executing contract) + - No G_NEW_ACCOUNT charge (contract has nonce > 0) + - No cold access charge (>=Berlin) + + Note: Gas boundary testing not possible for initcode since CREATE + doesn't accept a gas parameter - it uses all available gas. + """ + alice = pre.fund_eoa() + initcode = Op.SELFDESTRUCT(Op.ADDRESS) + initcode_len = len(initcode) + + factory_code = Om.MSTORE(initcode, 0) + Op.CREATE( + value=originator_balance, offset=0, size=initcode_len + ) + caller = pre.deploy_contract(code=factory_code, balance=originator_balance) + victim = compute_create_address(address=caller, nonce=1) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + protected=fork.supports_protected_txs(), + ) + + # Build BAL expectations + expected_bal: BlockAccessListExpectation | None = None + if fork.header_bal_hash_required(): + # Contract created and immediately destroyed - no net changes + # for victim + caller_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + ) + if originator_balance > 0: + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) + ) + + expected_bal = BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: BalAccountExpectation.empty(), + } + ) + + # Contract was created and destroyed in same tx + post: dict = { + alice: Account(nonce=1), + caller: Account(nonce=2), + victim: Account.NONEXISTENT, + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], + post=post, + ) diff --git a/tox.ini b/tox.ini index cd7ce8bfb7..22b12f6121 100644 --- a/tox.ini +++ b/tox.ini @@ -102,7 +102,7 @@ commands = --basetemp="{temp_dir}/pytest" \ --log-to "{toxworkdir}/logs" \ --clean \ - --until BPO4 \ + --until Amsterdam \ {posargs} \ tests diff --git a/whitelist.txt b/whitelist.txt index 4f73730fd5..171afdf61b 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -1293,6 +1293,7 @@ VRS vscode vv +warmup Watcherfall wd wds @@ -1335,4 +1336,10 @@ ZeroPaddedHexNumber zfill zkevm Zsh -zsh \ No newline at end of file +zsh +slot1 +slot2 +lexicographically +uint16 +uint128 +630m