From daa9b2288dd9162630993e1e37bc18817693a602 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 11 Nov 2025 08:18:46 -0700 Subject: [PATCH 01/71] feat(specs): Implement EIP-7928: Block-Level Access Lists fix(tests): Fix Amsterdam filling after rebase fix(specs): Fix issues with new ruff + mypy rules after rebase - bal -> block_access_list; re-add custom rlp encoding for block access list - bytes to uint - move away from method-style - Update EIP-7928 implementation: system contracts at index 0, migrate to RLP - System contracts (parent hash, beacon root) now use block_access_index 0 - Transactions use block_access_index 1 to len(transactions) - Post-execution changes use block_access_index len(transactions) + 1 - Migrated from SSZ to RLP encoding as per updated EIP-7928 spec - Updated all tests to match new API and structure - Replaced tx_index with block_access_index throughout codebase - add system contract logic - add markdown docstrings - update BAL format; address comments - ssz encoding and bal validation - six ssz types - bal tests - balspecs fix: do not track setting empty code to a new account (#19) fix: track implicit SLOAD within SSTORE for OOG cases (#18) refactor: Put back explicit acct tracking outside 7702 delegation path (#17) fix non-tracked 7702 authority for invalid delegations (#16) * fix non-tracked 7702 authority for invalid delegations * fix: lint issues * fix: track delegation target when loaded as call target * fix: track delegation target when loaded as call target from call opcodes * chore: fix issues with documentation generation Fix self-destruct cases with pre-execution balance cache / tracking * fix self-destruct implementation * fix self-destruct tracking balance * fix it in the bal finalization by filtering * add balance reset and fix tests * simplify pre-balance tracking not using snapshots fix duplicated code entries for in transaction self destruct fix self destruct in same transaction bug fix call/delagate call tracking bug fix zero-value transfer tracking (#6) * fix zero-value transfer tracking * fix reverted frame tracking * rename variables * fix missing addresses bug * fix: docs run & move imports to top of file refactor: move rlp_utils to block_access_lists; bal -> block_access_lists Some remaining fixes due to large refactor in `forks/osaka`: - Move BALs from amsterdam -> forks/amsterdam - rename: build -> build_block_access_list - fix docc issues move state change tracker to State correct system contract addresses Fixes to communicate with BALs EEST branch: - fix(bal): Initialize the state tracker before system contract calls - We were missing system contract calls to beacon roots and history contracts. This change initializes the state tracker before system contract calls and passes the tracker to these calls if post-Amsterdam. - fix(docs): Fix issues with toxenvs: lint, doc, json_infra - fix(t8n): Only initialize the bal_change_tracker for amsterdam - feat(fork criteria): Index upcoming forks for better ordering / fix issues - chore(forks): Fix issues from lint after rebase with Osaka latest - fix(setuptools): Update packages to include amsterdam - chore(lint): Fix 'tox -e static' issues - Fix bug in tracker Manually cherry-picked from e72991bf3876563900d5c2bcc2442b0a1eeb439f Author: nerolation - chore(tests): Attempt to resolve issues with CI tests - chore(lint): fix issues from running ``tox -e static`` locally - refactor(bal): Send BAL as a list over t8n tool - fix(amsterdam): Add change tracker to state test in t8n - chore(lint,tests): Fix tests after moving bal from osaka -> amsterdam - chore(forks): Move bals from Osaka to Amsterdam - chore(lint): Fix lint issues - refactor(bal): Send the full bal object and bal_hash over t8n - If we send the full object over JSON, we can model_validate() on ESST. - If we send the hash, once we fill the pydantic model, we can get the rlp and the hash and validate that our objects match while only really validating the parts of the BAL we are interested in for each test. - chore: point to working eest branch - chore(bals): Remove unused SSZ utils.py The SSZ implementation is no longer needed as we are now using RLP - refactor(bals): Clean up BAL module types and imports - Bytes -> Bytes32 type for storage slots - Remove unused imports / fix imports / fix linting - Update function signatures to match tracker - fix(bals-tx-index): Track bal indexes in t8n Keep track of BAL index state in t8n --- .../execution_testing/fixtures/blockchain.py | 16 + .../src/execution_testing/forks/base_fork.py | 19 + .../execution_testing/forks/forks/forks.py | 37 + .../src/execution_testing/specs/blockchain.py | 37 +- pyproject.toml | 20 + .../amsterdam/block_access_lists/__init__.py | 57 ++ .../amsterdam/block_access_lists/builder.py | 435 ++++++++++++ .../amsterdam/block_access_lists/rlp_types.py | 130 ++++ .../amsterdam/block_access_lists/rlp_utils.py | 232 ++++++ .../amsterdam/block_access_lists/tracker.py | 667 ++++++++++++++++++ src/ethereum/forks/amsterdam/blocks.py | 18 + src/ethereum/forks/amsterdam/fork.py | 63 +- src/ethereum/forks/amsterdam/state.py | 84 ++- src/ethereum/forks/amsterdam/vm/__init__.py | 6 + .../forks/amsterdam/vm/eoa_delegation.py | 12 + .../amsterdam/vm/instructions/environment.py | 17 +- .../amsterdam/vm/instructions/storage.py | 36 +- .../forks/amsterdam/vm/instructions/system.py | 37 +- .../forks/amsterdam/vm/interpreter.py | 21 + src/ethereum/forks/osaka/vm/eoa_delegation.py | 1 + src/ethereum/genesis.py | 6 + .../evm_tools/loaders/fork_loader.py | 17 + .../evm_tools/t8n/__init__.py | 43 +- src/ethereum_spec_tools/evm_tools/t8n/env.py | 3 + .../evm_tools/t8n/t8n_types.py | 94 +++ whitelist.txt | 8 +- 26 files changed, 2083 insertions(+), 33 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/__init__.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/builder.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/tracker.py diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index d5476034ab..bb8ecd943a 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -199,6 +199,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) @@ -283,6 +286,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) @@ -408,6 +416,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, diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 34979e8c49..7d4e13eb92 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -345,6 +345,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 @@ -710,6 +718,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( diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 89e62df6f9..c6b45b426f 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -443,6 +443,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 @@ -483,6 +491,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, @@ -2466,6 +2482,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.""" @@ -2479,6 +2505,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 1e1d4b63dc..6c1ca500d6 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -687,6 +687,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. @@ -695,6 +713,23 @@ 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 + 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, @@ -708,7 +743,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/pyproject.toml b/pyproject.toml index 4bf3928f95..61b5665e9c 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] @@ -372,6 +379,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) @@ -379,6 +395,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" # Built-in dictionaries to use skip = [ # Don't check these files/folders 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..856ab832bc --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -0,0 +1,57 @@ +""" +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, + validate_block_access_list_against_execution, +) +from .tracker import ( + StateChangeTracker, + begin_call_frame, + commit_call_frame, + rollback_call_frame, + set_block_access_index, + track_address_access, + track_balance_change, + track_code_change, + track_nonce_change, + track_storage_read, + track_storage_write, +) + +__all__ = [ + "BlockAccessListBuilder", + "StateChangeTracker", + "add_balance_change", + "add_code_change", + "add_nonce_change", + "add_storage_read", + "add_storage_write", + "add_touched_account", + "begin_call_frame", + "build_block_access_list", + "commit_call_frame", + "compute_block_access_list_hash", + "rollback_call_frame", + "set_block_access_index", + "rlp_encode_block_access_list", + "track_address_access", + "track_balance_change", + "track_code_change", + "track_nonce_change", + "track_storage_read", + "track_storage_write", + "validate_block_access_list_against_execution", +] 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..a9d6ee9930 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -0,0 +1,435 @@ +""" +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 Dict, List, Set + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256 + +from ..fork_types import Address +from .rlp_types import ( + AccountChanges, + BalanceChange, + BlockAccessIndex, + BlockAccessList, + CodeChange, + NonceChange, + SlotChanges, + StorageChange, +) + + +@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[Bytes32, 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[Bytes32] = 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: Bytes32, + block_access_index: BlockAccessIndex, + new_value: Bytes32, +) -> 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. Multiple writes to the same slot are tracked + separately, maintaining the order and transaction index of each change. + + 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] = [] + + 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: Bytes32 +) -> 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 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: + # Update the existing nonce change with the 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_block_access_list( + builder: BlockAccessListBuilder, +) -> BlockAccessList: + """ + Build the final [`BlockAccessList`] from accumulated changes. + + 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 + + """ + account_changes_list = [] + + 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, + ) + + account_changes_list.append(account_change) + + account_changes_list.sort(key=lambda x: x.address) + + return BlockAccessList(account_changes=tuple(account_changes_list)) 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..e4d37d6a74 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py @@ -0,0 +1,130 @@ +""" +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 Tuple + +from ethereum_types.bytes import Bytes, Bytes20, Bytes32 +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 = Bytes32 +StorageValue = Bytes32 +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, ...] + + +@slotted_freezable +@dataclass +class BlockAccessList: + """ + Block-Level Access List for EIP-7928. + Contains all addresses accessed during block execution. + RLP encoded as a list of AccountChanges. + """ + + account_changes: Tuple[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..bbcf4a3d21 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -0,0 +1,232 @@ +""" +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 .builder import BlockAccessListBuilder +from .rlp_types import MAX_CODE_SIZE, MAX_TXS, 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.account_changes: + # 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) + + +def validate_block_access_list_against_execution( + block_access_list: BlockAccessList, + block_access_list_builder: BlockAccessListBuilder | None = None, +) -> bool: + """ + Validate that a Block Access List is structurally correct and + optionally matches a builder's state. + + Parameters + ---------- + block_access_list : + The Block Access List to validate. + block_access_list_builder : + Optional Block Access List builder to validate against. + If provided, checks that the + Block Access List hash matches what would be built from + the builder's current state. + + Returns + ------- + valid : + True if the Block Access List is structurally valid and + matches the builder (if provided). + + """ + # 1. Validate structural constraints + + # Check that storage changes and reads don't overlap for the same slot + for account in block_access_list.account_changes: + changed_slots = {sc.slot for sc in account.storage_changes} + read_slots = set(account.storage_reads) + + # A slot should not be in both changes and reads (per EIP-7928) + if changed_slots & read_slots: + return False + + # 2. Validate ordering (addresses should be sorted lexicographically) + addresses = [ + account.address for account in block_access_list.account_changes + ] + if addresses != sorted(addresses): + return False + + # 3. Validate all data is within bounds + max_block_access_index = ( + MAX_TXS + 1 + ) # 0 for pre-exec, 1..MAX_TXS for txs, MAX_TXS+1 for post-exec + for account in block_access_list.account_changes: + # Validate storage slots are sorted within each account + storage_slots = [sc.slot for sc in account.storage_changes] + if storage_slots != sorted(storage_slots): + return False + + # Check storage changes + for slot_changes in account.storage_changes: + # Check changes are sorted by block_access_index + indices = [c.block_access_index for c in slot_changes.changes] + if indices != sorted(indices): + return False + + for change in slot_changes.changes: + if int(change.block_access_index) > max_block_access_index: + return False + + # Check balance changes are sorted by block_access_index + balance_indices = [ + bc.block_access_index for bc in account.balance_changes + ] + if balance_indices != sorted(balance_indices): + return False + + for balance_change in account.balance_changes: + if int(balance_change.block_access_index) > max_block_access_index: + return False + + # Check nonce changes are sorted by block_access_index + nonce_indices = [nc.block_access_index for nc in account.nonce_changes] + if nonce_indices != sorted(nonce_indices): + return False + + for nonce_change in account.nonce_changes: + if int(nonce_change.block_access_index) > max_block_access_index: + return False + + # Check code changes are sorted by block_access_index + code_indices = [cc.block_access_index for cc in account.code_changes] + if code_indices != sorted(code_indices): + return False + + for code_change in account.code_changes: + if int(code_change.block_access_index) > max_block_access_index: + return False + if len(code_change.new_code) > MAX_CODE_SIZE: + return False + + # 4. If Block Access List builder provided, validate against it + # by comparing hashes + if block_access_list_builder is not None: + from .builder import build_block_access_list + + # Build a Block Access List from the builder + expected_block_access_list = build_block_access_list( + block_access_list_builder + ) + + # Compare hashes + if compute_block_access_list_hash( + block_access_list + ) != compute_block_access_list_hash(expected_block_access_list): + return False + + return True diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py new file mode 100644 index 0000000000..66a4f1ebbd --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py @@ -0,0 +1,667 @@ +""" +Provides state change tracking functionality for building Block +Access Lists during transaction execution. + +The tracker integrates with the EVM execution to capture all state accesses +and modifications, distinguishing between actual changes and no-op operations. +It maintains a cache of pre-state values to enable accurate change detection +throughout block execution. + +See [EIP-7928] for the full specification +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ..fork_types import Address +from .builder import ( + BlockAccessListBuilder, + add_balance_change, + add_code_change, + add_nonce_change, + add_storage_read, + add_storage_write, + add_touched_account, +) +from .rlp_types import BlockAccessIndex + +if TYPE_CHECKING: + from ..state import State # noqa: F401 + + +@dataclass +class CallFrameSnapshot: + """ + Snapshot of block access list state for a single call frame. + + Used to track changes within a call frame to enable proper handling + of reverts as specified in EIP-7928. + """ + + touched_addresses: Set[Address] = field(default_factory=set) + """Addresses touched during this call frame.""" + + storage_writes: Dict[Tuple[Address, Bytes32], U256] = field( + default_factory=dict + ) + """Storage writes made during this call frame.""" + + balance_changes: Set[Tuple[Address, BlockAccessIndex, U256]] = field( + default_factory=set + ) + """Balance changes made during this call frame.""" + + nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( + default_factory=set + ) + """Nonce changes made during this call frame.""" + + code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( + default_factory=set + ) + """Code changes made during this call frame.""" + + +@dataclass +class StateChangeTracker: + """ + Tracks state changes during transaction execution for Block Access List + construction. + + This tracker maintains a cache of pre-state values and coordinates with + the [`BlockAccessListBuilder`] to record all state changes made during + block execution. It ensures that only actual changes (not no-op writes) + are recorded in the access list. + + [`BlockAccessListBuilder`]: + ref:ethereum.forks.amsterdam.block_access_lists.builder.BlockAccessListBuilder + """ + + block_access_list_builder: BlockAccessListBuilder + """ + The builder instance that accumulates all tracked changes. + """ + + pre_storage_cache: Dict[tuple, U256] = field(default_factory=dict) + """ + Cache of pre-transaction storage values, keyed by (address, slot) tuples. + This cache is cleared at the start of each transaction to track values + from the beginning of the current transaction. + """ + + pre_balance_cache: Dict[Address, U256] = field(default_factory=dict) + """ + Cache of pre-transaction balance values, keyed by address. + This cache is cleared at the start of each transaction and used by + finalize_transaction_changes to filter out balance changes where + the final balance equals the initial balance. + """ + + current_block_access_index: Uint = Uint(0) + """ + The current block access index (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + """ + + call_frame_snapshots: List[CallFrameSnapshot] = field(default_factory=list) + """ + Stack of snapshots for nested call frames to handle reverts properly. + """ + + +def set_block_access_index( + tracker: StateChangeTracker, block_access_index: Uint +) -> None: + """ + Set the current block access index for tracking changes. + + Must be called before processing each transaction/system contract + to ensure changes are associated with the correct block access index. + + Note: Block access indices differ from transaction indices: + - 0: Pre-execution (system contracts like beacon roots, block hashes) + - 1..n: Transactions (tx at index i gets block_access_index i+1) + - n+1: Post-execution (withdrawals, requests) + + Parameters + ---------- + tracker : + The state change tracker instance. + block_access_index : + The block access index (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + + """ + tracker.current_block_access_index = block_access_index + # Clear the pre-storage cache for each new transaction to ensure + # no-op writes are detected relative to the transaction start + tracker.pre_storage_cache.clear() + # Clear the pre-balance cache for each new transaction + tracker.pre_balance_cache.clear() + + +def capture_pre_state( + tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" +) -> U256: + """ + Capture and cache the pre-transaction value for a storage location. + + Retrieves the storage value from the beginning of the current transaction. + The value is cached within the transaction to avoid repeated lookups and + to maintain consistency across multiple accesses within the same + transaction. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address containing the storage. + key : + The storage slot to read. + state : + The current execution state. + + Returns + ------- + value : + The storage value at the beginning of the current transaction. + + """ + cache_key = (address, key) + if cache_key not in tracker.pre_storage_cache: + # Import locally to avoid circular import + from ..state import get_storage + + tracker.pre_storage_cache[cache_key] = get_storage(state, address, key) + return tracker.pre_storage_cache[cache_key] + + +def track_address_access( + tracker: StateChangeTracker, address: Address +) -> None: + """ + Track that an address was accessed. + + Records account access even when no state changes occur. This is + important for operations that read account data without modifying it. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address that was accessed. + + """ + add_touched_account(tracker.block_access_list_builder, address) + + +def track_storage_read( + tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" +) -> None: + """ + Track a storage read operation. + + Records that a storage slot was read and captures its pre-state value. + The slot will only appear in the final access list if it wasn't also + written to during block execution. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose storage is being read. + key : + The storage slot being read. + state : + The current execution state. + + """ + track_address_access(tracker, address) + + capture_pre_state(tracker, address, key, state) + + add_storage_read(tracker.block_access_list_builder, address, key) + + +def track_storage_write( + tracker: StateChangeTracker, + address: Address, + key: Bytes32, + new_value: U256, + state: "State", +) -> None: + """ + Track a storage write operation. + + Records storage modifications, but only if the new value differs from + the pre-state value. No-op writes (where the value doesn't change) are + tracked as reads instead, as specified in [EIP-7928]. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose storage is being modified. + key : + The storage slot being written to. + new_value : + The new value to write. + state : + The current execution state. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + + """ + track_address_access(tracker, address) + + pre_value = capture_pre_state(tracker, address, key, state) + + value_bytes = new_value.to_be_bytes32() + + if pre_value != new_value: + add_storage_write( + tracker.block_access_list_builder, + address, + key, + BlockAccessIndex(tracker.current_block_access_index), + value_bytes, + ) + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.storage_writes[(address, key)] = new_value + else: + add_storage_read(tracker.block_access_list_builder, address, key) + + +def capture_pre_balance( + tracker: StateChangeTracker, address: Address, state: "State" +) -> U256: + """ + Capture and cache the pre-transaction balance for an account. + + This function caches the balance on first access for each address during + a transaction. It must be called before any balance modifications are made + to ensure we capture the pre-transaction balance correctly. The cache is + cleared at the beginning of each transaction. + + This is used by finalize_transaction_changes to determine which balance + changes should be filtered out. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address. + state : + The current execution state. + + Returns + ------- + value : + The balance at the beginning of the current transaction. + + """ + if address not in tracker.pre_balance_cache: + # Import locally to avoid circular import + from ..state import get_account + + # Cache the current balance on first access + # This should be called before any balance modifications + account = get_account(state, address) + tracker.pre_balance_cache[address] = account.balance + return tracker.pre_balance_cache[address] + + +def track_balance_change( + tracker: StateChangeTracker, + address: Address, + new_balance: U256, +) -> None: + """ + Track a balance change for an account. + + Records the new balance after any balance-affecting operation, including + transfers, gas payments, block rewards, and withdrawals. The balance is + encoded as a 16-byte value (uint128) which is sufficient for the total + ETH supply. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose balance changed. + new_balance : + The new balance value. + + """ + track_address_access(tracker, address) + + block_access_index = BlockAccessIndex(tracker.current_block_access_index) + add_balance_change( + tracker.block_access_list_builder, + address, + block_access_index, + new_balance, + ) + + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.balance_changes.add( + (address, block_access_index, new_balance) + ) + + +def track_nonce_change( + tracker: StateChangeTracker, address: Address, new_nonce: Uint +) -> None: + """ + Track a nonce change for an account. + + Records nonce increments for both EOAs (when sending transactions) and + contracts (when performing [`CREATE`] or [`CREATE2`] operations). Deployed + contracts also have their initial nonce tracked. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose nonce changed. + new_nonce : + The new nonce value. + state : + The current execution state. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + track_address_access(tracker, address) + block_access_index = BlockAccessIndex(tracker.current_block_access_index) + nonce_u64 = U64(new_nonce) + add_nonce_change( + tracker.block_access_list_builder, + address, + block_access_index, + nonce_u64, + ) + + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.nonce_changes.add((address, block_access_index, nonce_u64)) + + +def track_code_change( + tracker: StateChangeTracker, address: Address, new_code: Bytes +) -> None: + """ + Track a code change for contract deployment. + + Records new contract code deployments via [`CREATE`], [`CREATE2`], or + [`SETCODE`] operations. This function is called when contract bytecode + is deployed to an address. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The address receiving the contract code. + new_code : + The deployed contract bytecode. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + track_address_access(tracker, address) + block_access_index = BlockAccessIndex(tracker.current_block_access_index) + add_code_change( + tracker.block_access_list_builder, + address, + block_access_index, + new_code, + ) + + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.code_changes.add((address, block_access_index, new_code)) + + +def handle_in_transaction_selfdestruct( + tracker: StateChangeTracker, address: Address +) -> None: + """ + Handle an account that self-destructed in the same transaction it was + created. + + Per EIP-7928, accounts destroyed within their creation transaction must be + included as read-only with storage writes converted to reads. Nonce and + code changes from the current transaction are also removed. + + Note: Balance changes are handled separately by + finalize_transaction_changes. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The address that self-destructed. + + """ + builder = tracker.block_access_list_builder + if address not in builder.accounts: + return + + account_data = builder.accounts[address] + current_index = tracker.current_block_access_index + + # Convert storage writes from current tx to reads + for slot in list(account_data.storage_changes.keys()): + account_data.storage_changes[slot] = [ + c + for c in account_data.storage_changes[slot] + if c.block_access_index != current_index + ] + if not account_data.storage_changes[slot]: + del account_data.storage_changes[slot] + account_data.storage_reads.add(slot) + + # Remove nonce and code changes from current transaction + account_data.nonce_changes = [ + c + for c in account_data.nonce_changes + if c.block_access_index != current_index + ] + account_data.code_changes = [ + c + for c in account_data.code_changes + if c.block_access_index != current_index + ] + + +def finalize_transaction_changes( + tracker: StateChangeTracker, state: "State" +) -> None: + """ + Finalize changes for the current transaction. + + This method is called at the end of each transaction execution to filter + out spurious balance changes. It removes all balance changes for addresses + where the post-transaction balance equals the pre-transaction balance. + + This is crucial for handling cases like: + - In-transaction self-destructs where an account with 0 balance is created + and destroyed, resulting in no net balance change + - Round-trip transfers where an account receives and sends equal amounts + + Only actual state changes are recorded in the Block Access List. + + Parameters + ---------- + tracker : + The state change tracker instance. + state : + The current execution state. + + """ + # Import locally to avoid circular import + from ..state import get_account + + builder = tracker.block_access_list_builder + current_index = tracker.current_block_access_index + + # Check each address that had balance changes in this transaction + for address in list(builder.accounts.keys()): + account_data = builder.accounts[address] + + # Get the pre-transaction balance + pre_balance = capture_pre_balance(tracker, address, state) + + # Get the current (post-transaction) balance + post_balance = get_account(state, address).balance + + # If pre-tx balance equals post-tx balance, remove all balance changes + # for this address in the current transaction + if pre_balance == post_balance: + # Filter out balance changes from the current transaction + account_data.balance_changes = [ + change + for change in account_data.balance_changes + if change.block_access_index != current_index + ] + + +def begin_call_frame(tracker: StateChangeTracker) -> None: + """ + Begin a new call frame for tracking reverts. + + Creates a new snapshot to track changes within this call frame. + This allows proper handling of reverts as specified in EIP-7928. + + Parameters + ---------- + tracker : + The state change tracker instance. + + """ + tracker.call_frame_snapshots.append(CallFrameSnapshot()) + + +def rollback_call_frame(tracker: StateChangeTracker) -> None: + """ + Rollback changes from the current call frame. + + When a call reverts, this function: + - Converts storage writes to reads + - Removes balance, nonce, and code changes + - Preserves touched addresses + + This implements EIP-7928 revert handling where reverted writes + become reads and addresses remain in the access list. + + Parameters + ---------- + tracker : + The state change tracker instance. + + """ + if not tracker.call_frame_snapshots: + return + + snapshot = tracker.call_frame_snapshots.pop() + builder = tracker.block_access_list_builder + + # Convert storage writes to reads + for (address, slot), _ in snapshot.storage_writes.items(): + # Remove the write from storage_changes + if address in builder.accounts: + account_data = builder.accounts[address] + if slot in account_data.storage_changes: + # Filter out changes from this call frame + account_data.storage_changes[slot] = [ + change + for change in account_data.storage_changes[slot] + if change.block_access_index + != tracker.current_block_access_index + ] + if not account_data.storage_changes[slot]: + del account_data.storage_changes[slot] + # Add as a read instead + account_data.storage_reads.add(slot) + + # Remove balance changes from this call frame + for address, block_access_index, new_balance in snapshot.balance_changes: + if address in builder.accounts: + account_data = builder.accounts[address] + # Filter out balance changes from this call frame + account_data.balance_changes = [ + change + for change in account_data.balance_changes + if not ( + change.block_access_index == block_access_index + and change.post_balance == new_balance + ) + ] + + # Remove nonce changes from this call frame + for address, block_access_index, new_nonce in snapshot.nonce_changes: + if address in builder.accounts: + account_data = builder.accounts[address] + # Filter out nonce changes from this call frame + account_data.nonce_changes = [ + change + for change in account_data.nonce_changes + if not ( + change.block_access_index == block_access_index + and change.new_nonce == new_nonce + ) + ] + + # Remove code changes from this call frame + for address, block_access_index, new_code in snapshot.code_changes: + if address in builder.accounts: + account_data = builder.accounts[address] + # Filter out code changes from this call frame + account_data.code_changes = [ + change + for change in account_data.code_changes + if not ( + change.block_access_index == block_access_index + and change.new_code == new_code + ) + ] + + # All touched addresses remain in the access list (already tracked) + + +def commit_call_frame(tracker: StateChangeTracker) -> None: + """ + Commit changes from the current call frame. + + Removes the current call frame snapshot without rolling back changes. + Called when a call completes successfully. + + Parameters + ---------- + tracker : + The state change tracker instance. + + """ + if tracker.call_frame_snapshots: + tracker.call_frame_snapshots.pop() diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index ba3c27e9e3..0d14066f47 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -19,6 +19,7 @@ from ethereum.crypto.hash import Hash32 +from .block_access_lists.rlp_types import BlockAccessList from .fork_types import Address, Bloom, Root from .transactions import ( AccessListTransaction, @@ -242,6 +243,16 @@ class Header: [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 """ + block_access_list_hash: Hash32 + """ + [SHA2-256] 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. + [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 @@ -295,6 +306,13 @@ class Block: A tuple of withdrawals processed in this block. """ + block_access_list: BlockAccessList + """ + Block Access List containing all accounts and storage locations accessed + during block execution. Introduced in [EIP-7928]. + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + """ + @slotted_freezable @dataclass diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 1d8bbcc106..08f5ad734c 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -16,7 +16,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U64, U256, Uint, ulen from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import ( @@ -29,6 +29,14 @@ ) 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 .block_access_lists.tracker import ( + finalize_transaction_changes, + handle_in_transaction_selfdestruct, + set_block_access_index, + track_balance_change, +) from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom from .exceptions import ( @@ -53,6 +61,7 @@ from .state import ( State, TransientStorage, + account_exists_and_is_empty, destroy_account, get_account, increment_nonce, @@ -246,6 +255,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 +277,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: @@ -764,6 +778,10 @@ def apply_body( """ block_output = vm.BlockOutput() + # Set block access index for pre-execution system contracts + # EIP-7928: System contracts use block_access_index 0 + set_block_access_index(block_env.state.change_tracker, Uint(0)) + process_unchecked_system_transaction( block_env=block_env, target_address=BEACON_ROOTS_ADDRESS, @@ -779,12 +797,21 @@ def apply_body( for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) + # EIP-7928: Post-execution uses block_access_index len(transactions) + 1 + post_execution_index = ulen(transactions) + Uint(1) + set_block_access_index( + block_env.state.change_tracker, post_execution_index + ) + process_withdrawals(block_env, block_output, withdrawals) process_general_purpose_requests( block_env=block_env, block_output=block_output, ) + block_output.block_access_list = build_block_access_list( + block_env.state.change_tracker.block_access_list_builder + ) return block_output @@ -864,6 +891,10 @@ def process_transaction( Index of the transaction in the block. """ + # EIP-7928: Transactions use block_access_index 1 to len(transactions) + # Transaction at index i gets block_access_index i+1 + set_block_access_index(block_env.state.change_tracker, index + Uint(1)) + trie_set( block_output.transactions_trie, rlp.encode(index), @@ -971,15 +1002,35 @@ def process_transaction( coinbase_balance_after_mining_fee = get_account( block_env.state, block_env.coinbase ).balance + U256(transaction_fee) + + # Always set coinbase balance to ensure proper tracking set_account_balance( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, ) + 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) + for address in tx_output.accounts_to_delete: + # EIP-7928: In-transaction self-destruct - convert storage writes to + # reads and remove nonce/code changes. Only accounts created in same + # tx are in accounts_to_delete per EIP-6780. + handle_in_transaction_selfdestruct( + block_env.state.change_tracker, address + ) destroy_account(block_env.state, address) + # EIP-7928: Finalize transaction changes + # Remove balance changes where post-tx balance equals pre-tx balance + finalize_transaction_changes( + block_env.state.change_tracker, + block_env.state, + ) + block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -1020,6 +1071,16 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(block_env.state, wd.address, increase_recipient_balance) + # Track balance change for BAL + # (withdrawals are tracked as system contract changes) + new_balance = get_account(block_env.state, wd.address).balance + track_balance_change( + block_env.state.change_tracker, wd.address, U256(new_balance) + ) + + if account_exists_and_is_empty(block_env.state, wd.address): + destroy_account(block_env.state, wd.address) + 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..3067b175d6 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -23,6 +23,14 @@ from ethereum_types.frozen import modify from ethereum_types.numeric import U256, Uint +from .block_access_lists.builder import BlockAccessListBuilder +from .block_access_lists.tracker import ( + StateChangeTracker, + capture_pre_balance, + track_balance_change, + track_code_change, + track_nonce_change, +) from .fork_types import EMPTY_ACCOUNT, Account, Address, Root from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set @@ -46,6 +54,9 @@ class State: ] ] = field(default_factory=list) created_accounts: Set[Address] = field(default_factory=set) + change_tracker: StateChangeTracker = field( + default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) + ) @dataclass @@ -440,6 +451,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 +508,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) @@ -491,6 +521,9 @@ def move_ether( """ Move funds between accounts. """ + # Capture pre-transaction balance before first modification + capture_pre_balance(state.change_tracker, sender_address, state) + capture_pre_balance(state.change_tracker, recipient_address, state) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -503,6 +536,16 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance + + track_balance_change( + state.change_tracker, sender_address, U256(sender_new_balance) + ) + track_balance_change( + state.change_tracker, recipient_address, U256(recipient_new_balance) + ) + def set_account_balance(state: State, address: Address, amount: U256) -> None: """ @@ -520,12 +563,16 @@ def set_account_balance(state: State, address: Address, amount: U256) -> None: The amount that needs to set in balance. """ + # Capture pre-transaction balance before first modification + capture_pre_balance(state.change_tracker, address, state) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) + track_balance_change(state.change_tracker, address, amount) + def increment_nonce(state: State, address: Address) -> None: """ @@ -546,6 +593,16 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) + # Track nonce change for Block Access List + # (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + account = get_account(state, address) + track_nonce_change(state.change_tracker, address, account.nonce) + def set_code(state: State, address: Address, code: Bytes) -> None: """ @@ -569,6 +626,13 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) + # Only track code changes if it's not setting empty code on a + # newly created address. For newly created addresses, setting + # code to b"" is not a meaningful state change since the address + # had no code to begin with. + if not (code == b"" and address in state.created_accounts): + track_code_change(state.change_tracker, address, code) + def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: """ diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index b2a8c5e2b9..7c2db77ce9 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,6 +21,7 @@ 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 @@ -73,6 +74,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 +92,9 @@ class BlockOutput: ) blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) + block_access_list: BlockAccessList = field( + default_factory=lambda: BlockAccessList(account_changes=()) + ) @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 29909b5fa5..eca5978435 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,6 +12,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError +from ..block_access_lists.tracker import track_address_access from ..fork_types import Address, Authorization from ..state import account_exists, get_account, increment_nonce, set_code from ..utils.hexadecimal import hex_to_address @@ -134,10 +135,14 @@ 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) + # EIP-7928: Track the authority address (delegated account being called) + track_address_access(state.change_tracker, address) + address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) if address in evm.accessed_addresses: access_gas_cost = GAS_WARM_ACCESS @@ -146,6 +151,9 @@ def access_delegation( access_gas_cost = GAS_COLD_ACCOUNT_ACCESS code = get_account(state, address).code + # EIP-7928: Track delegation target when loaded as call target + track_address_access(state.change_tracker, address) + return True, address, code, access_gas_cost @@ -185,6 +193,10 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code + # EIP-7928: Track authority account access in BAL even if delegation + # fails + track_address_access(state.change_tracker, authority) + if authority_code and not is_valid_delegation(authority_code): continue diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 8369043465..39b89567ff 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,6 +17,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.utils.numeric import ceil32 +from ...block_access_lists.tracker import track_address_access from ...fork_types import EMPTY_ACCOUNT from ...state import get_account from ...utils.address import to_address_masked @@ -84,7 +85,9 @@ def balance(evm: Evm) -> None: # 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_access(state.change_tracker, address) push(evm.stack, balance) @@ -350,7 +353,9 @@ def extcodesize(evm: Evm) -> None: 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_access(state.change_tracker, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -392,7 +397,9 @@ def extcodecopy(evm: Evm) -> None: # 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_access(state.change_tracker, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -482,7 +489,9 @@ def extcodehash(evm: Evm) -> None: 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_access(state.change_tracker, 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..35ff36bab3 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -13,6 +13,10 @@ from ethereum_types.numeric import Uint +from ...block_access_lists.tracker import ( + track_storage_read, + track_storage_write, +) from ...state import ( get_storage, get_storage_original, @@ -56,8 +60,14 @@ def sload(evm: Evm) -> None: charge_gas(evm, GAS_COLD_SLOAD) # OPERATION - value = get_storage( - evm.message.block_env.state, evm.message.current_target, key + state = evm.message.block_env.state + value = get_storage(state, evm.message.current_target, key) + + track_storage_read( + state.change_tracker, + evm.message.current_target, + key, + evm.message.block_env.state, ) push(evm.stack, value) @@ -88,6 +98,15 @@ def sstore(evm: Evm) -> None: ) current_value = get_storage(state, evm.message.current_target, key) + # Track the implicit SLOAD that occurs in SSTORE + # This must happen BEFORE charge_gas() so reads are recorded even if OOG + track_storage_read( + state.change_tracker, + evm.message.current_target, + key, + evm.message.block_env.state, + ) + gas_cost = Uint(0) if (evm.message.current_target, key) not in evm.accessed_storage_keys: @@ -126,6 +145,19 @@ def sstore(evm: Evm) -> None: charge_gas(evm, gas_cost) if evm.message.is_static: raise WriteInStaticContext + + # Track storage write BEFORE modifying state + # so we capture the correct pre-value + + track_storage_write( + state.change_tracker, + evm.message.current_target, + key, + new_value, + state, + ) + + # Now modify the storage set_storage(state, evm.message.current_target, key, new_value) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index fea7a0c1b9..665a6048c7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -16,6 +16,7 @@ from ethereum.utils.numeric import ceil32 +from ...block_access_lists.tracker import track_address_access from ...fork_types import Address from ...state import ( account_has_code_or_nonce, @@ -77,6 +78,8 @@ def generic_create( process_create_message, ) + state = evm.message.block_env.state + call_data = memory_read_bytes( evm.memory, memory_start_position, memory_size ) @@ -90,7 +93,7 @@ def generic_create( 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 @@ -104,15 +107,19 @@ def generic_create( evm.accessed_addresses.add(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): + state, contract_address + ) or account_has_storage(state, contract_address): increment_nonce( - evm.message.block_env.state, evm.message.current_target + state, + evm.message.current_target, ) push(evm.stack, U256(0)) return - increment_nonce(evm.message.block_env.state, evm.message.current_target) + increment_nonce( + state, + evm.message.current_target, + ) child_message = Message( block_env=evm.message.block_env, @@ -133,6 +140,9 @@ def generic_create( disable_precompiles=False, parent_evm=evm, ) + + track_address_access(state.change_tracker, contract_address) + child_evm = process_create_message(child_message) if child_evm.error: @@ -326,6 +336,9 @@ def generic_call( disable_precompiles=disable_precompiles, parent_evm=evm, ) + + track_address_access(evm.message.block_env.state.change_tracker, to) + child_evm = process_message(child_message) if child_evm.error: @@ -486,6 +499,10 @@ def callcode(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + # OPERATION evm.memory += b"\x00" * extend_memory.expand_by sender_balance = get_account( @@ -566,7 +583,11 @@ def selfdestruct(evm: Evm) -> None: if originator in evm.message.block_env.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( + evm.message.block_env.state, + originator, + U256(0), + ) evm.accounts_to_delete.add(originator) # HALT the execution @@ -622,6 +643,10 @@ def delegatecall(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + # OPERATION evm.memory += b"\x00" * extend_memory.expand_by generic_call( diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 07e9f1d2db..afd66169f1 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -29,6 +29,12 @@ evm_trace, ) +from ..block_access_lists.tracker import ( + begin_call_frame, + commit_call_frame, + rollback_call_frame, + track_address_access, +) from ..blocks import Log from ..fork_types import Address from ..state import ( @@ -133,6 +139,12 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code = get_account(block_env.state, delegated_address).code message.code_address = delegated_address + # EIP-7928: Track delegation target when loaded as call target + track_address_access( + block_env.state.change_tracker, + delegated_address, + ) + evm = process_message(message) if evm.error: @@ -241,6 +253,11 @@ def process_message(message: Message) -> Evm: # take snapshot of state before processing the message begin_transaction(state, transient_storage) + if hasattr(state, "change_tracker") and state.change_tracker: + begin_call_frame(state.change_tracker) + # Track target address access when processing a message + track_address_access(state.change_tracker, message.current_target) + if message.should_transfer_value and message.value != 0: move_ether( state, message.caller, message.current_target, message.value @@ -251,8 +268,12 @@ def process_message(message: Message) -> Evm: # revert state to the last saved checkpoint # since the message call resulted in an error rollback_transaction(state, transient_storage) + if hasattr(state, "change_tracker") and state.change_tracker: + rollback_call_frame(state.change_tracker) else: commit_transaction(state, transient_storage) + if hasattr(state, "change_tracker") and state.change_tracker: + commit_call_frame(state.change_tracker) return evm 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..7ba79d6c26 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"] = Hash32(b"\0" * 32) + genesis_header = hardfork.Header(**fields) block_fields = { @@ -273,6 +276,9 @@ def add_genesis_block( if has_field(hardfork.Block, "requests"): block_fields["requests"] = () + if has_field(hardfork.Block, "block_access_list"): + block_fields["block_access_list"] = rlp.encode([]) + genesis_block = hardfork.Block(**block_fields) chain.blocks.append(genesis_block) 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..5aebe681f3 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,23 @@ 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 set_block_access_index(self) -> Any: + """set_block_access_index function of the fork.""" + return self._module("block_access_lists").set_block_access_index + @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..3032572afc 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -10,7 +10,7 @@ from typing import Any, Final, TextIO, Tuple, Type, TypeVar from ethereum_rlp import rlp -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U64, U256, Uint, ulen from typing_extensions import override from ethereum import trace @@ -372,6 +372,10 @@ def run_state_test(self) -> Any: self.result.rejected = self.txs.rejected_txs def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: + if self.fork.is_after_fork("amsterdam"): + self.fork.set_block_access_index( + block_env.state.change_tracker, Uint(0) + ) if self.fork.has_compute_requests_hash: self.fork.process_unchecked_system_transaction( block_env=block_env, @@ -386,20 +390,38 @@ 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}" + ) + + if self.fork.is_after_fork("amsterdam"): + assert block_env.state.change_tracker is not None + num_transactions = ulen( + [tx for tx in self.txs.successfully_parsed if tx] + ) + + # post-execution use n + 1 + post_execution_index = num_transactions + Uint(1) + self.fork.set_block_access_index( + block_env.state.change_tracker, post_execution_index + ) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -417,6 +439,11 @@ 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.is_after_fork("amsterdam"): + block_output.block_access_list = self.fork.build_block_access_list( + block_env.state.change_tracker.block_access_list_builder + ) + 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..8cadd58c9f 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.is_after_fork("amsterdam"): + 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..1aac5e4b3f 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,87 @@ 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 + ) + ) + + def _block_access_list_to_json(self, bal: Any) -> Any: + """ + Convert BlockAccessList to JSON format matching the Pydantic models. + """ + account_changes = [] + + for account in bal.account_changes: + account_data: Dict[str, Any] = { + "address": "0x" + account.address.hex() + } + + # Add storage changes if present + if account.storage_changes: + storage_changes = [] + for slot_change in account.storage_changes: + slot_data: Dict[str, Any] = { + "slot": int.from_bytes(slot_change.slot, "big"), + "slotChanges": [], + } + for change in slot_change.changes: + slot_data["slotChanges"].append( + { + "txIndex": int(change.block_access_index), + "postValue": int.from_bytes( + change.new_value, "big" + ), + } + ) + storage_changes.append(slot_data) + account_data["storageChanges"] = storage_changes + + # Add storage reads if present + if account.storage_reads: + account_data["storageReads"] = [ + int.from_bytes(slot, "big") + for slot in account.storage_reads + ] + + # Add balance changes if present + if account.balance_changes: + account_data["balanceChanges"] = [ + { + "txIndex": int(change.block_access_index), + "postBalance": int(change.post_balance), + } + for change in account.balance_changes + ] + + # Add nonce changes if present + if account.nonce_changes: + account_data["nonceChanges"] = [ + { + "txIndex": int(change.block_access_index), + "postNonce": int(change.new_nonce), + } + for change in account.nonce_changes + ] + + # Add code changes if present + if account.code_changes: + account_data["codeChanges"] = [ + { + "txIndex": int(change.block_access_index), + "newCode": "0x" + change.new_code.hex(), + } + for change in account.code_changes + ] + + account_changes.append(account_data) + + # return as list directly + return account_changes + def json_encode_receipts(self) -> Any: """ Encode receipts to JSON. @@ -390,4 +473,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/whitelist.txt b/whitelist.txt index 4f73730fd5..0d4f493ffc 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -1335,4 +1335,10 @@ ZeroPaddedHexNumber zfill zkevm Zsh -zsh \ No newline at end of file +zsh +slot1 +slot2 +lexicographically +uint16 +uint128 +630m From 0acb443bb916a75e1d580fd068bd3ef27b064e07 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 30 Oct 2025 07:42:56 -0600 Subject: [PATCH 02/71] fix(specs): Fix zero value withdrawals BAL tracking (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(specs): Fix zero value withdrawals BAL tracking * docs(specs): rename 'finalize' to 'normalize' in comments * docs(specs): remove reference to uint128 for balance tracking --------- Co-authored-by: Toni Wahrstätter <51536394+nerolation@users.noreply.github.com> Co-authored-by: Toni Wahrstätter --- .../amsterdam/block_access_lists/tracker.py | 25 ++++++++++--------- src/ethereum/forks/amsterdam/fork.py | 21 +++++++++++++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py index 66a4f1ebbd..0ea945e7b1 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py @@ -97,7 +97,7 @@ class StateChangeTracker: """ Cache of pre-transaction balance values, keyed by address. This cache is cleared at the start of each transaction and used by - finalize_transaction_changes to filter out balance changes where + normalize_balance_changes to filter out balance changes where the final balance equals the initial balance. """ @@ -293,7 +293,7 @@ def capture_pre_balance( to ensure we capture the pre-transaction balance correctly. The cache is cleared at the beginning of each transaction. - This is used by finalize_transaction_changes to determine which balance + This is used by normalize_balance_changes to determine which balance changes should be filtered out. Parameters @@ -331,9 +331,7 @@ def track_balance_change( Track a balance change for an account. Records the new balance after any balance-affecting operation, including - transfers, gas payments, block rewards, and withdrawals. The balance is - encoded as a 16-byte value (uint128) which is sufficient for the total - ETH supply. + transfers, gas payments, block rewards, and withdrawals. Parameters ---------- @@ -454,7 +452,7 @@ def handle_in_transaction_selfdestruct( code changes from the current transaction are also removed. Note: Balance changes are handled separately by - finalize_transaction_changes. + normalize_balance_changes. Parameters ---------- @@ -495,22 +493,25 @@ def handle_in_transaction_selfdestruct( ] -def finalize_transaction_changes( +def normalize_balance_changes( tracker: StateChangeTracker, state: "State" ) -> None: """ - Finalize changes for the current transaction. + Normalize balance changes for the current block access index. - This method is called at the end of each transaction execution to filter - out spurious balance changes. It removes all balance changes for addresses - where the post-transaction balance equals the pre-transaction balance. + This method filters out spurious balance changes by removing all balance + changes for addresses where the post-execution balance equals the + pre-execution balance. This is crucial for handling cases like: - In-transaction self-destructs where an account with 0 balance is created and destroyed, resulting in no net balance change - Round-trip transfers where an account receives and sends equal amounts + - Zero-amount withdrawals where the balance doesn't actually change - Only actual state changes are recorded in the Block Access List. + This should be called at the end of any operation that tracks balance + changes (transactions, withdrawals, etc.). Only actual state changes are + recorded in the Block Access List. Parameters ---------- diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 08f5ad734c..c326bd1b93 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -32,8 +32,9 @@ from .block_access_lists.builder import build_block_access_list from .block_access_lists.rlp_utils import compute_block_access_list_hash from .block_access_lists.tracker import ( - finalize_transaction_changes, + capture_pre_balance, handle_in_transaction_selfdestruct, + normalize_balance_changes, set_block_access_index, track_balance_change, ) @@ -1024,9 +1025,9 @@ def process_transaction( ) destroy_account(block_env.state, address) - # EIP-7928: Finalize transaction changes + # EIP-7928: Normalize balance changes for this transaction # Remove balance changes where post-tx balance equals pre-tx balance - finalize_transaction_changes( + normalize_balance_changes( block_env.state.change_tracker, block_env.state, ) @@ -1069,6 +1070,12 @@ def increase_recipient_balance(recipient: Account) -> None: rlp.encode(wd), ) + # Capture pre-balance before modification (even for zero withdrawals) + # This ensures the address appears in BAL per EIP-7928 + capture_pre_balance( + block_env.state.change_tracker, wd.address, block_env.state + ) + modify_state(block_env.state, wd.address, increase_recipient_balance) # Track balance change for BAL @@ -1078,6 +1085,14 @@ def increase_recipient_balance(recipient: Account) -> None: block_env.state.change_tracker, wd.address, U256(new_balance) ) + # EIP-7928: Normalize balance changes for this withdrawal + # Remove balance changes where post-withdrawal balance + # equals pre-withdrawal balance + normalize_balance_changes( + block_env.state.change_tracker, + block_env.state, + ) + if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) From 3236e256274d5eaa2de434c2904e37eae0e3003f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:26:25 +0100 Subject: [PATCH 03/71] fix(specs): static upfront check for create + selfdestruct (#22) --- .../forks/amsterdam/vm/instructions/system.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 665a6048c7..c9feaabbc7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -78,18 +78,22 @@ 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 @@ -544,6 +548,9 @@ def selfdestruct(evm: Evm) -> None: The current EVM frame. """ + if evm.message.is_static: + raise WriteInStaticContext + # STACK beneficiary = to_address_masked(pop(evm.stack)) @@ -563,8 +570,6 @@ def selfdestruct(evm: Evm) -> None: gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext originator = evm.message.current_target originator_balance = get_account( From 290bfd8d0e7a676da860a4599440830566171662 Mon Sep 17 00:00:00 2001 From: raxhvl Date: Wed, 29 Oct 2025 10:37:49 +0100 Subject: [PATCH 04/71] feat(tests): Implement more EIP-7928 tests refactor(tests): Prevent skips by splitting tests appropriately fix(tests): Use valid inputs to precompile tests chore(tests): linting fixes feat(tests): EIP-7928 test_bal_storage_write_read_cross_frame feat(tests): EIP-7928 test_bal_storage_write_read_same_frame feat(tests): EIP-7928 test_bal_nonexistent_account_access feat(tests): EIP-7928 test_bal_nonexistent_value_transfer feat(tests): EIP-7928 test_bal_precompiles feat(tests): EIP-7928 test_bal_withdrawal_to_coinbase_empty_block feat(tests): EIP-7928 test_bal_withdrawal_to_coinbase feat(tests): EIP-7928 test_bal_withdrawal_largest_amount feat(tests): EIP-7928 test_bal_withdrawal_to_precompiles fix(tests): expectation for nonexistent account in post fix(specs,tests): Fix withdrawal tests for BALs issue with idx==0 - `self.txs.successfully_parsed` is a list of transaction indexes, not transactions. The "if tx" check here would then check `if 0` which parses as a boolean ``False``. This means we would skip counting the tx if index=0 was successful. - Fixes some test expectations where `post_code` was being checked instead of ``new_code``. feat(tests): EIP-7928 test_bal_zero_withdrawal feat(tests): EIP-7928 test_bal_withdrawal_and_new_contract feat(tests): EIP-7928 test_bal_withdrawal_and_selfdestruct feat(tests): EIP-7928 test_bal_multiple_withdrawals_same_address feat(tests): EIP-7928 withdrawal_and_value_transfer_same_address feat(tests): EIP-7928 withdrawal_and_state_access_same_account feat(tests): EIP-7928 test_bal_withdrawal_no_evm_execution feat(tests): EIP-7928 test_bal_withdrawal_to_nonexistent_account feat(tests): EIP-7928 test_bal_withdrawal_empty_block feat(tests): EIP-7928 test_bal_withdrawal_with_transaction feat(tests): EIP-7928 coinbase --- .../evm_tools/t8n/__init__.py | 6 +- .../test_block_access_lists.py | 508 +++++++++++ .../test_block_access_lists_eip4895.py | 810 ++++++++++++++++++ .../test_block_access_lists_opcodes.py | 148 ++++ .../test_cases.md | 27 +- 5 files changed, 1494 insertions(+), 5 deletions(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 3032572afc..6ef8d72d3b 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -414,7 +414,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.is_after_fork("amsterdam"): assert block_env.state.change_tracker is not None num_transactions = ulen( - [tx for tx in self.txs.successfully_parsed if tx] + [ + tx_idx + for tx_idx in self.txs.successfully_parsed + if tx_idx is not None + ] ) # post-execution use n + 1 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 4f51fd946b..9f1214fe4c 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 @@ -1417,3 +1417,511 @@ 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(tx_index=1, post_nonce=1)], + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=alice_final_balance + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_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.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(tx_index=1, post_nonce=1)], + ), + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_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(tx_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(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_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(tx_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(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=oracle_final_balance + ) + ] + if oracle_has_balance_change + else [], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_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, + }, + ) 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..edb8295c17 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py @@ -0,0 +1,810 @@ +"""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(tx_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(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5) + ], + ), + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_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(tx_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(tx_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(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_reads=[0x01], + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x99) + ], + ) + ], + balance_changes=[ + BalBalanceChange(tx_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(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5 * GWEI), + BalBalanceChange(tx_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(tx_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(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=100 * GWEI) + ], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=0), + BalBalanceChange(tx_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(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + code_changes=[BalCodeChange(tx_index=1, new_code=code)], + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5 * GWEI), + BalBalanceChange(tx_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.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(tx_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( + tx_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(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5) + ], + ), + coinbase: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=tip_to_coinbase + ), + BalBalanceChange( + tx_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(tx_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_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 5e5ebaefe3..17799d3655 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,14 @@ """ from enum import Enum +from typing import Callable import pytest from execution_testing import ( Account, Alloc, BalAccountExpectation, + BalNonceChange, BalStorageChange, BalStorageSlot, Block, @@ -606,3 +608,149 @@ def test_bal_extcodecopy_and_oog( 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(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_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(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_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}), + }, + ) 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..351395479d 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -29,8 +29,8 @@ | `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_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 | @@ -58,5 +58,24 @@ | `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 `tx_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 | From 862299be4fa30c065ac322e584ceab1cc0385684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:22:24 +0100 Subject: [PATCH 05/71] fix(specs): Ensure tracking before first access (#1722) --- .../forks/amsterdam/vm/instructions/system.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index c9feaabbc7..1de3a140a1 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -110,6 +110,9 @@ def generic_create( evm.accessed_addresses.add(contract_address) + # Track address access for BAL + track_address_access(state.change_tracker, contract_address) + if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): @@ -145,8 +148,6 @@ def generic_create( parent_evm=evm, ) - track_address_access(state.change_tracker, contract_address) - child_evm = process_create_message(child_message) if child_evm.error: @@ -341,8 +342,6 @@ def generic_call( parent_evm=evm, ) - track_address_access(evm.message.block_env.state.change_tracker, to) - child_evm = process_message(child_message) if child_evm.error: @@ -396,6 +395,9 @@ def call(evm: Evm) -> None: evm.accessed_addresses.add(to) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access(evm.message.block_env.state.change_tracker, to) + code_address = to ( disable_precompiles, @@ -485,6 +487,11 @@ def callcode(evm: Evm) -> None: evm.accessed_addresses.add(code_address) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + ( disable_precompiles, code_address, @@ -503,10 +510,6 @@ def callcode(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - track_address_access( - evm.message.block_env.state.change_tracker, code_address - ) - # OPERATION evm.memory += b"\x00" * extend_memory.expand_by sender_balance = get_account( @@ -560,6 +563,11 @@ def selfdestruct(evm: Evm) -> None: evm.accessed_addresses.add(beneficiary) gas_cost += GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access( + evm.message.block_env.state.change_tracker, beneficiary + ) + if ( not is_account_alive(evm.message.block_env.state, beneficiary) and get_account( @@ -635,6 +643,11 @@ def delegatecall(evm: Evm) -> None: evm.accessed_addresses.add(code_address) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + ( disable_precompiles, code_address, @@ -648,10 +661,6 @@ def delegatecall(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - track_address_access( - evm.message.block_env.state.change_tracker, code_address - ) - # OPERATION evm.memory += b"\x00" * extend_memory.expand_by generic_call( @@ -708,6 +717,9 @@ def staticcall(evm: Evm) -> None: evm.accessed_addresses.add(to) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access(evm.message.block_env.state.change_tracker, to) + code_address = to ( disable_precompiles, From 17c90531c639ac3fd1179d23a735ce7d3b6c8029 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 3 Nov 2025 10:08:58 -0700 Subject: [PATCH 06/71] chore(tests): fill all tests for bal releases --- .github/configs/feature.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index d51e7e0144..227779994f 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -23,5 +23,6 @@ benchmark_fast: bal: evm-type: develop - fill-params: --fork=Amsterdam ./tests/amsterdam/eip7928_block_level_access_lists + # TODO: Turn on block rlp limit tests after making filling them more flexible. + fill-params: --fork=Amsterdam -k "not eip7934" feature_only: true From 2336fd7475311fceeefc2792af80fd04670fe5fa Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 4 Nov 2025 04:44:36 -0700 Subject: [PATCH 07/71] fix(spec-specs): duplicate storage writes in state tracker (#1743) - Perform a similar check to balance changes and other tracker methods and keep only the last write. --- docs/CHANGELOG.md | 1 + .../amsterdam/block_access_lists/builder.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cdc6168306..6aeefed9f4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,6 +48,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 diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index a9d6ee9930..f27e26c377 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -126,8 +126,8 @@ def add_storage_write( Add a storage write operation to the block access list. Records a storage slot modification for a given address at a specific - transaction index. Multiple writes to the same slot are tracked - separately, maintaining the order and transaction index of each change. + 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 ---------- @@ -149,6 +149,18 @@ def add_storage_write( 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 ) From 9f7bb41db7213745e44ecd7f37fd99e8451ffa74 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 13 Nov 2025 09:05:36 -0700 Subject: [PATCH 08/71] fix(test-specs): validate t8n BAL independent of expectation existence (#1742) - Validate static checks on the t8n BAL if it exists - IF the expectation also exists, validate against the expectation Keep these checks separate as this helps validation now that we fill for all tests, regardless if they have an expectation or not. --- .../src/execution_testing/specs/blockchain.py | 6 + .../block_access_list/expectations.py | 114 +----- .../test_types/block_access_list/t8n.py | 106 ++++++ ... => test_block_access_list_expectation.py} | 225 ----------- .../tests/test_block_access_list_t8n.py | 351 ++++++++++++++++++ 5 files changed, 465 insertions(+), 337 deletions(-) rename packages/testing/src/execution_testing/test_types/tests/{test_block_access_lists.py => test_block_access_list_expectation.py} (82%) create mode 100644 packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 6c1ca500d6..e08738c1ce 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -717,6 +717,12 @@ def generate_block_data( # 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 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 fad7017c4c..6150dfeabd 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 @@ -179,9 +178,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 @@ -190,9 +188,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: @@ -236,111 +231,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 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..03b8224bbf 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,108 @@ 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 + + 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]}" + ) 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 82% 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..899d9647e4 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 @@ -312,231 +312,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. 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..3c884cf2f4 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py @@ -0,0 +1,351 @@ +""" +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, HexNumber, StorageKey +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=StorageKey(0), slot_changes=[]), + BalStorageSlot(slot=StorageKey(1), slot_changes=[]), + BalStorageSlot(slot=StorageKey(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=StorageKey(0), slot_changes=[]), + BalStorageSlot(slot=StorageKey(2), slot_changes=[]), + BalStorageSlot(slot=StorageKey(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=[StorageKey(0), StorageKey(1), StorageKey(2)], + ) + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order + bal_invalid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_reads=[StorageKey(0), StorageKey(2), StorageKey(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_tx_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: tx_index 1, 2, 3 + if field_name == "nonce_changes": + changes_valid = [ + BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), + BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(2)), + BalNonceChange(tx_index=HexNumber(3), post_nonce=HexNumber(3)), + ] + changes_invalid = [ + BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), + BalNonceChange(tx_index=HexNumber(3), post_nonce=HexNumber(3)), + BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(2)), + ] + elif field_name == "balance_changes": + changes_valid = [ + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(100) + ), + BalBalanceChange( + tx_index=HexNumber(2), post_balance=HexNumber(200) + ), + BalBalanceChange( + tx_index=HexNumber(3), post_balance=HexNumber(300) + ), + ] + changes_invalid = [ + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(100) + ), + BalBalanceChange( + tx_index=HexNumber(3), post_balance=HexNumber(300) + ), + BalBalanceChange( + tx_index=HexNumber(2), post_balance=HexNumber(200) + ), + ] + elif field_name == "code_changes": + changes_valid = [ + BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), + BalCodeChange(tx_index=HexNumber(3), new_code=b"code3"), + ] + changes_invalid = [ + BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(tx_index=HexNumber(3), new_code=b"code3"), + BalCodeChange(tx_index=HexNumber(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"Transaction 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_tx_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 tx_index=1 + if field_name == "nonce_changes": + changes = [ + BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), + BalNonceChange( + tx_index=HexNumber(1), post_nonce=HexNumber(2) + ), # duplicate tx_index + BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(3)), + ] + elif field_name == "balance_changes": + changes = [ + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(100) + ), + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(200) + ), # duplicate tx_index + BalBalanceChange( + tx_index=HexNumber(2), post_balance=HexNumber(300) + ), + ] + elif field_name == "code_changes": + changes = [ + BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange( + tx_index=HexNumber(1), new_code=b"" + ), # duplicate tx_index + BalCodeChange(tx_index=HexNumber(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_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 + bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot( + slot=StorageKey(0), + slot_changes=[ + BalStorageChange( + tx_index=HexNumber(1), + post_value=StorageKey(100), + ), + BalStorageChange( + tx_index=HexNumber(1), + post_value=StorageKey(200), + ), # duplicate tx_index + BalStorageChange( + tx_index=HexNumber(2), + post_value=StorageKey(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( + tx_index=HexNumber(1), post_nonce=HexNumber(1) + ), + BalNonceChange( + tx_index=HexNumber(1), post_nonce=HexNumber(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( + tx_index=HexNumber(1), post_nonce=HexNumber(1) + ) + ], + ) + ] + ) + bal.validate_structure() # Should not raise From ced5370a6466f7728afab92d529df4c1d09b655d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= Date: Wed, 5 Nov 2025 12:11:41 +0100 Subject: [PATCH 09/71] feat(specs): EIP-7928 refactoring --- .../amsterdam/block_access_lists/__init__.py | 6 + .../amsterdam/block_access_lists/tracker.py | 152 +++++++++------ src/ethereum/forks/amsterdam/fork.py | 38 ++-- src/ethereum/forks/amsterdam/state.py | 112 +++++++---- src/ethereum/forks/amsterdam/vm/__init__.py | 5 + .../forks/amsterdam/vm/eoa_delegation.py | 86 ++++++--- src/ethereum/forks/amsterdam/vm/gas.py | 17 ++ .../amsterdam/vm/instructions/environment.py | 55 +++--- .../amsterdam/vm/instructions/storage.py | 80 ++++---- .../forks/amsterdam/vm/instructions/system.py | 177 +++++++++++------- .../forks/amsterdam/vm/interpreter.py | 31 ++- 11 files changed, 474 insertions(+), 285 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py index 856ab832bc..ebcda46e98 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -21,6 +21,9 @@ StateChangeTracker, begin_call_frame, commit_call_frame, + handle_in_transaction_selfdestruct, + normalize_balance_changes, + prepare_balance_tracking, rollback_call_frame, set_block_access_index, track_address_access, @@ -44,6 +47,9 @@ "build_block_access_list", "commit_call_frame", "compute_block_access_list_hash", + "handle_in_transaction_selfdestruct", + "normalize_balance_changes", + "prepare_balance_tracking", "rollback_call_frame", "set_block_access_index", "rlp_encode_block_access_list", diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py index 0ea945e7b1..9008a20878 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from ..state import State # noqa: F401 + from ..vm import BlockEnvironment # noqa: F401 @dataclass @@ -114,7 +115,7 @@ class StateChangeTracker: def set_block_access_index( - tracker: StateChangeTracker, block_access_index: Uint + block_env: "BlockEnvironment", block_access_index: Uint ) -> None: """ Set the current block access index for tracking changes. @@ -129,13 +130,14 @@ def set_block_access_index( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. block_access_index : The block access index (0 for pre-execution, 1..n for transactions, n+1 for post-execution). """ + tracker = block_env.change_tracker tracker.current_block_access_index = block_access_index # Clear the pre-storage cache for each new transaction to ensure # no-op writes are detected relative to the transaction start @@ -182,7 +184,7 @@ def capture_pre_state( def track_address_access( - tracker: StateChangeTracker, address: Address + block_env: "BlockEnvironment", address: Address ) -> None: """ Track that an address was accessed. @@ -192,17 +194,19 @@ def track_address_access( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address that was accessed. """ - add_touched_account(tracker.block_access_list_builder, address) + add_touched_account( + block_env.change_tracker.block_access_list_builder, address + ) def track_storage_read( - tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" + block_env: "BlockEnvironment", address: Address, key: Bytes32 ) -> None: """ Track a storage read operation. @@ -213,29 +217,28 @@ def track_storage_read( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose storage is being read. key : The storage slot being read. - state : - The current execution state. """ - track_address_access(tracker, address) + track_address_access(block_env, address) - capture_pre_state(tracker, address, key, state) + capture_pre_state(block_env.change_tracker, address, key, block_env.state) - add_storage_read(tracker.block_access_list_builder, address, key) + add_storage_read( + block_env.change_tracker.block_access_list_builder, address, key + ) def track_storage_write( - tracker: StateChangeTracker, + block_env: "BlockEnvironment", address: Address, key: Bytes32, new_value: U256, - state: "State", ) -> None: """ Track a storage write operation. @@ -246,23 +249,22 @@ def track_storage_write( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose storage is being modified. key : The storage slot being written to. new_value : The new value to write. - state : - The current execution state. [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 """ - track_address_access(tracker, address) + track_address_access(block_env, address) - pre_value = capture_pre_state(tracker, address, key, state) + tracker = block_env.change_tracker + pre_value = capture_pre_state(tracker, address, key, block_env.state) value_bytes = new_value.to_be_bytes32() @@ -322,8 +324,36 @@ def capture_pre_balance( return tracker.pre_balance_cache[address] +def prepare_balance_tracking( + block_env: "BlockEnvironment", address: Address +) -> None: + """ + Prepare for tracking balance changes by caching the pre-transaction + balance. + + This should be called before any balance modifications when you need to + ensure the pre-balance is captured for later normalization. This is + particularly important for operations like withdrawals where the balance + might not actually change. + + Parameters + ---------- + block_env : + The block execution environment. + address : + The account address whose balance will be tracked. + + + """ + # Ensure the address is tracked + track_address_access(block_env, address) + + # Cache the pre-balance for later normalization + capture_pre_balance(block_env.change_tracker, address, block_env.state) + + def track_balance_change( - tracker: StateChangeTracker, + block_env: "BlockEnvironment", address: Address, new_balance: U256, ) -> None: @@ -335,16 +365,17 @@ def track_balance_change( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose balance changed. new_balance : The new balance value. """ - track_address_access(tracker, address) + track_address_access(block_env, address) + tracker = block_env.change_tracker block_access_index = BlockAccessIndex(tracker.current_block_access_index) add_balance_change( tracker.block_access_list_builder, @@ -362,7 +393,7 @@ def track_balance_change( def track_nonce_change( - tracker: StateChangeTracker, address: Address, new_nonce: Uint + block_env: "BlockEnvironment", address: Address, new_nonce: Uint ) -> None: """ Track a nonce change for an account. @@ -373,20 +404,19 @@ def track_nonce_change( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose nonce changed. new_nonce : The new nonce value. - state : - The current execution state. [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 """ - track_address_access(tracker, address) + track_address_access(block_env, address) + tracker = block_env.change_tracker block_access_index = BlockAccessIndex(tracker.current_block_access_index) nonce_u64 = U64(new_nonce) add_nonce_change( @@ -403,7 +433,7 @@ def track_nonce_change( def track_code_change( - tracker: StateChangeTracker, address: Address, new_code: Bytes + block_env: "BlockEnvironment", address: Address, new_code: Bytes ) -> None: """ Track a code change for contract deployment. @@ -414,8 +444,8 @@ def track_code_change( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The address receiving the contract code. new_code : @@ -425,7 +455,8 @@ def track_code_change( [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 """ - track_address_access(tracker, address) + track_address_access(block_env, address) + tracker = block_env.change_tracker block_access_index = BlockAccessIndex(tracker.current_block_access_index) add_code_change( tracker.block_access_list_builder, @@ -441,7 +472,7 @@ def track_code_change( def handle_in_transaction_selfdestruct( - tracker: StateChangeTracker, address: Address + block_env: "BlockEnvironment", address: Address ) -> None: """ Handle an account that self-destructed in the same transaction it was @@ -456,12 +487,13 @@ def handle_in_transaction_selfdestruct( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The address that self-destructed. """ + tracker = block_env.change_tracker builder = tracker.block_access_list_builder if address not in builder.accounts: return @@ -493,9 +525,7 @@ def handle_in_transaction_selfdestruct( ] -def normalize_balance_changes( - tracker: StateChangeTracker, state: "State" -) -> None: +def normalize_balance_changes(block_env: "BlockEnvironment") -> None: """ Normalize balance changes for the current block access index. @@ -515,15 +545,14 @@ def normalize_balance_changes( Parameters ---------- - tracker : - The state change tracker instance. - state : - The current execution state. + block_env : + The block execution environment. """ # Import locally to avoid circular import from ..state import get_account + tracker = block_env.change_tracker builder = tracker.block_access_list_builder current_index = tracker.current_block_access_index @@ -532,10 +561,10 @@ def normalize_balance_changes( account_data = builder.accounts[address] # Get the pre-transaction balance - pre_balance = capture_pre_balance(tracker, address, state) + pre_balance = capture_pre_balance(tracker, address, block_env.state) # Get the current (post-transaction) balance - post_balance = get_account(state, address).balance + post_balance = get_account(block_env.state, address).balance # If pre-tx balance equals post-tx balance, remove all balance changes # for this address in the current transaction @@ -548,7 +577,7 @@ def normalize_balance_changes( ] -def begin_call_frame(tracker: StateChangeTracker) -> None: +def begin_call_frame(block_env: "BlockEnvironment") -> None: """ Begin a new call frame for tracking reverts. @@ -557,14 +586,14 @@ def begin_call_frame(tracker: StateChangeTracker) -> None: Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. """ - tracker.call_frame_snapshots.append(CallFrameSnapshot()) + block_env.change_tracker.call_frame_snapshots.append(CallFrameSnapshot()) -def rollback_call_frame(tracker: StateChangeTracker) -> None: +def rollback_call_frame(block_env: "BlockEnvironment") -> None: """ Rollback changes from the current call frame. @@ -578,10 +607,11 @@ def rollback_call_frame(tracker: StateChangeTracker) -> None: Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. """ + tracker = block_env.change_tracker if not tracker.call_frame_snapshots: return @@ -651,7 +681,7 @@ def rollback_call_frame(tracker: StateChangeTracker) -> None: # All touched addresses remain in the access list (already tracked) -def commit_call_frame(tracker: StateChangeTracker) -> None: +def commit_call_frame(block_env: "BlockEnvironment") -> None: """ Commit changes from the current call frame. @@ -660,9 +690,9 @@ def commit_call_frame(tracker: StateChangeTracker) -> None: Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. """ - if tracker.call_frame_snapshots: - tracker.call_frame_snapshots.pop() + if block_env.change_tracker.call_frame_snapshots: + block_env.change_tracker.call_frame_snapshots.pop() diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index c326bd1b93..4ec12acf91 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -32,9 +32,9 @@ from .block_access_lists.builder import build_block_access_list from .block_access_lists.rlp_utils import compute_block_access_list_hash from .block_access_lists.tracker import ( - capture_pre_balance, handle_in_transaction_selfdestruct, normalize_balance_changes, + prepare_balance_tracking, set_block_access_index, track_balance_change, ) @@ -781,7 +781,7 @@ def apply_body( # Set block access index for pre-execution system contracts # EIP-7928: System contracts use block_access_index 0 - set_block_access_index(block_env.state.change_tracker, Uint(0)) + set_block_access_index(block_env, Uint(0)) process_unchecked_system_transaction( block_env=block_env, @@ -800,9 +800,7 @@ def apply_body( # EIP-7928: Post-execution uses block_access_index len(transactions) + 1 post_execution_index = ulen(transactions) + Uint(1) - set_block_access_index( - block_env.state.change_tracker, post_execution_index - ) + set_block_access_index(block_env, post_execution_index) process_withdrawals(block_env, block_output, withdrawals) @@ -811,7 +809,7 @@ def apply_body( block_output=block_output, ) block_output.block_access_list = build_block_access_list( - block_env.state.change_tracker.block_access_list_builder + block_env.change_tracker.block_access_list_builder ) return block_output @@ -894,7 +892,7 @@ def process_transaction( """ # EIP-7928: Transactions use block_access_index 1 to len(transactions) # Transaction at index i gets block_access_index i+1 - set_block_access_index(block_env.state.change_tracker, index + Uint(1)) + set_block_access_index(block_env, index + Uint(1)) trie_set( block_output.transactions_trie, @@ -1020,17 +1018,12 @@ def process_transaction( # EIP-7928: In-transaction self-destruct - convert storage writes to # reads and remove nonce/code changes. Only accounts created in same # tx are in accounts_to_delete per EIP-6780. - handle_in_transaction_selfdestruct( - block_env.state.change_tracker, address - ) + handle_in_transaction_selfdestruct(block_env, address) destroy_account(block_env.state, address) # EIP-7928: Normalize balance changes for this transaction # Remove balance changes where post-tx balance equals pre-tx balance - normalize_balance_changes( - block_env.state.change_tracker, - block_env.state, - ) + normalize_balance_changes(block_env) block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -1070,28 +1063,21 @@ def increase_recipient_balance(recipient: Account) -> None: rlp.encode(wd), ) - # Capture pre-balance before modification (even for zero withdrawals) - # This ensures the address appears in BAL per EIP-7928 - capture_pre_balance( - block_env.state.change_tracker, wd.address, block_env.state - ) + # Prepare for balance tracking (ensures address appears in BAL and + # pre-balance is cached for normalization) + prepare_balance_tracking(block_env, wd.address) modify_state(block_env.state, wd.address, increase_recipient_balance) # Track balance change for BAL # (withdrawals are tracked as system contract changes) new_balance = get_account(block_env.state, wd.address).balance - track_balance_change( - block_env.state.change_tracker, wd.address, U256(new_balance) - ) + track_balance_change(block_env, wd.address, U256(new_balance)) # EIP-7928: Normalize balance changes for this withdrawal # Remove balance changes where post-withdrawal balance # equals pre-withdrawal balance - normalize_balance_changes( - block_env.state.change_tracker, - block_env.state, - ) + normalize_balance_changes(block_env) if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 3067b175d6..b47cd2d377 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -17,16 +17,14 @@ """ 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 from ethereum_types.numeric import U256, Uint -from .block_access_lists.builder import BlockAccessListBuilder from .block_access_lists.tracker import ( - StateChangeTracker, - capture_pre_balance, + prepare_balance_tracking, track_balance_change, track_code_change, track_nonce_change, @@ -34,6 +32,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: @@ -54,9 +55,6 @@ class State: ] ] = field(default_factory=list) created_accounts: Set[Address] = field(default_factory=set) - change_tracker: StateChangeTracker = field( - default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) - ) @dataclass @@ -517,13 +515,17 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, + block_env: "BlockEnvironment" = None, ) -> None: """ Move funds between accounts. """ - # Capture pre-transaction balance before first modification - capture_pre_balance(state.change_tracker, sender_address, state) - capture_pre_balance(state.change_tracker, recipient_address, state) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Prepare for balance tracking (captures pre-balance and ensures + # addresses are tracked) + prepare_balance_tracking(block_env, sender_address) + prepare_balance_tracking(block_env, recipient_address) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -536,18 +538,25 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - sender_new_balance = get_account(state, sender_address).balance - recipient_new_balance = get_account(state, recipient_address).balance + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance - track_balance_change( - state.change_tracker, sender_address, U256(sender_new_balance) - ) - track_balance_change( - state.change_tracker, recipient_address, U256(recipient_new_balance) - ) + track_balance_change( + block_env, sender_address, U256(sender_new_balance) + ) + track_balance_change( + block_env, recipient_address, U256(recipient_new_balance) + ) -def set_account_balance(state: State, address: Address, amount: U256) -> None: +def set_account_balance( + state: State, + address: Address, + amount: U256, + block_env: "BlockEnvironment" = None, +) -> None: """ Sets the balance of an account. @@ -562,19 +571,29 @@ def set_account_balance(state: State, address: Address, amount: U256) -> None: amount: The amount that needs to set in balance. + block_env: + Optional block environment for tracking changes. + """ - # Capture pre-transaction balance before first modification - capture_pre_balance(state.change_tracker, address, state) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Prepare for balance tracking (captures pre-balance and ensures + # address is tracked) + prepare_balance_tracking(block_env, address) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - track_balance_change(state.change_tracker, address, amount) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + track_balance_change(block_env, address, amount) -def increment_nonce(state: State, address: Address) -> None: +def increment_nonce( + state: State, address: Address, block_env: "BlockEnvironment" = None +) -> None: """ Increments the nonce of an account. @@ -586,6 +605,9 @@ def increment_nonce(state: State, address: Address) -> None: address: Address of the account whose nonce needs to be incremented. + block_env: + Optional block environment for tracking changes. + """ def increase_nonce(sender: Account) -> None: @@ -593,18 +615,25 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - # Track nonce change for Block Access List - # (for ALL accounts and ALL nonce changes) - # This includes: - # - EOA senders (transaction nonce increments) - # - Contracts performing CREATE/CREATE2 - # - Deployed contracts - # - EIP-7702 authorities - account = get_account(state, address) - track_nonce_change(state.change_tracker, address, account.nonce) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Track nonce change for Block Access List + # (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + account = get_account(state, address) + track_nonce_change(block_env, address, account.nonce) -def set_code(state: State, address: Address, code: Bytes) -> None: +def set_code( + state: State, + address: Address, + code: Bytes, + block_env: "BlockEnvironment" = None, +) -> None: """ Sets Account code. @@ -619,6 +648,9 @@ def set_code(state: State, address: Address, code: Bytes) -> None: code: The bytecode that needs to be set. + block_env: + Optional block environment for tracking changes. + """ def write_code(sender: Account) -> None: @@ -626,12 +658,14 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track code changes if it's not setting empty code on a - # newly created address. For newly created addresses, setting - # code to b"" is not a meaningful state change since the address - # had no code to begin with. - if not (code == b"" and address in state.created_accounts): - track_code_change(state.change_tracker, address, code) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Only track code changes if it's not setting empty code on a + # newly created address. For newly created addresses, setting + # code to b"" is not a meaningful state change since the address + # had no code to begin with. + if not (code == b"" and address in state.created_accounts): + track_code_change(block_env, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 7c2db77ce9..c10df4897b 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,7 +21,9 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException +from ..block_access_lists.builder import BlockAccessListBuilder from ..block_access_lists.rlp_types import BlockAccessList +from ..block_access_lists.tracker import StateChangeTracker from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage @@ -48,6 +50,9 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 + change_tracker: StateChangeTracker = field( + default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) + ) @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index eca5978435..649027cb43 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -115,46 +115,92 @@ def recover_authority(authorization: Authorization) -> Address: return Address(keccak256(public_key)[12:32]) -def access_delegation( +def check_delegation( evm: Evm, address: Address -) -> Tuple[bool, Address, Bytes, Uint]: +) -> Tuple[bool, Address, Address, Bytes, Uint]: """ - Get the delegation address, code, and the cost of access from the address. + Check delegation info without modifying state or tracking. 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, Address, Bytes, Uint]` + (is_delegated, original_address, final_address, code, + additional_gas_cost) """ state = evm.message.block_env.state code = get_account(state, address).code if not is_valid_delegation(code): - return False, address, code, Uint(0) + return False, address, address, code, Uint(0) - # EIP-7928: Track the authority address (delegated account being called) - track_address_access(state.change_tracker, address) + delegated_address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) - address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + if delegated_address in evm.accessed_addresses: + additional_gas_cost = GAS_WARM_ACCESS else: - evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - code = get_account(state, address).code + additional_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + delegated_code = get_account(state, delegated_address).code + + return ( + True, + address, + delegated_address, + delegated_code, + additional_gas_cost, + ) + + +def apply_delegation_tracking( + evm: Evm, original_address: Address, delegated_address: Address +) -> None: + """ + Apply delegation tracking after gas check passes. + + Parameters + ---------- + evm : `Evm` + The execution frame. + original_address : `Address` + The original address that was called. + delegated_address : `Address` + The address delegated to. + + """ + track_address_access(evm.message.block_env, original_address) + + if delegated_address not in evm.accessed_addresses: + evm.accessed_addresses.add(delegated_address) + + track_address_access(evm.message.block_env, delegated_address) + + +def access_delegation( + evm: Evm, address: Address +) -> Tuple[bool, Address, Bytes, Uint]: + """ + Access delegation info and track state changes. + + DEPRECATED: Use check_delegation and apply_delegation_tracking + for proper gas check ordering. + + """ + is_delegated, orig_addr, final_addr, code, gas_cost = check_delegation( + evm, address + ) - # EIP-7928: Track delegation target when loaded as call target - track_address_access(state.change_tracker, address) + if is_delegated: + apply_delegation_tracking(evm, orig_addr, final_addr) - return True, address, code, access_gas_cost + return is_delegated, final_addr, code, gas_cost def set_delegation(message: Message) -> U256: @@ -193,9 +239,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - # EIP-7928: Track authority account access in BAL even if delegation - # fails - track_address_access(state.change_tracker, authority) + track_address_access(message.block_env, authority) if authority_code and not is_valid_delegation(authority_code): continue 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 39b89567ff..e984d8030f 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -36,6 +36,7 @@ calculate_blob_gas_price, calculate_gas_extend_memory, charge_gas, + check_gas, ) from ..stack import pop, push @@ -77,17 +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 + check_gas(evm, gas_cost) + if is_cold_access: evm.accessed_addresses.add(address) - charge_gas(evm, GAS_COLD_ACCOUNT_ACCESS) + track_address_access(evm.message.block_env, address) + charge_gas(evm, gas_cost) # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. state = evm.message.block_env.state balance = get_account(state, address).balance - track_address_access(state.change_tracker, address) push(evm.stack, balance) @@ -344,18 +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 + ) + check_gas(evm, access_gas_cost) + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - + track_address_access(evm.message.block_env, address) charge_gas(evm, access_gas_cost) # OPERATION state = evm.message.block_env.state code = get_account(state, address).code - track_address_access(state.change_tracker, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -387,19 +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: - evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + 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 - charge_gas(evm, access_gas_cost + copy_gas_cost + extend_memory.cost) + check_gas(evm, total_gas_cost) + if is_cold_access: + evm.accessed_addresses.add(address) + track_address_access(evm.message.block_env, address) + charge_gas(evm, total_gas_cost) # OPERATION evm.memory += b"\x00" * extend_memory.expand_by state = evm.message.block_env.state code = get_account(state, address).code - track_address_access(state.change_tracker, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -480,18 +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 + ) + check_gas(evm, access_gas_cost) + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - + track_address_access(evm.message.block_env, address) charge_gas(evm, access_gas_cost) # OPERATION state = evm.message.block_env.state account = get_account(state, address) - track_address_access(state.change_tracker, 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 35ff36bab3..65a6a38455 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -34,6 +34,7 @@ GAS_STORAGE_UPDATE, GAS_WARM_ACCESS, charge_gas, + check_gas, ) from ..stack import pop, push @@ -53,22 +54,24 @@ def sload(evm: Evm) -> None: key = pop(evm.stack).to_be_bytes32() # GAS - if (evm.message.current_target, key) in evm.accessed_storage_keys: - charge_gas(evm, GAS_WARM_ACCESS) - else: + gas_cost = ( + GAS_WARM_ACCESS + if (evm.message.current_target, key) in evm.accessed_storage_keys + else GAS_COLD_SLOAD + ) + check_gas(evm, gas_cost) + if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) - charge_gas(evm, GAS_COLD_SLOAD) - - # OPERATION - state = evm.message.block_env.state - value = get_storage(state, evm.message.current_target, key) - track_storage_read( - state.change_tracker, + evm.message.block_env, evm.message.current_target, key, - evm.message.block_env.state, ) + charge_gas(evm, gas_cost) + + # OPERATION + state = evm.message.block_env.state + value = get_storage(state, evm.message.current_target, key) push(evm.stack, value) @@ -98,19 +101,14 @@ def sstore(evm: Evm) -> None: ) current_value = get_storage(state, evm.message.current_target, key) - # Track the implicit SLOAD that occurs in SSTORE - # This must happen BEFORE charge_gas() so reads are recorded even if OOG - track_storage_read( - state.change_tracker, + # GAS + gas_cost = Uint(0) + is_cold_access = ( evm.message.current_target, key, - evm.message.block_env.state, - ) - - gas_cost = Uint(0) + ) not in evm.accessed_storage_keys - if (evm.message.current_target, key) not in evm.accessed_storage_keys: - evm.accessed_storage_keys.add((evm.message.current_target, key)) + if is_cold_access: gas_cost += GAS_COLD_SLOAD if original_value == current_value and current_value != new_value: @@ -121,7 +119,28 @@ def sstore(evm: Evm) -> None: else: gas_cost += GAS_WARM_ACCESS - # Refund Counter Calculation + check_gas(evm, gas_cost) + + if is_cold_access: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + + track_storage_read( + evm.message.block_env, + evm.message.current_target, + key, + ) + track_storage_write( + evm.message.block_env, + evm.message.current_target, + key, + new_value, + ) + + charge_gas(evm, gas_cost) + if evm.message.is_static: + raise WriteInStaticContext + + # REFUND COUNTER if current_value != new_value: if original_value != 0 and current_value != 0 and new_value == 0: # Storage is cleared for the first time in the transaction @@ -142,22 +161,7 @@ def sstore(evm: Evm) -> None: GAS_STORAGE_UPDATE - GAS_COLD_SLOAD - GAS_WARM_ACCESS ) - charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext - - # Track storage write BEFORE modifying state - # so we capture the correct pre-value - - track_storage_write( - state.change_tracker, - evm.message.current_target, - key, - new_value, - state, - ) - - # Now modify the storage + # OPERATION set_storage(state, evm.message.current_target, key, new_value) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 1de3a140a1..8c1babdcd1 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -32,7 +32,10 @@ compute_create2_contract_address, to_address_masked, ) -from ...vm.eoa_delegation import access_delegation +from ...vm.eoa_delegation import ( + apply_delegation_tracking, + check_delegation, +) from .. import ( Evm, Message, @@ -53,6 +56,7 @@ calculate_gas_extend_memory, calculate_message_call_gas, charge_gas, + check_gas, init_code_cost, max_message_call_gas, ) @@ -110,8 +114,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - # Track address access for BAL - track_address_access(state.change_tracker, contract_address) + track_address_access(evm.message.block_env, contract_address) if account_has_code_or_nonce( state, contract_address @@ -389,23 +392,22 @@ def call(evm: Evm) -> None: ], ) - if to in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(to) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access(evm.message.block_env.state.change_tracker, to) + is_cold_access = to not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) - code_address = to ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, to) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated create_gas_cost = GAS_NEW_ACCOUNT if value == 0 or is_account_alive(evm.message.block_env.state, to): @@ -418,6 +420,17 @@ def call(evm: Evm) -> None: extend_memory.cost, access_gas_cost + create_gas_cost + transfer_gas_cost, ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(to) + + track_address_access(evm.message.block_env, to) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext @@ -453,7 +466,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 ---------- @@ -481,24 +494,22 @@ def callcode(evm: Evm) -> None: ], ) - if code_address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(code_address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access( - evm.message.block_env.state.change_tracker, code_address + is_cold_access = code_address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, code_address) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE message_call_gas = calculate_message_call_gas( @@ -508,6 +519,17 @@ def callcode(evm: Evm) -> None: extend_memory.cost, access_gas_cost + transfer_gas_cost, ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(original_address) + + track_address_access(evm.message.block_env, original_address) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -559,15 +581,10 @@ def selfdestruct(evm: Evm) -> None: # 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 - # Track address access for BAL - track_address_access( - evm.message.block_env.state.change_tracker, beneficiary - ) - if ( not is_account_alive(evm.message.block_env.state, beneficiary) and get_account( @@ -577,6 +594,13 @@ def selfdestruct(evm: Evm) -> None: ): gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT + check_gas(evm, gas_cost) + + if is_cold_access: + evm.accessed_addresses.add(beneficiary) + + track_address_access(evm.message.block_env, beneficiary) + charge_gas(evm, gas_cost) originator = evm.message.current_target @@ -637,28 +661,37 @@ def delegatecall(evm: Evm) -> None: ], ) - if code_address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(code_address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access( - evm.message.block_env.state.change_tracker, code_address + is_cold_access = code_address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, code_address) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated message_call_gas = calculate_message_call_gas( U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(original_address) + + track_address_access(evm.message.block_env, original_address) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -711,23 +744,22 @@ def staticcall(evm: Evm) -> None: ], ) - if to in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(to) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access(evm.message.block_env.state.change_tracker, to) + is_cold_access = to not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) - code_address = to ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, to) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated message_call_gas = calculate_message_call_gas( U256(0), @@ -736,6 +768,17 @@ def staticcall(evm: Evm) -> None: extend_memory.cost, access_gas_cost, ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(to) + + track_address_access(evm.message.block_env, to) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index afd66169f1..5b33e48dd0 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -141,7 +141,7 @@ def process_message_call(message: Message) -> MessageCallOutput: # EIP-7928: Track delegation target when loaded as call target track_address_access( - block_env.state.change_tracker, + block_env, delegated_address, ) @@ -253,14 +253,21 @@ def process_message(message: Message) -> Evm: # take snapshot of state before processing the message begin_transaction(state, transient_storage) - if hasattr(state, "change_tracker") and state.change_tracker: - begin_call_frame(state.change_tracker) + if ( + hasattr(message.block_env, "change_tracker") + and message.block_env.change_tracker + ): + begin_call_frame(message.block_env) # Track target address access when processing a message - track_address_access(state.change_tracker, message.current_target) + track_address_access(message.block_env, message.current_target) if message.should_transfer_value and message.value != 0: move_ether( - state, message.caller, message.current_target, message.value + state, + message.caller, + message.current_target, + message.value, + message.block_env, ) evm = execute_code(message) @@ -268,12 +275,18 @@ def process_message(message: Message) -> Evm: # revert state to the last saved checkpoint # since the message call resulted in an error rollback_transaction(state, transient_storage) - if hasattr(state, "change_tracker") and state.change_tracker: - rollback_call_frame(state.change_tracker) + if ( + hasattr(message.block_env, "change_tracker") + and message.block_env.change_tracker + ): + rollback_call_frame(message.block_env) else: commit_transaction(state, transient_storage) - if hasattr(state, "change_tracker") and state.change_tracker: - commit_call_frame(state.change_tracker) + if ( + hasattr(message.block_env, "change_tracker") + and message.block_env.change_tracker + ): + commit_call_frame(message.block_env) return evm From a05a46210cb25c1ce7ada57ec98b57853bdf2fbf Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 5 Nov 2025 10:59:35 -0700 Subject: [PATCH 10/71] fix(spec-specs): require and use blockenv for state tracking --- src/ethereum/forks/amsterdam/fork.py | 9 +- src/ethereum/forks/amsterdam/state.py | 86 ++++++++----------- .../forks/amsterdam/vm/eoa_delegation.py | 4 +- .../amsterdam/vm/instructions/storage.py | 23 +++-- .../forks/amsterdam/vm/instructions/system.py | 4 + .../forks/amsterdam/vm/interpreter.py | 6 +- .../evm_tools/t8n/__init__.py | 7 +- 7 files changed, 65 insertions(+), 74 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 4ec12acf91..d74c8eeb80 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -923,13 +923,13 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender) + increment_nonce(block_env.state, sender, block_env) sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee ) set_account_balance( - block_env.state, sender, U256(sender_balance_after_gas_fee) + block_env.state, sender, U256(sender_balance_after_gas_fee), block_env ) access_list_addresses = set() @@ -995,7 +995,9 @@ def process_transaction( sender_balance_after_refund = get_account( block_env.state, sender ).balance + U256(gas_refund_amount) - set_account_balance(block_env.state, sender, sender_balance_after_refund) + set_account_balance( + block_env.state, sender, sender_balance_after_refund, block_env + ) # transfer miner fees coinbase_balance_after_mining_fee = get_account( @@ -1007,6 +1009,7 @@ def process_transaction( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, + block_env, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index b47cd2d377..3656a386c7 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -515,17 +515,15 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - block_env: "BlockEnvironment" = None, + block_env: "BlockEnvironment", ) -> None: """ Move funds between accounts. """ - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Prepare for balance tracking (captures pre-balance and ensures - # addresses are tracked) - prepare_balance_tracking(block_env, sender_address) - prepare_balance_tracking(block_env, recipient_address) + # Prepare for balance tracking (captures pre-balance and ensures + # addresses are tracked) + prepare_balance_tracking(block_env, sender_address) + prepare_balance_tracking(block_env, recipient_address) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -538,24 +536,20 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - sender_new_balance = get_account(state, sender_address).balance - recipient_new_balance = get_account(state, recipient_address).balance + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance - track_balance_change( - block_env, sender_address, U256(sender_new_balance) - ) - track_balance_change( - block_env, recipient_address, U256(recipient_new_balance) - ) + track_balance_change(block_env, sender_address, U256(sender_new_balance)) + track_balance_change( + block_env, recipient_address, U256(recipient_new_balance) + ) def set_account_balance( state: State, address: Address, amount: U256, - block_env: "BlockEnvironment" = None, + block_env: "BlockEnvironment", ) -> None: """ Sets the balance of an account. @@ -572,27 +566,23 @@ def set_account_balance( The amount that needs to set in balance. block_env: - Optional block environment for tracking changes. + Block environment for tracking changes. """ - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Prepare for balance tracking (captures pre-balance and ensures - # address is tracked) - prepare_balance_tracking(block_env, address) + # Prepare for balance tracking (captures pre-balance and ensures + # address is tracked) + prepare_balance_tracking(block_env, address) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - track_balance_change(block_env, address, amount) + track_balance_change(block_env, address, amount) def increment_nonce( - state: State, address: Address, block_env: "BlockEnvironment" = None + state: State, address: Address, block_env: "BlockEnvironment" ) -> None: """ Increments the nonce of an account. @@ -606,7 +596,7 @@ def increment_nonce( Address of the account whose nonce needs to be incremented. block_env: - Optional block environment for tracking changes. + Block environment for tracking changes. """ @@ -615,24 +605,22 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Track nonce change for Block Access List - # (for ALL accounts and ALL nonce changes) - # This includes: - # - EOA senders (transaction nonce increments) - # - Contracts performing CREATE/CREATE2 - # - Deployed contracts - # - EIP-7702 authorities - account = get_account(state, address) - track_nonce_change(block_env, address, account.nonce) + # Track nonce change for Block Access List (EIP-7928) + # (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + account = get_account(state, address) + track_nonce_change(block_env, address, account.nonce) def set_code( state: State, address: Address, code: Bytes, - block_env: "BlockEnvironment" = None, + block_env: "BlockEnvironment", ) -> None: """ Sets Account code. @@ -649,7 +637,7 @@ def set_code( The bytecode that needs to be set. block_env: - Optional block environment for tracking changes. + Block environment for tracking changes. """ @@ -658,14 +646,12 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Only track code changes if it's not setting empty code on a - # newly created address. For newly created addresses, setting - # code to b"" is not a meaningful state change since the address - # had no code to begin with. - if not (code == b"" and address in state.created_accounts): - track_code_change(block_env, address, code) + # Only track code changes if it's not setting empty code on a + # newly created address (EIP-7928). For newly created addresses, setting + # code to b"" is not a meaningful state change since the address + # had no code to begin with. + if not (code == b"" and address in state.created_accounts): + track_code_change(block_env, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 649027cb43..c831f9d337 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -255,9 +255,9 @@ 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) + set_code(state, authority, code_to_set, message.block_env) - increment_nonce(state, authority) + increment_nonce(state, authority, message.block_env) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 65a6a38455..f8bf08ca29 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -100,6 +100,11 @@ def sstore(evm: Evm) -> None: state, evm.message.current_target, key ) current_value = get_storage(state, evm.message.current_target, key) + track_storage_read( + evm.message.block_env, + evm.message.current_target, + key, + ) # GAS gas_cost = Uint(0) @@ -124,18 +129,6 @@ def sstore(evm: Evm) -> None: if is_cold_access: evm.accessed_storage_keys.add((evm.message.current_target, key)) - track_storage_read( - evm.message.block_env, - evm.message.current_target, - key, - ) - track_storage_write( - evm.message.block_env, - evm.message.current_target, - key, - new_value, - ) - charge_gas(evm, gas_cost) if evm.message.is_static: raise WriteInStaticContext @@ -163,6 +156,12 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) + track_storage_write( + evm.message.block_env, + evm.message.current_target, + key, + new_value, + ) # PROGRAM COUNTER evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 8c1babdcd1..873e1cd4d8 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -122,6 +122,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, + evm.message.block_env, ) push(evm.stack, U256(0)) return @@ -129,6 +130,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, + evm.message.block_env, ) child_message = Message( @@ -613,6 +615,7 @@ def selfdestruct(evm: Evm) -> None: originator, beneficiary, originator_balance, + evm.message.block_env, ) # register account for deletion only if it was created @@ -624,6 +627,7 @@ def selfdestruct(evm: Evm) -> None: evm.message.block_env.state, originator, U256(0), + evm.message.block_env, ) evm.accounts_to_delete.add(originator) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 5b33e48dd0..8cd40cbce1 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -205,7 +205,7 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce(state, message.current_target) + increment_nonce(state, message.current_target, message.block_env) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -223,7 +223,9 @@ def process_create_message(message: Message) -> Evm: evm.output = b"" evm.error = error else: - set_code(state, message.current_target, contract_code) + set_code( + state, message.current_target, contract_code, message.block_env + ) commit_transaction(state, transient_storage) else: rollback_transaction(state, transient_storage) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 6ef8d72d3b..3c07ec1d20 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -412,7 +412,6 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: ) if self.fork.is_after_fork("amsterdam"): - assert block_env.state.change_tracker is not None num_transactions = ulen( [ tx_idx @@ -423,9 +422,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: # post-execution use n + 1 post_execution_index = num_transactions + Uint(1) - self.fork.set_block_access_index( - block_env.state.change_tracker, post_execution_index - ) + self.fork.set_block_access_index(block_env, post_execution_index) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -445,7 +442,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.is_after_fork("amsterdam"): block_output.block_access_list = self.fork.build_block_access_list( - block_env.state.change_tracker.block_access_list_builder + block_env.change_tracker.block_access_list_builder ) def run_blockchain_test(self) -> None: From 17d3175897cc337637c6e7d47747eb4daa32969b Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 5 Nov 2025 18:08:56 -0700 Subject: [PATCH 11/71] refactor(spec-specs): track BAL changes via frames --- .../amsterdam/block_access_lists/__init__.py | 30 - .../amsterdam/block_access_lists/builder.py | 71 +- .../amsterdam/block_access_lists/rlp_utils.py | 4 +- .../amsterdam/block_access_lists/tracker.py | 698 ------------------ src/ethereum/forks/amsterdam/fork.py | 129 ++-- src/ethereum/forks/amsterdam/state.py | 72 +- src/ethereum/forks/amsterdam/state_tracker.py | 360 +++++++++ src/ethereum/forks/amsterdam/vm/__init__.py | 16 +- .../forks/amsterdam/vm/eoa_delegation.py | 17 +- .../amsterdam/vm/instructions/environment.py | 10 +- .../amsterdam/vm/instructions/storage.py | 24 +- .../forks/amsterdam/vm/instructions/system.py | 34 +- .../forks/amsterdam/vm/interpreter.py | 107 ++- .../evm_tools/loaders/fork_loader.py | 5 - .../evm_tools/t8n/__init__.py | 18 +- 15 files changed, 691 insertions(+), 904 deletions(-) delete mode 100644 src/ethereum/forks/amsterdam/block_access_lists/tracker.py create mode 100644 src/ethereum/forks/amsterdam/state_tracker.py diff --git a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py index ebcda46e98..a83523861a 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -17,47 +17,17 @@ rlp_encode_block_access_list, validate_block_access_list_against_execution, ) -from .tracker import ( - StateChangeTracker, - begin_call_frame, - commit_call_frame, - handle_in_transaction_selfdestruct, - normalize_balance_changes, - prepare_balance_tracking, - rollback_call_frame, - set_block_access_index, - track_address_access, - track_balance_change, - track_code_change, - track_nonce_change, - track_storage_read, - track_storage_write, -) __all__ = [ "BlockAccessListBuilder", - "StateChangeTracker", "add_balance_change", "add_code_change", "add_nonce_change", "add_storage_read", "add_storage_write", "add_touched_account", - "begin_call_frame", "build_block_access_list", - "commit_call_frame", "compute_block_access_list_hash", - "handle_in_transaction_selfdestruct", - "normalize_balance_changes", - "prepare_balance_tracking", - "rollback_call_frame", - "set_block_access_index", "rlp_encode_block_access_list", - "track_address_access", - "track_balance_change", - "track_code_change", - "track_nonce_change", - "track_storage_read", - "track_storage_write", "validate_block_access_list_against_execution", ] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index f27e26c377..07a0ffe7c5 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -14,7 +14,7 @@ """ from dataclasses import dataclass, field -from typing import Dict, List, Set +from typing import TYPE_CHECKING, Dict, List, Set from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U64, U256 @@ -31,6 +31,9 @@ StorageChange, ) +if TYPE_CHECKING: + from ..state_tracker import StateChanges + @dataclass class AccountData: @@ -374,11 +377,11 @@ def add_touched_account( ensure_account(builder, address) -def build_block_access_list( +def _build_from_builder( builder: BlockAccessListBuilder, ) -> BlockAccessList: """ - Build the final [`BlockAccessList`] from accumulated changes. + 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: @@ -445,3 +448,65 @@ def build_block_access_list( account_changes_list.sort(key=lambda x: x.address) return BlockAccessList(account_changes=tuple(account_changes_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, slot) + + # Add all storage writes + for (address, slot), ( + block_access_index, + value, + ) in state_changes.storage_writes.items(): + # Convert U256 to Bytes32 for storage + value_bytes = Bytes32(value.to_bytes(U256(32), "big")) + add_storage_write( + builder, address, slot, block_access_index, value_bytes + ) + + # 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 + for address, block_access_index, new_code in state_changes.code_changes: + 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_utils.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py index bbcf4a3d21..738abce181 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -216,10 +216,10 @@ def validate_block_access_list_against_execution( # 4. If Block Access List builder provided, validate against it # by comparing hashes if block_access_list_builder is not None: - from .builder import build_block_access_list + from .builder import _build_from_builder # Build a Block Access List from the builder - expected_block_access_list = build_block_access_list( + expected_block_access_list = _build_from_builder( block_access_list_builder ) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py deleted file mode 100644 index 9008a20878..0000000000 --- a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py +++ /dev/null @@ -1,698 +0,0 @@ -""" -Provides state change tracking functionality for building Block -Access Lists during transaction execution. - -The tracker integrates with the EVM execution to capture all state accesses -and modifications, distinguishing between actual changes and no-op operations. -It maintains a cache of pre-state values to enable accurate change detection -throughout block execution. - -See [EIP-7928] for the full specification -[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 -""" - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Dict, List, Set, Tuple - -from ethereum_types.bytes import Bytes, Bytes32 -from ethereum_types.numeric import U64, U256, Uint - -from ..fork_types import Address -from .builder import ( - BlockAccessListBuilder, - add_balance_change, - add_code_change, - add_nonce_change, - add_storage_read, - add_storage_write, - add_touched_account, -) -from .rlp_types import BlockAccessIndex - -if TYPE_CHECKING: - from ..state import State # noqa: F401 - from ..vm import BlockEnvironment # noqa: F401 - - -@dataclass -class CallFrameSnapshot: - """ - Snapshot of block access list state for a single call frame. - - Used to track changes within a call frame to enable proper handling - of reverts as specified in EIP-7928. - """ - - touched_addresses: Set[Address] = field(default_factory=set) - """Addresses touched during this call frame.""" - - storage_writes: Dict[Tuple[Address, Bytes32], U256] = field( - default_factory=dict - ) - """Storage writes made during this call frame.""" - - balance_changes: Set[Tuple[Address, BlockAccessIndex, U256]] = field( - default_factory=set - ) - """Balance changes made during this call frame.""" - - nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( - default_factory=set - ) - """Nonce changes made during this call frame.""" - - code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( - default_factory=set - ) - """Code changes made during this call frame.""" - - -@dataclass -class StateChangeTracker: - """ - Tracks state changes during transaction execution for Block Access List - construction. - - This tracker maintains a cache of pre-state values and coordinates with - the [`BlockAccessListBuilder`] to record all state changes made during - block execution. It ensures that only actual changes (not no-op writes) - are recorded in the access list. - - [`BlockAccessListBuilder`]: - ref:ethereum.forks.amsterdam.block_access_lists.builder.BlockAccessListBuilder - """ - - block_access_list_builder: BlockAccessListBuilder - """ - The builder instance that accumulates all tracked changes. - """ - - pre_storage_cache: Dict[tuple, U256] = field(default_factory=dict) - """ - Cache of pre-transaction storage values, keyed by (address, slot) tuples. - This cache is cleared at the start of each transaction to track values - from the beginning of the current transaction. - """ - - pre_balance_cache: Dict[Address, U256] = field(default_factory=dict) - """ - Cache of pre-transaction balance values, keyed by address. - This cache is cleared at the start of each transaction and used by - normalize_balance_changes to filter out balance changes where - the final balance equals the initial balance. - """ - - current_block_access_index: Uint = Uint(0) - """ - The current block access index (0 for pre-execution, - 1..n for transactions, n+1 for post-execution). - """ - - call_frame_snapshots: List[CallFrameSnapshot] = field(default_factory=list) - """ - Stack of snapshots for nested call frames to handle reverts properly. - """ - - -def set_block_access_index( - block_env: "BlockEnvironment", block_access_index: Uint -) -> None: - """ - Set the current block access index for tracking changes. - - Must be called before processing each transaction/system contract - to ensure changes are associated with the correct block access index. - - Note: Block access indices differ from transaction indices: - - 0: Pre-execution (system contracts like beacon roots, block hashes) - - 1..n: Transactions (tx at index i gets block_access_index i+1) - - n+1: Post-execution (withdrawals, requests) - - Parameters - ---------- - block_env : - The block execution environment. - block_access_index : - The block access index (0 for pre-execution, - 1..n for transactions, n+1 for post-execution). - - """ - tracker = block_env.change_tracker - tracker.current_block_access_index = block_access_index - # Clear the pre-storage cache for each new transaction to ensure - # no-op writes are detected relative to the transaction start - tracker.pre_storage_cache.clear() - # Clear the pre-balance cache for each new transaction - tracker.pre_balance_cache.clear() - - -def capture_pre_state( - tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" -) -> U256: - """ - Capture and cache the pre-transaction value for a storage location. - - Retrieves the storage value from the beginning of the current transaction. - The value is cached within the transaction to avoid repeated lookups and - to maintain consistency across multiple accesses within the same - transaction. - - Parameters - ---------- - tracker : - The state change tracker instance. - address : - The account address containing the storage. - key : - The storage slot to read. - state : - The current execution state. - - Returns - ------- - value : - The storage value at the beginning of the current transaction. - - """ - cache_key = (address, key) - if cache_key not in tracker.pre_storage_cache: - # Import locally to avoid circular import - from ..state import get_storage - - tracker.pre_storage_cache[cache_key] = get_storage(state, address, key) - return tracker.pre_storage_cache[cache_key] - - -def track_address_access( - block_env: "BlockEnvironment", address: Address -) -> None: - """ - Track that an address was accessed. - - Records account access even when no state changes occur. This is - important for operations that read account data without modifying it. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address that was accessed. - - """ - add_touched_account( - block_env.change_tracker.block_access_list_builder, address - ) - - -def track_storage_read( - block_env: "BlockEnvironment", address: Address, key: Bytes32 -) -> None: - """ - Track a storage read operation. - - Records that a storage slot was read and captures its pre-state value. - The slot will only appear in the final access list if it wasn't also - written to during block execution. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose storage is being read. - key : - The storage slot being read. - - """ - track_address_access(block_env, address) - - capture_pre_state(block_env.change_tracker, address, key, block_env.state) - - add_storage_read( - block_env.change_tracker.block_access_list_builder, address, key - ) - - -def track_storage_write( - block_env: "BlockEnvironment", - address: Address, - key: Bytes32, - new_value: U256, -) -> None: - """ - Track a storage write operation. - - Records storage modifications, but only if the new value differs from - the pre-state value. No-op writes (where the value doesn't change) are - tracked as reads instead, as specified in [EIP-7928]. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose storage is being modified. - key : - The storage slot being written to. - new_value : - The new value to write. - - [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 - - """ - track_address_access(block_env, address) - - tracker = block_env.change_tracker - pre_value = capture_pre_state(tracker, address, key, block_env.state) - - value_bytes = new_value.to_be_bytes32() - - if pre_value != new_value: - add_storage_write( - tracker.block_access_list_builder, - address, - key, - BlockAccessIndex(tracker.current_block_access_index), - value_bytes, - ) - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.storage_writes[(address, key)] = new_value - else: - add_storage_read(tracker.block_access_list_builder, address, key) - - -def capture_pre_balance( - tracker: StateChangeTracker, address: Address, state: "State" -) -> U256: - """ - Capture and cache the pre-transaction balance for an account. - - This function caches the balance on first access for each address during - a transaction. It must be called before any balance modifications are made - to ensure we capture the pre-transaction balance correctly. The cache is - cleared at the beginning of each transaction. - - This is used by normalize_balance_changes to determine which balance - changes should be filtered out. - - Parameters - ---------- - tracker : - The state change tracker instance. - address : - The account address. - state : - The current execution state. - - Returns - ------- - value : - The balance at the beginning of the current transaction. - - """ - if address not in tracker.pre_balance_cache: - # Import locally to avoid circular import - from ..state import get_account - - # Cache the current balance on first access - # This should be called before any balance modifications - account = get_account(state, address) - tracker.pre_balance_cache[address] = account.balance - return tracker.pre_balance_cache[address] - - -def prepare_balance_tracking( - block_env: "BlockEnvironment", address: Address -) -> None: - """ - Prepare for tracking balance changes by caching the pre-transaction - balance. - - This should be called before any balance modifications when you need to - ensure the pre-balance is captured for later normalization. This is - particularly important for operations like withdrawals where the balance - might not actually change. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose balance will be tracked. - - - """ - # Ensure the address is tracked - track_address_access(block_env, address) - - # Cache the pre-balance for later normalization - capture_pre_balance(block_env.change_tracker, address, block_env.state) - - -def track_balance_change( - block_env: "BlockEnvironment", - address: Address, - new_balance: U256, -) -> None: - """ - Track a balance change for an account. - - Records the new balance after any balance-affecting operation, including - transfers, gas payments, block rewards, and withdrawals. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose balance changed. - new_balance : - The new balance value. - - """ - track_address_access(block_env, address) - - tracker = block_env.change_tracker - block_access_index = BlockAccessIndex(tracker.current_block_access_index) - add_balance_change( - tracker.block_access_list_builder, - address, - block_access_index, - new_balance, - ) - - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.balance_changes.add( - (address, block_access_index, new_balance) - ) - - -def track_nonce_change( - block_env: "BlockEnvironment", address: Address, new_nonce: Uint -) -> None: - """ - Track a nonce change for an account. - - Records nonce increments for both EOAs (when sending transactions) and - contracts (when performing [`CREATE`] or [`CREATE2`] operations). Deployed - contracts also have their initial nonce tracked. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose nonce changed. - new_nonce : - The new nonce value. - - [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create - [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 - - """ - track_address_access(block_env, address) - tracker = block_env.change_tracker - block_access_index = BlockAccessIndex(tracker.current_block_access_index) - nonce_u64 = U64(new_nonce) - add_nonce_change( - tracker.block_access_list_builder, - address, - block_access_index, - nonce_u64, - ) - - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.nonce_changes.add((address, block_access_index, nonce_u64)) - - -def track_code_change( - block_env: "BlockEnvironment", address: Address, new_code: Bytes -) -> None: - """ - Track a code change for contract deployment. - - Records new contract code deployments via [`CREATE`], [`CREATE2`], or - [`SETCODE`] operations. This function is called when contract bytecode - is deployed to an address. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The address receiving the contract code. - new_code : - The deployed contract bytecode. - - [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create - [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 - - """ - track_address_access(block_env, address) - tracker = block_env.change_tracker - block_access_index = BlockAccessIndex(tracker.current_block_access_index) - add_code_change( - tracker.block_access_list_builder, - address, - block_access_index, - new_code, - ) - - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.code_changes.add((address, block_access_index, new_code)) - - -def handle_in_transaction_selfdestruct( - block_env: "BlockEnvironment", address: Address -) -> None: - """ - Handle an account that self-destructed in the same transaction it was - created. - - Per EIP-7928, accounts destroyed within their creation transaction must be - included as read-only with storage writes converted to reads. Nonce and - code changes from the current transaction are also removed. - - Note: Balance changes are handled separately by - normalize_balance_changes. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The address that self-destructed. - - """ - tracker = block_env.change_tracker - builder = tracker.block_access_list_builder - if address not in builder.accounts: - return - - account_data = builder.accounts[address] - current_index = tracker.current_block_access_index - - # Convert storage writes from current tx to reads - for slot in list(account_data.storage_changes.keys()): - account_data.storage_changes[slot] = [ - c - for c in account_data.storage_changes[slot] - if c.block_access_index != current_index - ] - if not account_data.storage_changes[slot]: - del account_data.storage_changes[slot] - account_data.storage_reads.add(slot) - - # Remove nonce and code changes from current transaction - account_data.nonce_changes = [ - c - for c in account_data.nonce_changes - if c.block_access_index != current_index - ] - account_data.code_changes = [ - c - for c in account_data.code_changes - if c.block_access_index != current_index - ] - - -def normalize_balance_changes(block_env: "BlockEnvironment") -> None: - """ - Normalize balance changes for the current block access index. - - This method filters out spurious balance changes by removing all balance - changes for addresses where the post-execution balance equals the - pre-execution balance. - - This is crucial for handling cases like: - - In-transaction self-destructs where an account with 0 balance is created - and destroyed, resulting in no net balance change - - Round-trip transfers where an account receives and sends equal amounts - - Zero-amount withdrawals where the balance doesn't actually change - - This should be called at the end of any operation that tracks balance - changes (transactions, withdrawals, etc.). Only actual state changes are - recorded in the Block Access List. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - # Import locally to avoid circular import - from ..state import get_account - - tracker = block_env.change_tracker - builder = tracker.block_access_list_builder - current_index = tracker.current_block_access_index - - # Check each address that had balance changes in this transaction - for address in list(builder.accounts.keys()): - account_data = builder.accounts[address] - - # Get the pre-transaction balance - pre_balance = capture_pre_balance(tracker, address, block_env.state) - - # Get the current (post-transaction) balance - post_balance = get_account(block_env.state, address).balance - - # If pre-tx balance equals post-tx balance, remove all balance changes - # for this address in the current transaction - if pre_balance == post_balance: - # Filter out balance changes from the current transaction - account_data.balance_changes = [ - change - for change in account_data.balance_changes - if change.block_access_index != current_index - ] - - -def begin_call_frame(block_env: "BlockEnvironment") -> None: - """ - Begin a new call frame for tracking reverts. - - Creates a new snapshot to track changes within this call frame. - This allows proper handling of reverts as specified in EIP-7928. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - block_env.change_tracker.call_frame_snapshots.append(CallFrameSnapshot()) - - -def rollback_call_frame(block_env: "BlockEnvironment") -> None: - """ - Rollback changes from the current call frame. - - When a call reverts, this function: - - Converts storage writes to reads - - Removes balance, nonce, and code changes - - Preserves touched addresses - - This implements EIP-7928 revert handling where reverted writes - become reads and addresses remain in the access list. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - tracker = block_env.change_tracker - if not tracker.call_frame_snapshots: - return - - snapshot = tracker.call_frame_snapshots.pop() - builder = tracker.block_access_list_builder - - # Convert storage writes to reads - for (address, slot), _ in snapshot.storage_writes.items(): - # Remove the write from storage_changes - if address in builder.accounts: - account_data = builder.accounts[address] - if slot in account_data.storage_changes: - # Filter out changes from this call frame - account_data.storage_changes[slot] = [ - change - for change in account_data.storage_changes[slot] - if change.block_access_index - != tracker.current_block_access_index - ] - if not account_data.storage_changes[slot]: - del account_data.storage_changes[slot] - # Add as a read instead - account_data.storage_reads.add(slot) - - # Remove balance changes from this call frame - for address, block_access_index, new_balance in snapshot.balance_changes: - if address in builder.accounts: - account_data = builder.accounts[address] - # Filter out balance changes from this call frame - account_data.balance_changes = [ - change - for change in account_data.balance_changes - if not ( - change.block_access_index == block_access_index - and change.post_balance == new_balance - ) - ] - - # Remove nonce changes from this call frame - for address, block_access_index, new_nonce in snapshot.nonce_changes: - if address in builder.accounts: - account_data = builder.accounts[address] - # Filter out nonce changes from this call frame - account_data.nonce_changes = [ - change - for change in account_data.nonce_changes - if not ( - change.block_access_index == block_access_index - and change.new_nonce == new_nonce - ) - ] - - # Remove code changes from this call frame - for address, block_access_index, new_code in snapshot.code_changes: - if address in builder.accounts: - account_data = builder.accounts[address] - # Filter out code changes from this call frame - account_data.code_changes = [ - change - for change in account_data.code_changes - if not ( - change.block_access_index == block_access_index - and change.new_code == new_code - ) - ] - - # All touched addresses remain in the access list (already tracked) - - -def commit_call_frame(block_env: "BlockEnvironment") -> None: - """ - Commit changes from the current call frame. - - Removes the current call frame snapshot without rolling back changes. - Called when a call completes successfully. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - if block_env.change_tracker.call_frame_snapshots: - block_env.change_tracker.call_frame_snapshots.pop() diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index d74c8eeb80..86a7089a30 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -16,7 +16,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes -from ethereum_types.numeric import U64, U256, Uint, ulen +from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import ( @@ -30,14 +30,8 @@ from . import vm from .block_access_lists.builder import build_block_access_list +from .block_access_lists.rlp_types import BlockAccessIndex from .block_access_lists.rlp_utils import compute_block_access_list_hash -from .block_access_lists.tracker import ( - handle_in_transaction_selfdestruct, - normalize_balance_changes, - prepare_balance_tracking, - set_block_access_index, - track_balance_change, -) from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom from .exceptions import ( @@ -70,6 +64,11 @@ set_account_balance, state_root, ) +from .state_tracker import ( + create_child_frame, + handle_in_transaction_selfdestruct, + normalize_balance_changes_for_transaction, +) from .transactions import ( AccessListTransaction, BlobTransaction, @@ -779,9 +778,9 @@ def apply_body( """ block_output = vm.BlockOutput() - # Set block access index for pre-execution system contracts # EIP-7928: System contracts use block_access_index 0 - set_block_access_index(block_env, Uint(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, @@ -798,9 +797,10 @@ def apply_body( for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) - # EIP-7928: Post-execution uses block_access_index len(transactions) + 1 - post_execution_index = ulen(transactions) + Uint(1) - set_block_access_index(block_env, post_execution_index) + # 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 + block_env.block_state_changes.increment_index() process_withdrawals(block_env, block_output, withdrawals) @@ -808,8 +808,9 @@ def apply_body( block_env=block_env, block_output=block_output, ) + # Build block access list from block_env.block_state_changes block_output.block_access_list = build_block_access_list( - block_env.change_tracker.block_access_list_builder + block_env.block_state_changes ) return block_output @@ -890,9 +891,19 @@ def process_transaction( Index of the transaction in the block. """ - # EIP-7928: Transactions use block_access_index 1 to len(transactions) - # Transaction at index i gets block_access_index i+1 - set_block_access_index(block_env, index + Uint(1)) + # EIP-7928: Create a transaction-level StateChanges frame + # The frame will read the current block_access_index from the block frame + # Before transaction starts, increment block index so it's ready + block_env.block_state_changes.increment_index() + tx_state_changes = create_child_frame(block_env.block_state_changes) + + coinbase_pre_balance = get_account( + block_env.state, block_env.coinbase + ).balance + tx_state_changes.track_address(block_env.coinbase) + tx_state_changes.capture_pre_balance( + block_env.coinbase, coinbase_pre_balance + ) trie_set( block_output.transactions_trie, @@ -923,13 +934,16 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender, block_env) + increment_nonce(block_env.state, sender, tx_state_changes) sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee ) set_account_balance( - block_env.state, sender, U256(sender_balance_after_gas_fee), block_env + block_env.state, + sender, + U256(sender_balance_after_gas_fee), + tx_state_changes, ) access_list_addresses = set() @@ -967,6 +981,8 @@ def process_transaction( ) message = prepare_message(block_env, tx_env, tx) + # Set transaction frame so call frames become children of it + message.transaction_state_changes = tx_state_changes tx_output = process_message_call(message) @@ -996,7 +1012,7 @@ def process_transaction( block_env.state, sender ).balance + U256(gas_refund_amount) set_account_balance( - block_env.state, sender, sender_balance_after_refund, block_env + block_env.state, sender, sender_balance_after_refund, tx_state_changes ) # transfer miner fees @@ -1009,7 +1025,7 @@ def process_transaction( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, - block_env, + tx_state_changes, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( @@ -1017,17 +1033,6 @@ def process_transaction( ): destroy_account(block_env.state, block_env.coinbase) - for address in tx_output.accounts_to_delete: - # EIP-7928: In-transaction self-destruct - convert storage writes to - # reads and remove nonce/code changes. Only accounts created in same - # tx are in accounts_to_delete per EIP-6780. - handle_in_transaction_selfdestruct(block_env, address) - destroy_account(block_env.state, address) - - # EIP-7928: Normalize balance changes for this transaction - # Remove balance changes where post-tx balance equals pre-tx balance - normalize_balance_changes(block_env) - block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -1046,6 +1051,34 @@ def process_transaction( block_output.block_logs += tx_output.logs + # Merge transaction frame into block frame + tx_state_changes.merge_on_success() + + # EIP-7928: Handle in-transaction self-destruct AFTER merge + # Convert storage writes to reads and remove nonce/code changes + # Only accounts created in same tx are in accounts_to_delete per EIP-6780 + + for address in tx_output.accounts_to_delete: + handle_in_transaction_selfdestruct( + block_env.block_state_changes, + address, + BlockAccessIndex( + block_env.block_state_changes.get_block_access_index() + ), + ) + destroy_account(block_env.state, address) + + # EIP-7928: Normalize balance changes for this transaction + # Remove balance changes where post-tx balance equals pre-tx balance + + normalize_balance_changes_for_transaction( + block_env.block_state_changes, + BlockAccessIndex( + block_env.block_state_changes.get_block_access_index() + ), + block_env.state, + ) + def process_withdrawals( block_env: vm.BlockEnvironment, @@ -1055,6 +1088,11 @@ def process_withdrawals( """ Increase the balance of the withdrawing account. """ + withdrawal_addresses = {wd.address for wd in withdrawals} + for address in withdrawal_addresses: + pre_balance = get_account(block_env.state, address).balance + block_env.block_state_changes.track_address(address) + block_env.block_state_changes.capture_pre_balance(address, pre_balance) def increase_recipient_balance(recipient: Account) -> None: recipient.balance += wd.amount * U256(10**9) @@ -1066,25 +1104,28 @@ def increase_recipient_balance(recipient: Account) -> None: rlp.encode(wd), ) - # Prepare for balance tracking (ensures address appears in BAL and - # pre-balance is cached for normalization) - prepare_balance_tracking(block_env, wd.address) - modify_state(block_env.state, wd.address, increase_recipient_balance) - # Track balance change for BAL - # (withdrawals are tracked as system contract changes) + # Track balance change for BAL (withdrawals use post-execution index) new_balance = get_account(block_env.state, wd.address).balance - track_balance_change(block_env, wd.address, U256(new_balance)) - - # EIP-7928: Normalize balance changes for this withdrawal - # Remove balance changes where post-withdrawal balance - # equals pre-withdrawal balance - normalize_balance_changes(block_env) + block_env.block_state_changes.track_balance_change( + wd.address, new_balance + ) if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) + # EIP-7928: Normalize balance changes after all withdrawals + # Filters out net-zero changes + + normalize_balance_changes_for_transaction( + block_env.block_state_changes, + BlockAccessIndex( + block_env.block_state_changes.get_block_access_index() + ), + block_env.state, + ) + 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 3656a386c7..326595ac93 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -21,15 +21,10 @@ from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify -from ethereum_types.numeric import U256, Uint - -from .block_access_lists.tracker import ( - prepare_balance_tracking, - track_balance_change, - track_code_change, - track_nonce_change, -) +from ethereum_types.numeric import U64, U256, Uint + from .fork_types import EMPTY_ACCOUNT, Account, Address, Root +from .state_tracker import StateChanges from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set if TYPE_CHECKING: @@ -515,15 +510,18 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - block_env: "BlockEnvironment", + state_changes: StateChanges, ) -> None: """ Move funds between accounts. """ - # Prepare for balance tracking (captures pre-balance and ensures - # addresses are tracked) - prepare_balance_tracking(block_env, sender_address) - prepare_balance_tracking(block_env, recipient_address) + sender_balance = get_account(state, sender_address).balance + recipient_balance = get_account(state, recipient_address).balance + + state_changes.track_address(sender_address) + state_changes.capture_pre_balance(sender_address, sender_balance) + state_changes.track_address(recipient_address) + state_changes.capture_pre_balance(recipient_address, recipient_balance) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -539,9 +537,11 @@ def increase_recipient_balance(recipient: Account) -> None: sender_new_balance = get_account(state, sender_address).balance recipient_new_balance = get_account(state, recipient_address).balance - track_balance_change(block_env, sender_address, U256(sender_new_balance)) - track_balance_change( - block_env, recipient_address, U256(recipient_new_balance) + state_changes.track_balance_change( + sender_address, U256(sender_new_balance) + ) + state_changes.track_balance_change( + recipient_address, U256(recipient_new_balance) ) @@ -549,7 +549,7 @@ def set_account_balance( state: State, address: Address, amount: U256, - block_env: "BlockEnvironment", + state_changes: StateChanges, ) -> None: """ Sets the balance of an account. @@ -565,24 +565,26 @@ def set_account_balance( amount: The amount that needs to set in balance. - block_env: - Block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ - # Prepare for balance tracking (captures pre-balance and ensures - # address is tracked) - prepare_balance_tracking(block_env, address) + current_balance = get_account(state, address).balance + + state_changes.track_address(address) + state_changes.capture_pre_balance(address, current_balance) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - - track_balance_change(block_env, address, amount) + state_changes.track_balance_change(address, amount) def increment_nonce( - state: State, address: Address, block_env: "BlockEnvironment" + state: State, + address: Address, + state_changes: "StateChanges", ) -> None: """ Increments the nonce of an account. @@ -595,8 +597,8 @@ def increment_nonce( address: Address of the account whose nonce needs to be incremented. - block_env: - Block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ @@ -606,21 +608,15 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) # Track nonce change for Block Access List (EIP-7928) - # (for ALL accounts and ALL nonce changes) - # This includes: - # - EOA senders (transaction nonce increments) - # - Contracts performing CREATE/CREATE2 - # - Deployed contracts - # - EIP-7702 authorities account = get_account(state, address) - track_nonce_change(block_env, address, account.nonce) + state_changes.track_nonce_change(address, U64(account.nonce)) def set_code( state: State, address: Address, code: Bytes, - block_env: "BlockEnvironment", + state_changes: StateChanges, ) -> None: """ Sets Account code. @@ -636,8 +632,8 @@ def set_code( code: The bytecode that needs to be set. - block_env: - Block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ @@ -651,7 +647,7 @@ def write_code(sender: Account) -> None: # code to b"" is not a meaningful state change since the address # had no code to begin with. if not (code == b"" and address in state.created_accounts): - track_code_change(block_env, address, code) + state_changes.track_code_change(address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py new file mode 100644 index 0000000000..7ee77259b1 --- /dev/null +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -0,0 +1,360 @@ +""" +Hierarchical state change tracking for EIP-7928 Block Access Lists. + +Implements a frame-based hierarchy: Block → Transaction → Call frames. +Each frame tracks state changes and merges upward on completion: +- Success: merge all changes (reads + writes) +- Failure: merge only reads (writes discarded) + +Frame Hierarchy: + Block Frame: Root, lifetime = entire block, index 0..N+1 + Transaction Frame: Child of block, lifetime = single transaction + Call Frame: Child of transaction/call, lifetime = single message + +Block Access Index: 0=pre-exec, 1..N=transactions, N+1=post-exec +Stored in root frame, accessed by walking parent chain. + +Pre-State Tracking: Values captured before modifications to enable +net-zero filtering. + +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, 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 + +if TYPE_CHECKING: + from .state import State + + +@dataclass +class StateChanges: + """ + Tracks state changes within a single execution frame. + + Frames form a hierarchy and merge changes upward on completion. + """ + + 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], Tuple[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: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( + default_factory=set + ) + + # Pre-state captures for net-zero filtering + pre_balances: Dict[Address, U256] = field(default_factory=dict) + pre_nonces: Dict[Address, U64] = field(default_factory=dict) + pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( + default_factory=dict + ) + + def get_block_access_index(self) -> BlockAccessIndex: + """Get current block access index by walking to root.""" + current = self + while current.parent is not None: + current = current.parent + return current._block_access_index + + def capture_pre_balance(self, address: Address, balance: U256) -> None: + """Capture pre-balance (first-write-wins for net-zero filtering).""" + if address not in self.pre_balances: + self.pre_balances[address] = balance + + def capture_pre_nonce(self, address: Address, nonce: U64) -> None: + """Capture pre-nonce (first-write-wins).""" + if address not in self.pre_nonces: + self.pre_nonces[address] = nonce + + def capture_pre_storage( + self, address: Address, key: Bytes32, value: U256 + ) -> None: + """Capture pre-storage (first-write-wins for noop filtering).""" + slot = (address, key) + if slot not in self.pre_storage: + self.pre_storage[slot] = value + + def track_address(self, address: Address) -> None: + """Track that an address was accessed.""" + self.touched_addresses.add(address) + + def track_storage_read(self, address: Address, key: Bytes32) -> None: + """Track a storage read operation.""" + self.storage_reads.add((address, key)) + + def track_storage_write( + self, address: Address, key: Bytes32, value: U256 + ) -> None: + """Track a storage write operation with block access index.""" + self.storage_writes[(address, key)] = ( + self.get_block_access_index(), + value, + ) + + def track_balance_change( + self, address: Address, new_balance: U256 + ) -> None: + """Track balance change keyed by (address, index).""" + self.balance_changes[(address, self.get_block_access_index())] = ( + new_balance + ) + + def track_nonce_change(self, address: Address, new_nonce: U64) -> None: + """Track a nonce change.""" + self.nonce_changes.add( + (address, self.get_block_access_index(), new_nonce) + ) + + def track_code_change(self, address: Address, new_code: Bytes) -> None: + """Track a code change.""" + self.code_changes.add( + (address, self.get_block_access_index(), new_code) + ) + + def increment_index(self) -> None: + """Increment block access index by walking to root.""" + root = self + while root.parent is not None: + root = root.parent + root._block_access_index = BlockAccessIndex( + root._block_access_index + Uint(1) + ) + + def merge_on_success(self) -> None: + """ + Merge this frame's changes into parent on successful completion. + + Merges all tracked changes (reads and writes) from this frame + into the parent frame. Filters out net-zero changes based on + captured pre-state values by comparing initial vs final values. + """ + if self.parent is None: + return + + # Merge address accesses + self.parent.touched_addresses.update(self.touched_addresses) + + # Merge pre-state captures for transaction-level normalization + # Only if parent doesn't have value (first capture wins) + for addr, balance in self.pre_balances.items(): + if addr not in self.parent.pre_balances: + self.parent.pre_balances[addr] = balance + for addr, nonce in self.pre_nonces.items(): + if addr not in self.parent.pre_nonces: + self.parent.pre_nonces[addr] = nonce + for slot, value in self.pre_storage.items(): + if slot not in self.parent.pre_storage: + self.parent.pre_storage[slot] = value + + # Merge storage operations, filtering noop writes + self.parent.storage_reads.update(self.storage_reads) + for (addr, key), (idx, value) in self.storage_writes.items(): + # Only merge if value actually changed from pre-state + if (addr, key) in self.pre_storage: + if self.pre_storage[(addr, key)] != value: + self.parent.storage_writes[(addr, key)] = (idx, value) + # If equal, it's a noop write - convert to read only + else: + self.parent.storage_reads.add((addr, key)) + else: + # No pre-state captured, merge as-is + self.parent.storage_writes[(addr, key)] = (idx, value) + + # Merge balance changes - filter net-zero changes + # balance_changes keyed by (address, index) + for (addr, idx), final_balance in self.balance_changes.items(): + if addr in self.pre_balances: + if self.pre_balances[addr] != final_balance: + # Net change occurred - merge the final balance + self.parent.balance_changes[(addr, idx)] = final_balance + # else: Net-zero change - skip entirely + else: + # No pre-balance captured, merge as-is + self.parent.balance_changes[(addr, idx)] = final_balance + + # Merge nonce changes - keep only highest nonce per address + # Nonces are monotonically increasing, so just keep the max + address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} + for addr, idx, nonce in self.nonce_changes: + # Keep the highest nonce value for each address + if ( + addr not in address_final_nonces + or nonce > address_final_nonces[addr][1] + ): + address_final_nonces[addr] = (idx, nonce) + + # Merge final nonces (no net-zero filtering - nonces never decrease) + for addr, (idx, final_nonce) in address_final_nonces.items(): + self.parent.nonce_changes.add((addr, idx, final_nonce)) + + # Merge code changes - keep only latest code per address + address_final_code: Dict[Address, Tuple[BlockAccessIndex, Bytes]] = {} + for addr, idx, code in self.code_changes: + # Keep the change with highest index (most recent) + if ( + addr not in address_final_code + or idx >= address_final_code[addr][0] + ): + address_final_code[addr] = (idx, code) + + # Merge final code changes + for addr, (idx, final_code) in address_final_code.items(): + self.parent.code_changes.add((addr, idx, final_code)) + + def merge_on_failure(self) -> None: + """ + Merge this frame's changes into parent on failed completion. + + Merges only read operations from this frame into the parent. + Write operations are discarded since the frame reverted. + This is called when a call frame fails/reverts. + """ + if self.parent is None: + return + + # Only merge reads and address accesses on failure + self.parent.touched_addresses.update(self.touched_addresses) + self.parent.storage_reads.update(self.storage_reads) + + # Convert writes to reads (failed writes still accessed the slots) + for address, key in self.storage_writes.keys(): + self.parent.storage_reads.add((address, key)) + + # Note: balance_changes, nonce_changes, and code_changes are NOT + # merged on failure - they are discarded + + +def handle_in_transaction_selfdestruct( + state_changes: StateChanges, + address: Address, + current_block_access_index: BlockAccessIndex, +) -> None: + """ + Handle account self-destructed in same transaction as creation. + + Per EIP-7928 and EIP-6780, accounts destroyed within their creation + transaction must have: + - Nonce changes from current transaction removed + - Code changes from current transaction removed + - Storage writes from current transaction converted to reads + - Balance changes handled by net-zero filtering + + Parameters + ---------- + state_changes : StateChanges + The state changes tracker (typically the block-level frame). + address : Address + The address that self-destructed. + current_block_access_index : BlockAccessIndex + The current block access index (transaction index). + + """ + # Remove nonce changes from current transaction + state_changes.nonce_changes = { + (addr, idx, nonce) + for addr, idx, nonce in state_changes.nonce_changes + if not (addr == address and idx == current_block_access_index) + } + + # Remove code changes from current transaction + state_changes.code_changes = { + (addr, idx, code) + for addr, idx, code in state_changes.code_changes + if not (addr == address and idx == current_block_access_index) + } + + # Convert storage writes from current transaction to reads + for (addr, key), (idx, _value) in list( + state_changes.storage_writes.items() + ): + if addr == address and idx == current_block_access_index: + del state_changes.storage_writes[(addr, key)] + state_changes.storage_reads.add((addr, key)) + + +def normalize_balance_changes_for_transaction( + block_frame: StateChanges, + current_block_access_index: BlockAccessIndex, + state: "State", +) -> None: + """ + Normalize balance changes for the current transaction. + + Removes balance changes where post-transaction balance equals + pre-transaction balance. This handles net-zero transfers across + the entire transaction. + + This function should be called after merging transaction frames + into the block frame to filter out addresses where balance didn't + actually change from transaction start to transaction end. + + Parameters + ---------- + block_frame : StateChanges + The block-level state changes frame. + current_block_access_index : BlockAccessIndex + The current transaction's block access index. + state : State + The current state to read final balances from. + + """ + # Import locally to avoid circular import + from .state import get_account + + # Collect addresses that have balance changes in this transaction + addresses_to_check = [ + addr + for (addr, idx) in block_frame.balance_changes.keys() + if idx == current_block_access_index + ] + + # For each address, compare pre vs post balance + for addr in addresses_to_check: + if addr in block_frame.pre_balances: + pre_balance = block_frame.pre_balances[addr] + post_balance = get_account(state, addr).balance + + if pre_balance == post_balance: + # Remove balance change for this address - net-zero transfer + del block_frame.balance_changes[ + (addr, current_block_access_index) + ] + + +def create_child_frame(parent: StateChanges) -> StateChanges: + """ + Create a child frame for nested execution. + + The child frame will dynamically read the block_access_index from + the root (block) frame, ensuring all frames see the same current index. + + Parameters + ---------- + parent : StateChanges + The parent frame. + + Returns + ------- + child : StateChanges + A new child frame with parent link. + + """ + return StateChanges(parent=parent) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index c10df4897b..04b74eee9e 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,12 +21,11 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException -from ..block_access_lists.builder import BlockAccessListBuilder from ..block_access_lists.rlp_types import BlockAccessList -from ..block_access_lists.tracker import StateChangeTracker from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage +from ..state_tracker import StateChanges from ..transactions import LegacyTransaction from ..trie import Trie @@ -50,8 +49,8 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 - change_tracker: StateChangeTracker = field( - default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) + block_state_changes: StateChanges = field( + default_factory=lambda: StateChanges() ) @@ -143,6 +142,7 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] + transaction_state_changes: Optional[StateChanges] = None @dataclass @@ -165,6 +165,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: @@ -186,6 +187,9 @@ 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 state changes from successful child frame (EIP-7928) + child_evm.state_changes.merge_on_success() + def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ @@ -200,3 +204,7 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left + + # Merge state changes from failed child frame (EIP-7928) + # Only reads are merged, writes are discarded + child_evm.state_changes.merge_on_failure() diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index c831f9d337..4f2d5d5f1c 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,7 +12,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError -from ..block_access_lists.tracker import track_address_access +# track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization from ..state import account_exists, get_account, increment_nonce, set_code from ..utils.hexadecimal import hex_to_address @@ -175,12 +175,12 @@ def apply_delegation_tracking( The address delegated to. """ - track_address_access(evm.message.block_env, original_address) + evm.state_changes.track_address(original_address) if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) - track_address_access(evm.message.block_env, delegated_address) + evm.state_changes.track_address(delegated_address) def access_delegation( @@ -239,7 +239,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - track_address_access(message.block_env, authority) + message.block_env.block_state_changes.track_address(authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -255,9 +255,14 @@ 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, message.block_env) - increment_nonce(state, authority, message.block_env) + # Use transaction frame, not block frame (EIP-7928) + state_changes = ( + message.transaction_state_changes + or message.block_env.block_state_changes + ) + set_code(state, authority, code_to_set, state_changes) + increment_nonce(state, authority, state_changes) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index e984d8030f..dae8c20280 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,7 +17,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.utils.numeric import ceil32 -from ...block_access_lists.tracker import track_address_access +# track_address_access removed - now using state_changes.track_address() from ...fork_types import EMPTY_ACCOUNT from ...state import get_account from ...utils.address import to_address_masked @@ -83,7 +83,7 @@ def balance(evm: Evm) -> None: check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, gas_cost) # OPERATION @@ -353,7 +353,7 @@ def extcodesize(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, access_gas_cost) # OPERATION @@ -399,7 +399,7 @@ def extcodecopy(evm: Evm) -> None: check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, total_gas_cost) # OPERATION @@ -493,7 +493,7 @@ def extcodehash(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, access_gas_cost) # OPERATION diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index f8bf08ca29..1709dab5d7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -13,10 +13,6 @@ from ethereum_types.numeric import Uint -from ...block_access_lists.tracker import ( - track_storage_read, - track_storage_write, -) from ...state import ( get_storage, get_storage_original, @@ -62,8 +58,7 @@ def sload(evm: Evm) -> None: check_gas(evm, gas_cost) if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) - track_storage_read( - evm.message.block_env, + evm.state_changes.track_storage_read( evm.message.current_target, key, ) @@ -100,11 +95,6 @@ def sstore(evm: Evm) -> None: state, evm.message.current_target, key ) current_value = get_storage(state, evm.message.current_target, key) - track_storage_read( - evm.message.block_env, - evm.message.current_target, - key, - ) # GAS gas_cost = Uint(0) @@ -124,6 +114,15 @@ def sstore(evm: Evm) -> None: else: gas_cost += GAS_WARM_ACCESS + # Track storage access BEFORE checking gas (EIP-7928) + # Even if we run out of gas, the access attempt should be tracked + evm.state_changes.capture_pre_storage( + evm.message.current_target, key, current_value + ) + evm.state_changes.track_storage_read( + evm.message.current_target, + key, + ) check_gas(evm, gas_cost) if is_cold_access: @@ -156,8 +155,7 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) - track_storage_write( - evm.message.block_env, + evm.state_changes.track_storage_write( evm.message.current_target, key, new_value, diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 873e1cd4d8..e193b539de 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -16,7 +16,7 @@ from ethereum.utils.numeric import ceil32 -from ...block_access_lists.tracker import track_address_access +# track_address_access removed - now using state_changes.track_address() from ...fork_types import Address from ...state import ( account_has_code_or_nonce, @@ -114,7 +114,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - track_address_access(evm.message.block_env, contract_address) + evm.state_changes.track_address(contract_address) if account_has_code_or_nonce( state, contract_address @@ -122,7 +122,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, - evm.message.block_env, + evm.state_changes, ) push(evm.stack, U256(0)) return @@ -130,7 +130,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, - evm.message.block_env, + evm.state_changes, ) child_message = Message( @@ -327,6 +327,8 @@ def generic_call( evm.memory, memory_input_start_position, memory_input_size ) + # EIP-7928: Child message inherits transaction_state_changes from parent + # The actual child frame will be created automatically in process_message child_message = Message( block_env=evm.message.block_env, tx_env=evm.message.tx_env, @@ -345,6 +347,7 @@ def generic_call( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=disable_precompiles, parent_evm=evm, + transaction_state_changes=evm.message.transaction_state_changes, ) child_evm = process_message(child_message) @@ -428,7 +431,7 @@ def call(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) - track_address_access(evm.message.block_env, to) + evm.state_changes.track_address(to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -527,7 +530,7 @@ def callcode(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(original_address) - track_address_access(evm.message.block_env, original_address) + evm.state_changes.track_address(original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -539,6 +542,15 @@ 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 parent frame. CALLCODE transfers value from/to current_target + # (same address), affecting current storage context, not child frame + if value != 0 and sender_balance >= value: + evm.state_changes.capture_pre_balance( + evm.message.current_target, sender_balance + ) + if sender_balance < value: push(evm.stack, U256(0)) evm.return_data = b"" @@ -601,7 +613,7 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address_access(evm.message.block_env, beneficiary) + evm.state_changes.track_address(beneficiary) charge_gas(evm, gas_cost) @@ -615,7 +627,7 @@ def selfdestruct(evm: Evm) -> None: originator, beneficiary, originator_balance, - evm.message.block_env, + evm.state_changes, ) # register account for deletion only if it was created @@ -627,7 +639,7 @@ def selfdestruct(evm: Evm) -> None: evm.message.block_env.state, originator, U256(0), - evm.message.block_env, + evm.state_changes, ) evm.accounts_to_delete.add(originator) @@ -691,7 +703,7 @@ def delegatecall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(original_address) - track_address_access(evm.message.block_env, original_address) + evm.state_changes.track_address(original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -778,7 +790,7 @@ def staticcall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) - track_address_access(evm.message.block_env, to) + evm.state_changes.track_address(to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 8cd40cbce1..ab07912389 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -29,12 +29,6 @@ evm_trace, ) -from ..block_access_lists.tracker import ( - begin_call_frame, - commit_call_frame, - rollback_call_frame, - track_address_access, -) from ..blocks import Log from ..fork_types import Address from ..state import ( @@ -50,6 +44,7 @@ rollback_transaction, set_code, ) +from ..state_tracker import StateChanges, create_child_frame from ..vm import Message from ..vm.eoa_delegation import get_delegated_code_address, set_delegation from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas @@ -72,6 +67,59 @@ MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE +def create_call_frame(parent_frame: StateChanges) -> StateChanges: + """ + Create a child frame for call-level state tracking. + + Used for contract calls (CALL, DELEGATECALL, STATICCALL, etc.) where + state changes need to be isolated and potentially reverted. + + Parameters + ---------- + parent_frame : + The parent frame (transaction or another call frame). + + Returns + ------- + call_frame : StateChanges + A new child frame linked to the parent. + + """ + return create_child_frame(parent_frame) + + +def get_message_state_frame(message: Message) -> StateChanges: + """ + Determine and create the appropriate state tracking frame for a message. + + Frame selection logic: + - Nested calls: Create child of parent EVM's frame + - Top-level calls: Create child of transaction frame + - System transactions: Use block frame directly (no isolation needed) + + Parameters + ---------- + message : + The message being processed. + + Returns + ------- + state_frame : StateChanges + The state tracking frame to use for this message execution. + + """ + if message.parent_evm is not None: + # Nested call - create child of parent EVM's frame + return create_call_frame(message.parent_evm.state_changes) + elif message.transaction_state_changes is not None: + # Top-level transaction call - create child of transaction frame + # This ensures contract execution is isolated and can be reverted + return create_call_frame(message.transaction_state_changes) + else: + # System transaction - use block frame directly + return message.block_env.block_state_changes + + @dataclass class MessageCallOutput: """ @@ -140,9 +188,8 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code_address = delegated_address # EIP-7928: Track delegation target when loaded as call target - track_address_access( - block_env, - delegated_address, + message.block_env.block_state_changes.track_address( + delegated_address ) evm = process_message(message) @@ -205,7 +252,9 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce(state, message.current_target, message.block_env) + increment_nonce( + state, message.current_target, message.block_env.block_state_changes + ) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -224,7 +273,10 @@ def process_create_message(message: Message) -> Evm: evm.error = error else: set_code( - state, message.current_target, contract_code, message.block_env + state, + message.current_target, + contract_code, + message.block_env.block_state_changes, ) commit_transaction(state, transient_storage) else: @@ -252,16 +304,10 @@ def process_message(message: Message) -> Evm: if message.depth > STACK_DEPTH_LIMIT: raise StackDepthLimitError("Stack depth limit reached") - # take snapshot of state before processing the message begin_transaction(state, transient_storage) - if ( - hasattr(message.block_env, "change_tracker") - and message.block_env.change_tracker - ): - begin_call_frame(message.block_env) - # Track target address access when processing a message - track_address_access(message.block_env, message.current_target) + state_changes = get_message_state_frame(message) + state_changes.track_address(message.current_target) if message.should_transfer_value and message.value != 0: move_ether( @@ -269,30 +315,24 @@ def process_message(message: Message) -> Evm: message.caller, message.current_target, message.value, - message.block_env, + state_changes, ) - evm = execute_code(message) + evm = execute_code(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 ( - hasattr(message.block_env, "change_tracker") - and message.block_env.change_tracker - ): - rollback_call_frame(message.block_env) + # Merge call frame state changes into parent + evm.state_changes.merge_on_failure() else: commit_transaction(state, transient_storage) - if ( - hasattr(message.block_env, "change_tracker") - and message.block_env.change_tracker - ): - commit_call_frame(message.block_env) + # Merge call frame state changes into parent + evm.state_changes.merge_on_success() return evm -def execute_code(message: Message) -> Evm: +def execute_code(message: Message, state_changes: StateChanges) -> Evm: """ Executes bytecode present in the `message`. @@ -300,6 +340,8 @@ def execute_code(message: Message) -> Evm: ---------- message : Transaction specific items. + state_changes : + The state changes frame to use for tracking. Returns ------- @@ -327,6 +369,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_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 5aebe681f3..002bde2e84 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -132,11 +132,6 @@ def compute_block_access_list_hash(self) -> Any: "block_access_lists" ).compute_block_access_list_hash - @property - def set_block_access_index(self) -> Any: - """set_block_access_index function of the fork.""" - return self._module("block_access_lists").set_block_access_index - @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 3c07ec1d20..2100983144 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -10,7 +10,7 @@ from typing import Any, Final, TextIO, Tuple, Type, TypeVar from ethereum_rlp import rlp -from ethereum_types.numeric import U64, U256, Uint, ulen +from ethereum_types.numeric import U64, U256, Uint from typing_extensions import override from ethereum import trace @@ -411,18 +411,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: f"Transaction {original_idx} failed: {e!r}" ) + # Post-execution operations use index N+1 if self.fork.is_after_fork("amsterdam"): - num_transactions = ulen( - [ - tx_idx - for tx_idx in self.txs.successfully_parsed - if tx_idx is not None - ] - ) - - # post-execution use n + 1 - post_execution_index = num_transactions + Uint(1) - self.fork.set_block_access_index(block_env, post_execution_index) + block_env.block_state_changes.increment_index() if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -441,8 +432,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: self.fork.process_general_purpose_requests(block_env, block_output) if self.fork.is_after_fork("amsterdam"): + # Build block access list from block_env.block_state_changes block_output.block_access_list = self.fork.build_block_access_list( - block_env.change_tracker.block_access_list_builder + block_env.block_state_changes ) def run_blockchain_test(self) -> None: From 01f433de17d2991dfd1cecbb426b4df4b52ecfc0 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 11 Nov 2025 09:26:14 -0700 Subject: [PATCH 12/71] fix(spec-specs): Mark original addr warm before delegation Co-authored-by: spencer spencer.taylor-brown@ethereum.org --- .../forks/amsterdam/vm/instructions/system.py | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index e193b539de..136f194655 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -401,6 +401,8 @@ def call(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(to) ( is_delegated, @@ -428,11 +430,7 @@ def call(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(to) - evm.state_changes.track_address(to) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -503,6 +501,8 @@ def callcode(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(code_address) ( is_delegated, @@ -527,11 +527,7 @@ def callcode(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(original_address) - evm.state_changes.track_address(original_address) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -681,6 +677,8 @@ def delegatecall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(code_address) ( is_delegated, @@ -700,11 +698,7 @@ def delegatecall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(original_address) - evm.state_changes.track_address(original_address) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -764,6 +758,8 @@ def staticcall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(to) ( is_delegated, @@ -787,11 +783,7 @@ def staticcall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(to) - evm.state_changes.track_address(to) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) From c17dcba59fea489ddd1eb4eeac508a5052b20e08 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 11 Nov 2025 15:56:19 -0700 Subject: [PATCH 13/71] fix(spec-specs): Make sure we account for no changes --- .../forks/amsterdam/block_access_lists/builder.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 07a0ffe7c5..843cc42c4e 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -483,11 +483,18 @@ def build_block_access_list( for address, slot in state_changes.storage_reads: add_storage_read(builder, address, slot) - # Add all storage writes + # Add all storage writes, filtering net-zero changes for (address, slot), ( block_access_index, value, ) in state_changes.storage_writes.items(): + # Check if this is a net-zero change by comparing with pre-state + if (address, slot) in state_changes.pre_storage: + if state_changes.pre_storage[(address, slot)] == value: + # Net-zero change - convert to read only + add_storage_read(builder, address, slot) + continue + # Convert U256 to Bytes32 for storage value_bytes = Bytes32(value.to_bytes(U256(32), "big")) add_storage_write( From 3458cbf950887b67363c26b7bd43be3b4c77be2a Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 11:40:18 -0700 Subject: [PATCH 14/71] fix(spec-specs): Better tracking for code changes; ensure with BAL test --- .../amsterdam/block_access_lists/builder.py | 5 +- src/ethereum/forks/amsterdam/state.py | 8 +-- src/ethereum/forks/amsterdam/state_tracker.py | 48 ++++++++-------- .../forks/amsterdam/vm/eoa_delegation.py | 3 + .../test_block_access_lists_eip7702.py | 55 +++++++++++++++++++ .../test_cases.md | 1 + 6 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 843cc42c4e..3e1870b0b5 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -513,7 +513,10 @@ def build_block_access_list( add_nonce_change(builder, address, block_access_index, new_nonce) # Add all code changes - for address, block_access_index, new_code in state_changes.code_changes: + 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/state.py b/src/ethereum/forks/amsterdam/state.py index 326595ac93..8f58b6e815 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -641,13 +641,7 @@ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) - - # Only track code changes if it's not setting empty code on a - # newly created address (EIP-7928). For newly created addresses, setting - # code to b"" is not a meaningful state change since the address - # had no code to begin with. - if not (code == b"" and address in state.created_accounts): - state_changes.track_code_change(address, code) + state_changes.track_code_change(address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 7ee77259b1..42203d5e8d 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -56,8 +56,8 @@ class StateChanges: nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( default_factory=set ) - code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( - default_factory=set + code_changes: Dict[Tuple[Address, BlockAccessIndex], Bytes] = field( + default_factory=dict ) # Pre-state captures for net-zero filtering @@ -66,6 +66,7 @@ class StateChanges: pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) + pre_code: Dict[Address, Bytes] = field(default_factory=dict) def get_block_access_index(self) -> BlockAccessIndex: """Get current block access index by walking to root.""" @@ -92,6 +93,11 @@ def capture_pre_storage( if slot not in self.pre_storage: self.pre_storage[slot] = value + def capture_pre_code(self, address: Address, code: Bytes) -> None: + """Capture pre-code (first-write-wins).""" + if address not in self.pre_code: + self.pre_code[address] = code + def track_address(self, address: Address) -> None: """Track that an address was accessed.""" self.touched_addresses.add(address) @@ -125,9 +131,7 @@ def track_nonce_change(self, address: Address, new_nonce: U64) -> None: def track_code_change(self, address: Address, new_code: Bytes) -> None: """Track a code change.""" - self.code_changes.add( - (address, self.get_block_access_index(), new_code) - ) + self.code_changes[(address, self.get_block_access_index())] = new_code def increment_index(self) -> None: """Increment block access index by walking to root.""" @@ -163,6 +167,9 @@ def merge_on_success(self) -> None: for slot, value in self.pre_storage.items(): if slot not in self.parent.pre_storage: self.parent.pre_storage[slot] = value + for addr, code in self.pre_code.items(): + if addr not in self.parent.pre_code: + self.parent.pre_code[addr] = code # Merge storage operations, filtering noop writes self.parent.storage_reads.update(self.storage_reads) @@ -205,19 +212,17 @@ def merge_on_success(self) -> None: for addr, (idx, final_nonce) in address_final_nonces.items(): self.parent.nonce_changes.add((addr, idx, final_nonce)) - # Merge code changes - keep only latest code per address - address_final_code: Dict[Address, Tuple[BlockAccessIndex, Bytes]] = {} - for addr, idx, code in self.code_changes: - # Keep the change with highest index (most recent) - if ( - addr not in address_final_code - or idx >= address_final_code[addr][0] - ): - address_final_code[addr] = (idx, code) - - # Merge final code changes - for addr, (idx, final_code) in address_final_code.items(): - self.parent.code_changes.add((addr, idx, final_code)) + # Merge code changes - filter net-zero changes + # code_changes keyed by (address, index) + for (addr, idx), final_code in self.code_changes.items(): + if addr in self.pre_code: + if self.pre_code[addr] != final_code: + # Net change occurred - merge the final code + self.parent.code_changes[(addr, idx)] = final_code + # else: Net-zero change - skip entirely + else: + # No pre-code captured, merge as-is + self.parent.code_changes[(addr, idx)] = final_code def merge_on_failure(self) -> None: """ @@ -275,11 +280,8 @@ def handle_in_transaction_selfdestruct( } # Remove code changes from current transaction - state_changes.code_changes = { - (addr, idx, code) - for addr, idx, code in state_changes.code_changes - if not (addr == address and idx == current_block_access_index) - } + if (address, current_block_access_index) in state_changes.code_changes: + del state_changes.code_changes[(address, current_block_access_index)] # Convert storage writes from current transaction to reads for (addr, key), (idx, _value) in list( diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 4f2d5d5f1c..bce49462f2 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -261,6 +261,9 @@ def set_delegation(message: Message) -> U256: message.transaction_state_changes or message.block_env.block_state_changes ) + + # Capture pre-code just before setting to enable no-op filtering + state_changes.capture_pre_code(authority, authority_code) set_code(state, authority, code_to_set, state_changes) increment_nonce(state, authority, state_changes) 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 5ff9e7b135..2a315b990f 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 @@ -608,3 +608,58 @@ 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(tx_index=1, post_nonce=2)], + code_changes=[], # explicit check for no code changes + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_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), + }, + ) 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 351395479d..34fa49528d 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -39,6 +39,7 @@ | `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. | `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_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 | From 9fef2f78698d1ebb17cd9dcbdee97172505bb160 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 13:57:12 -0700 Subject: [PATCH 15/71] fix(spec-specs): Use child frame for create message --- .../forks/amsterdam/vm/interpreter.py | 15 +++- .../test_block_access_lists_opcodes.py | 83 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index ab07912389..9d6190b0e8 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -252,9 +252,11 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce( - state, message.current_target, message.block_env.block_state_changes - ) + # Create a temporary child frame for tracking changes that may be rolled + # back on OOG during code deposit. This frame is merged only on success. + create_frame = create_child_frame(message.block_env.block_state_changes) + + increment_nonce(state, message.current_target, create_frame) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -268,6 +270,9 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) + # Merge create_frame on failure - keeps reads, discards writes + # (address access is preserved, nonce change is discarded) + create_frame.merge_on_failure() evm.gas_left = Uint(0) evm.output = b"" evm.error = error @@ -276,9 +281,11 @@ def process_create_message(message: Message) -> Evm: state, message.current_target, contract_code, - message.block_env.block_state_changes, + create_frame, ) commit_transaction(state, transient_storage) + # Merge create_frame on success - includes nonce and code changes + create_frame.merge_on_success() else: rollback_transaction(state, transient_storage) return evm 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 17799d3655..207a87d9a8 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 @@ -32,6 +32,7 @@ Fork, Op, Transaction, + compute_create_address, ) from .spec import ref_spec_7928 @@ -754,3 +755,85 @@ def test_bal_storage_write_read_cross_frame( 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(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + storage_changes=[ + BalStorageSlot( + slot=1, + slot_changes=[ + # SSTORE saves 0 (CREATE failed) + BalStorageChange(tx_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, + }, + ) 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 34fa49528d..ceea2b0842 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,6 +49,7 @@ | `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_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_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_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 | From 20fff5f0777658b4480b227afc81e2a0ec694146 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 15:07:17 -0700 Subject: [PATCH 16/71] fix(spec-specs): Normalize transaction before merging to block frame --- src/ethereum/forks/amsterdam/fork.py | 20 ++--- .../test_block_access_lists.py | 89 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 86a7089a30..25968e4598 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1051,13 +1051,20 @@ def process_transaction( block_output.block_logs += tx_output.logs + # EIP-7928: Normalize balance changes for this transaction before merging + # into block frame. + normalize_balance_changes_for_transaction( + tx_state_changes, + BlockAccessIndex(tx_state_changes.get_block_access_index()), + block_env.state, + ) + # Merge transaction frame into block frame tx_state_changes.merge_on_success() # EIP-7928: Handle in-transaction self-destruct AFTER merge # Convert storage writes to reads and remove nonce/code changes # Only accounts created in same tx are in accounts_to_delete per EIP-6780 - for address in tx_output.accounts_to_delete: handle_in_transaction_selfdestruct( block_env.block_state_changes, @@ -1068,17 +1075,6 @@ def process_transaction( ) destroy_account(block_env.state, address) - # EIP-7928: Normalize balance changes for this transaction - # Remove balance changes where post-tx balance equals pre-tx balance - - normalize_balance_changes_for_transaction( - block_env.block_state_changes, - BlockAccessIndex( - block_env.block_state_changes.get_block_access_index() - ), - block_env.state, - ) - def process_withdrawals( block_env: vm.BlockEnvironment, 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 9f1214fe4c..6d03e9bb9a 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 @@ -1925,3 +1925,92 @@ def test_bal_nonexistent_account_access_value_transfer( 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(tx_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=2, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=bob_balance_after_tx0, + ), + BalBalanceChange( + tx_index=2, + post_balance=bob_balance_after_tx1, + ), + ], + ), + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=2, post_balance=spend_amount + ) + ], + ), + } + ), + ) + ], + post={ + bob: Account(nonce=1, balance=bob_balance_after_tx1), + charlie: Account(balance=spend_amount), + }, + ) 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 ceea2b0842..c33f56eb43 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,6 +49,7 @@ | `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_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_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_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 | From cea34a81981a759bef4120aa1448b644c2af5ac3 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 15:43:40 -0700 Subject: [PATCH 17/71] fix(spec-specs): Early static check for SSTORE before any reads --- .../amsterdam/vm/instructions/storage.py | 6 +- .../test_block_access_lists_opcodes.py | 73 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 1709dab5d7..db1536a707 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -90,6 +90,10 @@ def sstore(evm: Evm) -> None: if evm.gas_left <= GAS_CALL_STIPEND: raise OutOfGasError + # Check static context before accessing storage + if evm.message.is_static: + raise WriteInStaticContext + state = evm.message.block_env.state original_value = get_storage_original( state, evm.message.current_target, key @@ -129,8 +133,6 @@ def sstore(evm: Evm) -> None: evm.accessed_storage_keys.add((evm.message.current_target, key)) charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext # REFUND COUNTER if current_value != new_value: 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 207a87d9a8..de354bbeb1 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 @@ -837,3 +837,76 @@ def test_bal_create_oog_code_deposit( 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(tx_index=1, post_nonce=1) + ], + ), + contract_a: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + tx_index=1, post_value=1 + ), + ], + ), + ], + ), + contract_b: BalAccountExpectation.empty(), + } + ), + ) + ], + post={ + contract_a: Account(storage={0: 1}), + contract_b: Account(storage={0: 0}), # SSTORE failed + }, + ) 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 c33f56eb43..1f329b9a3b 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -41,6 +41,7 @@ | `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. | `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 | From 6d2f759943e5216ed3984f15c67349ae8cc8c9e0 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 18:50:27 -0700 Subject: [PATCH 18/71] fix(spec-specs): Track storage writes more appropriately wrt index --- .../amsterdam/block_access_lists/builder.py | 7 +- src/ethereum/forks/amsterdam/state_tracker.py | 25 +++---- .../test_block_access_lists.py | 74 +++++++++++++++++++ .../test_cases.md | 1 + 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 3e1870b0b5..4ed7aa767c 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -484,10 +484,11 @@ def build_block_access_list( add_storage_read(builder, address, slot) # Add all storage writes, filtering net-zero changes - for (address, slot), ( + for ( + address, + slot, block_access_index, - value, - ) in state_changes.storage_writes.items(): + ), value in state_changes.storage_writes.items(): # Check if this is a net-zero change by comparing with pre-state if (address, slot) in state_changes.pre_storage: if state_changes.pre_storage[(address, slot)] == value: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 42203d5e8d..0d875be3c0 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -46,9 +46,9 @@ class StateChanges: touched_addresses: Set[Address] = field(default_factory=set) storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) - storage_writes: Dict[ - Tuple[Address, Bytes32], Tuple[BlockAccessIndex, U256] - ] = field(default_factory=dict) + storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256] = ( + field(default_factory=dict) + ) balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256] = field( default_factory=dict @@ -110,9 +110,8 @@ def track_storage_write( self, address: Address, key: Bytes32, value: U256 ) -> None: """Track a storage write operation with block access index.""" - self.storage_writes[(address, key)] = ( - self.get_block_access_index(), - value, + self.storage_writes[(address, key, self.get_block_access_index())] = ( + value ) def track_balance_change( @@ -173,17 +172,17 @@ def merge_on_success(self) -> None: # Merge storage operations, filtering noop writes self.parent.storage_reads.update(self.storage_reads) - for (addr, key), (idx, value) in self.storage_writes.items(): + for (addr, key, idx), value in self.storage_writes.items(): # Only merge if value actually changed from pre-state if (addr, key) in self.pre_storage: if self.pre_storage[(addr, key)] != value: - self.parent.storage_writes[(addr, key)] = (idx, value) + self.parent.storage_writes[(addr, key, idx)] = value # If equal, it's a noop write - convert to read only else: self.parent.storage_reads.add((addr, key)) else: # No pre-state captured, merge as-is - self.parent.storage_writes[(addr, key)] = (idx, value) + self.parent.storage_writes[(addr, key, idx)] = value # Merge balance changes - filter net-zero changes # balance_changes keyed by (address, index) @@ -240,7 +239,7 @@ def merge_on_failure(self) -> None: self.parent.storage_reads.update(self.storage_reads) # Convert writes to reads (failed writes still accessed the slots) - for address, key in self.storage_writes.keys(): + for address, key, _idx in self.storage_writes.keys(): self.parent.storage_reads.add((address, key)) # Note: balance_changes, nonce_changes, and code_changes are NOT @@ -284,11 +283,9 @@ def handle_in_transaction_selfdestruct( del state_changes.code_changes[(address, current_block_access_index)] # Convert storage writes from current transaction to reads - for (addr, key), (idx, _value) in list( - state_changes.storage_writes.items() - ): + for addr, key, idx in list(state_changes.storage_writes.keys()): if addr == address and idx == current_block_access_index: - del state_changes.storage_writes[(addr, key)] + del state_changes.storage_writes[(addr, key, idx)] state_changes.storage_reads.add((addr, key)) 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 6d03e9bb9a..baead4938e 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 @@ -2014,3 +2014,77 @@ def test_bal_multiple_balance_changes_same_account( 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(tx_index=1, post_nonce=1), + BalNonceChange(tx_index=2, post_nonce=2), + BalNonceChange(tx_index=3, post_nonce=3), + ], + ), + contract: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=1, + slot_changes=[ + BalStorageChange( + tx_index=1, post_value=1 + ), + BalStorageChange( + tx_index=2, post_value=2 + ), + BalStorageChange( + tx_index=3, post_value=3 + ), + ], + ), + ], + storage_reads=[], + balance_changes=[], + code_changes=[], + ), + } + ), + ) + ], + post={ + alice: Account(nonce=3), + contract: Account(storage={1: 3}), + }, + ) 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 1f329b9a3b..bc45ef2d21 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -51,6 +51,7 @@ | `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_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_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 | From 9018c4fc3de2d45759b47ae6a102f93893d28f59 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 13 Nov 2025 15:09:39 -0600 Subject: [PATCH 19/71] fix(spec-specs): Use functions, not methods; fix create revert --- src/ethereum/forks/amsterdam/fork.py | 46 +- src/ethereum/forks/amsterdam/state.py | 36 +- src/ethereum/forks/amsterdam/state_tracker.py | 593 ++++++++++++------ src/ethereum/forks/amsterdam/vm/__init__.py | 9 +- .../forks/amsterdam/vm/eoa_delegation.py | 11 +- .../amsterdam/vm/instructions/environment.py | 9 +- .../amsterdam/vm/instructions/storage.py | 21 +- .../forks/amsterdam/vm/instructions/system.py | 36 +- .../forks/amsterdam/vm/interpreter.py | 90 +-- .../evm_tools/t8n/__init__.py | 6 +- .../test_block_access_lists_opcodes.py | 59 ++ .../test_cases.md | 1 + 12 files changed, 597 insertions(+), 320 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 25968e4598..dcc46d1c4f 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -65,9 +65,15 @@ state_root, ) from .state_tracker import ( + capture_pre_balance, create_child_frame, + get_block_access_index, handle_in_transaction_selfdestruct, + increment_block_access_index, + merge_on_success, normalize_balance_changes_for_transaction, + track_address, + track_balance_change, ) from .transactions import ( AccessListTransaction, @@ -800,7 +806,7 @@ def apply_body( # 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 - block_env.block_state_changes.increment_index() + increment_block_access_index(block_env.block_state_changes) process_withdrawals(block_env, block_output, withdrawals) @@ -893,16 +899,15 @@ def process_transaction( """ # EIP-7928: Create a transaction-level StateChanges frame # The frame will read the current block_access_index from the block frame - # Before transaction starts, increment block index so it's ready - block_env.block_state_changes.increment_index() + increment_block_access_index(block_env.block_state_changes) tx_state_changes = create_child_frame(block_env.block_state_changes) coinbase_pre_balance = get_account( block_env.state, block_env.coinbase ).balance - tx_state_changes.track_address(block_env.coinbase) - tx_state_changes.capture_pre_balance( - block_env.coinbase, coinbase_pre_balance + track_address(tx_state_changes, block_env.coinbase) + capture_pre_balance( + tx_state_changes, block_env.coinbase, coinbase_pre_balance ) trie_set( @@ -1012,15 +1017,16 @@ def process_transaction( block_env.state, sender ).balance + U256(gas_refund_amount) set_account_balance( - block_env.state, sender, sender_balance_after_refund, tx_state_changes + block_env.state, + sender, + sender_balance_after_refund, + tx_state_changes, ) - # transfer miner fees coinbase_balance_after_mining_fee = get_account( block_env.state, block_env.coinbase ).balance + U256(transaction_fee) - # Always set coinbase balance to ensure proper tracking set_account_balance( block_env.state, block_env.coinbase, @@ -1055,12 +1061,13 @@ def process_transaction( # into block frame. normalize_balance_changes_for_transaction( tx_state_changes, - BlockAccessIndex(tx_state_changes.get_block_access_index()), + BlockAccessIndex( + get_block_access_index(block_env.block_state_changes) + ), block_env.state, ) - # Merge transaction frame into block frame - tx_state_changes.merge_on_success() + merge_on_success(tx_state_changes) # EIP-7928: Handle in-transaction self-destruct AFTER merge # Convert storage writes to reads and remove nonce/code changes @@ -1070,7 +1077,7 @@ def process_transaction( block_env.block_state_changes, address, BlockAccessIndex( - block_env.block_state_changes.get_block_access_index() + get_block_access_index(block_env.block_state_changes) ), ) destroy_account(block_env.state, address) @@ -1087,8 +1094,10 @@ def process_withdrawals( withdrawal_addresses = {wd.address for wd in withdrawals} for address in withdrawal_addresses: pre_balance = get_account(block_env.state, address).balance - block_env.block_state_changes.track_address(address) - block_env.block_state_changes.capture_pre_balance(address, pre_balance) + track_address(block_env.block_state_changes, address) + capture_pre_balance( + block_env.block_state_changes, address, pre_balance + ) def increase_recipient_balance(recipient: Account) -> None: recipient.balance += wd.amount * U256(10**9) @@ -1102,10 +1111,9 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(block_env.state, wd.address, increase_recipient_balance) - # Track balance change for BAL (withdrawals use post-execution index) new_balance = get_account(block_env.state, wd.address).balance - block_env.block_state_changes.track_balance_change( - wd.address, new_balance + track_balance_change( + block_env.block_state_changes, wd.address, new_balance ) if account_exists_and_is_empty(block_env.state, wd.address): @@ -1117,7 +1125,7 @@ def increase_recipient_balance(recipient: Account) -> None: normalize_balance_changes_for_transaction( block_env.block_state_changes, BlockAccessIndex( - block_env.block_state_changes.get_block_access_index() + get_block_access_index(block_env.block_state_changes) ), block_env.state, ) diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 8f58b6e815..af384ec4df 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -24,7 +24,14 @@ from ethereum_types.numeric import U64, U256, Uint from .fork_types import EMPTY_ACCOUNT, Account, Address, Root -from .state_tracker import StateChanges +from .state_tracker import ( + StateChanges, + capture_pre_balance, + track_address, + track_balance_change, + track_code_change, + track_nonce_change, +) from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set if TYPE_CHECKING: @@ -518,10 +525,10 @@ def move_ether( sender_balance = get_account(state, sender_address).balance recipient_balance = get_account(state, recipient_address).balance - state_changes.track_address(sender_address) - state_changes.capture_pre_balance(sender_address, sender_balance) - state_changes.track_address(recipient_address) - state_changes.capture_pre_balance(recipient_address, recipient_balance) + track_address(state_changes, sender_address) + capture_pre_balance(state_changes, sender_address, sender_balance) + track_address(state_changes, recipient_address) + capture_pre_balance(state_changes, recipient_address, recipient_balance) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -537,11 +544,11 @@ def increase_recipient_balance(recipient: Account) -> None: sender_new_balance = get_account(state, sender_address).balance recipient_new_balance = get_account(state, recipient_address).balance - state_changes.track_balance_change( - sender_address, U256(sender_new_balance) + track_balance_change( + state_changes, sender_address, U256(sender_new_balance) ) - state_changes.track_balance_change( - recipient_address, U256(recipient_new_balance) + track_balance_change( + state_changes, recipient_address, U256(recipient_new_balance) ) @@ -571,14 +578,14 @@ def set_account_balance( """ current_balance = get_account(state, address).balance - state_changes.track_address(address) - state_changes.capture_pre_balance(address, current_balance) + track_address(state_changes, address) + capture_pre_balance(state_changes, address, current_balance) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - state_changes.track_balance_change(address, amount) + track_balance_change(state_changes, address, amount) def increment_nonce( @@ -607,9 +614,8 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - # Track nonce change for Block Access List (EIP-7928) account = get_account(state, address) - state_changes.track_nonce_change(address, U64(account.nonce)) + track_nonce_change(state_changes, address, U64(account.nonce)) def set_code( @@ -641,7 +647,7 @@ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) - state_changes.track_code_change(address, code) + track_code_change(state_changes, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 0d875be3c0..2e9385d272 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -12,7 +12,7 @@ Call Frame: Child of transaction/call, lifetime = single message Block Access Index: 0=pre-exec, 1..N=transactions, N+1=post-exec -Stored in root frame, accessed by walking parent chain. +Stored in root frame, passed explicitly to operations. Pre-State Tracking: Values captured before modifications to enable net-zero filtering. @@ -38,7 +38,8 @@ class StateChanges: """ Tracks state changes within a single execution frame. - Frames form a hierarchy and merge changes upward on completion. + Frames form a hierarchy: Block → Transaction → Call frames. + Each frame holds a reference to its parent for upward traversal. """ parent: Optional["StateChanges"] = None @@ -68,182 +69,409 @@ class StateChanges: ) pre_code: Dict[Address, Bytes] = field(default_factory=dict) - def get_block_access_index(self) -> BlockAccessIndex: - """Get current block access index by walking to root.""" - current = self - while current.parent is not None: - current = current.parent - return current._block_access_index - - def capture_pre_balance(self, address: Address, balance: U256) -> None: - """Capture pre-balance (first-write-wins for net-zero filtering).""" - if address not in self.pre_balances: - self.pre_balances[address] = balance - - def capture_pre_nonce(self, address: Address, nonce: U64) -> None: - """Capture pre-nonce (first-write-wins).""" - if address not in self.pre_nonces: - self.pre_nonces[address] = nonce - - def capture_pre_storage( - self, address: Address, key: Bytes32, value: U256 - ) -> None: - """Capture pre-storage (first-write-wins for noop filtering).""" - slot = (address, key) - if slot not in self.pre_storage: - self.pre_storage[slot] = value - - def capture_pre_code(self, address: Address, code: Bytes) -> None: - """Capture pre-code (first-write-wins).""" - if address not in self.pre_code: - self.pre_code[address] = code - - def track_address(self, address: Address) -> None: - """Track that an address was accessed.""" - self.touched_addresses.add(address) - - def track_storage_read(self, address: Address, key: Bytes32) -> None: - """Track a storage read operation.""" - self.storage_reads.add((address, key)) - - def track_storage_write( - self, address: Address, key: Bytes32, value: U256 - ) -> None: - """Track a storage write operation with block access index.""" - self.storage_writes[(address, key, self.get_block_access_index())] = ( - value - ) - - def track_balance_change( - self, address: Address, new_balance: U256 - ) -> None: - """Track balance change keyed by (address, index).""" - self.balance_changes[(address, self.get_block_access_index())] = ( - new_balance - ) - - def track_nonce_change(self, address: Address, new_nonce: U64) -> None: - """Track a nonce change.""" - self.nonce_changes.add( - (address, self.get_block_access_index(), new_nonce) - ) - - def track_code_change(self, address: Address, new_code: Bytes) -> None: - """Track a code change.""" - self.code_changes[(address, self.get_block_access_index())] = new_code - - def increment_index(self) -> None: - """Increment block access index by walking to root.""" - root = self - while root.parent is not None: - root = root.parent - root._block_access_index = BlockAccessIndex( - root._block_access_index + Uint(1) - ) - - def merge_on_success(self) -> None: - """ - Merge this frame's changes into parent on successful completion. - - Merges all tracked changes (reads and writes) from this frame - into the parent frame. Filters out net-zero changes based on - captured pre-state values by comparing initial vs final values. - """ - if self.parent is None: - return - - # Merge address accesses - self.parent.touched_addresses.update(self.touched_addresses) - - # Merge pre-state captures for transaction-level normalization - # Only if parent doesn't have value (first capture wins) - for addr, balance in self.pre_balances.items(): - if addr not in self.parent.pre_balances: - self.parent.pre_balances[addr] = balance - for addr, nonce in self.pre_nonces.items(): - if addr not in self.parent.pre_nonces: - self.parent.pre_nonces[addr] = nonce - for slot, value in self.pre_storage.items(): - if slot not in self.parent.pre_storage: - self.parent.pre_storage[slot] = value - for addr, code in self.pre_code.items(): - if addr not in self.parent.pre_code: - self.parent.pre_code[addr] = code - - # Merge storage operations, filtering noop writes - self.parent.storage_reads.update(self.storage_reads) - for (addr, key, idx), value in self.storage_writes.items(): - # Only merge if value actually changed from pre-state - if (addr, key) in self.pre_storage: - if self.pre_storage[(addr, key)] != value: - self.parent.storage_writes[(addr, key, idx)] = value - # If equal, it's a noop write - convert to read only - else: - self.parent.storage_reads.add((addr, key)) - else: - # No pre-state captured, merge as-is - self.parent.storage_writes[(addr, key, idx)] = value - - # Merge balance changes - filter net-zero changes - # balance_changes keyed by (address, index) - for (addr, idx), final_balance in self.balance_changes.items(): - if addr in self.pre_balances: - if self.pre_balances[addr] != final_balance: - # Net change occurred - merge the final balance - self.parent.balance_changes[(addr, idx)] = final_balance - # else: Net-zero change - skip entirely - else: - # No pre-balance captured, merge as-is - self.parent.balance_changes[(addr, idx)] = final_balance - - # Merge nonce changes - keep only highest nonce per address - # Nonces are monotonically increasing, so just keep the max - address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} - for addr, idx, nonce in self.nonce_changes: - # Keep the highest nonce value for each address - if ( - addr not in address_final_nonces - or nonce > address_final_nonces[addr][1] - ): - address_final_nonces[addr] = (idx, nonce) - - # Merge final nonces (no net-zero filtering - nonces never decrease) - for addr, (idx, final_nonce) in address_final_nonces.items(): - self.parent.nonce_changes.add((addr, idx, final_nonce)) - - # Merge code changes - filter net-zero changes - # code_changes keyed by (address, index) - for (addr, idx), final_code in self.code_changes.items(): - if addr in self.pre_code: - if self.pre_code[addr] != final_code: - # Net change occurred - merge the final code - self.parent.code_changes[(addr, idx)] = final_code - # else: Net-zero change - skip entirely + +def get_block_frame(state_changes: StateChanges) -> StateChanges: + """ + Walk to block-level frame. + + Parameters + ---------- + state_changes : + Any state changes frame. + + Returns + ------- + block_frame : StateChanges + The block-level frame. + + """ + block_frame = state_changes + while block_frame.parent is not None: + block_frame = block_frame.parent + return block_frame + + +def get_block_access_index(root_frame: StateChanges) -> BlockAccessIndex: + """ + Get current block access index from root frame. + + Parameters + ---------- + root_frame : + The root (block-level) state changes frame. + + Returns + ------- + index : BlockAccessIndex + The current block access index. + + """ + return root_frame._block_access_index + + +def increment_block_access_index(root_frame: StateChanges) -> None: + """ + Increment block access index in root frame. + + Parameters + ---------- + root_frame : + The root (block-level) state changes frame to increment. + + """ + root_frame._block_access_index = BlockAccessIndex( + root_frame._block_access_index + Uint(1) + ) + + +def capture_pre_balance( + state_changes: StateChanges, address: Address, balance: U256 +) -> None: + """ + Capture pre-balance (first-write-wins for net-zero filtering). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose balance is being captured. + balance : + The balance value before modification. + + """ + if address not in state_changes.pre_balances: + state_changes.pre_balances[address] = balance + + +def capture_pre_nonce( + state_changes: StateChanges, address: Address, nonce: U64 +) -> None: + """ + Capture pre-nonce (first-write-wins). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose nonce is being captured. + nonce : + The nonce value before modification. + + """ + if address not in state_changes.pre_nonces: + state_changes.pre_nonces[address] = nonce + + +def capture_pre_storage( + state_changes: StateChanges, address: Address, key: Bytes32, value: U256 +) -> None: + """ + Capture pre-storage (first-write-wins for noop filtering). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose storage is being captured. + key : + The storage key. + value : + The storage value before modification. + + """ + slot = (address, key) + if slot not in state_changes.pre_storage: + state_changes.pre_storage[slot] = value + + +def capture_pre_code( + state_changes: StateChanges, address: Address, code: Bytes +) -> None: + """ + Capture pre-code (first-write-wins). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose code is being captured. + code : + The code value before modification. + + """ + if address not in state_changes.pre_code: + state_changes.pre_code[address] = code + + +def track_address(state_changes: StateChanges, address: Address) -> None: + """ + Track 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: + """ + Track 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: + """ + Track a storage write operation with 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. + + """ + block_frame = get_block_frame(state_changes) + state_changes.storage_writes[ + (address, key, get_block_access_index(block_frame)) + ] = value + + +def track_balance_change( + state_changes: StateChanges, + address: Address, + new_balance: U256, +) -> None: + """ + Track balance change keyed by (address, index). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose balance changed. + new_balance : + The new balance value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.balance_changes[ + (address, get_block_access_index(block_frame)) + ] = new_balance + + +def track_nonce_change( + state_changes: StateChanges, + address: Address, + new_nonce: U64, +) -> None: + """ + Track a nonce change. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose nonce changed. + new_nonce : + The new nonce value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.nonce_changes.add( + (address, get_block_access_index(block_frame), new_nonce) + ) + + +def track_code_change( + state_changes: StateChanges, + address: Address, + new_code: Bytes, +) -> None: + """ + Track a code change. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose code changed. + new_code : + The new code value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.code_changes[ + (address, get_block_access_index(block_frame)) + ] = new_code + + +def merge_on_success(child_frame: StateChanges) -> None: + """ + Merge child frame's changes into parent on successful completion. + + Merges all tracked changes (reads and writes) from the child frame + into the parent frame. Filters out net-zero changes based on + captured pre-state values by comparing initial vs final values. + + 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 pre-state captures for transaction-level normalization + # Only if parent doesn't have value (first capture wins) + for addr, balance in child_frame.pre_balances.items(): + if addr not in parent_frame.pre_balances: + parent_frame.pre_balances[addr] = balance + for addr, nonce in child_frame.pre_nonces.items(): + if addr not in parent_frame.pre_nonces: + parent_frame.pre_nonces[addr] = nonce + for slot, value in child_frame.pre_storage.items(): + if slot not in parent_frame.pre_storage: + parent_frame.pre_storage[slot] = value + for addr, code in child_frame.pre_code.items(): + if addr not in parent_frame.pre_code: + parent_frame.pre_code[addr] = code + + # Merge storage operations, filtering noop writes + parent_frame.storage_reads.update(child_frame.storage_reads) + for (addr, key, idx), value in child_frame.storage_writes.items(): + # Only merge if value actually changed from pre-state + if (addr, key) in child_frame.pre_storage: + if child_frame.pre_storage[(addr, key)] != value: + parent_frame.storage_writes[(addr, key, idx)] = value + # If equal, it's a noop write - convert to read only else: - # No pre-code captured, merge as-is - self.parent.code_changes[(addr, idx)] = final_code + parent_frame.storage_reads.add((addr, key)) + else: + # No pre-state captured, merge as-is + parent_frame.storage_writes[(addr, key, idx)] = value + + # Merge balance changes - filter net-zero changes + # balance_changes keyed by (address, index) + for (addr, idx), final_balance in child_frame.balance_changes.items(): + if addr in child_frame.pre_balances: + if child_frame.pre_balances[addr] != final_balance: + # Net change occurred - merge the final balance + parent_frame.balance_changes[(addr, idx)] = final_balance + # else: Net-zero change - skip entirely + else: + # No pre-balance captured, merge as-is + parent_frame.balance_changes[(addr, idx)] = final_balance + + # Merge nonce changes - keep only highest nonce per address + # Nonces are monotonically increasing, so just keep the max + address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} + for addr, idx, nonce in child_frame.nonce_changes: + # Keep the highest nonce value for each address + if ( + addr not in address_final_nonces + or nonce > address_final_nonces[addr][1] + ): + address_final_nonces[addr] = (idx, nonce) + + # Merge final nonces (no net-zero filtering - nonces never decrease) + for addr, (idx, final_nonce) in address_final_nonces.items(): + parent_frame.nonce_changes.add((addr, idx, final_nonce)) + + # Merge code changes - filter net-zero changes + # code_changes keyed by (address, index) + for (addr, idx), final_code in child_frame.code_changes.items(): + if addr in child_frame.pre_code: + if child_frame.pre_code[addr] != final_code: + # Net change occurred - merge the final code + parent_frame.code_changes[(addr, idx)] = final_code + # else: Net-zero change - skip entirely + else: + # No pre-code captured, merge as-is + parent_frame.code_changes[(addr, idx)] = final_code + + +def merge_on_failure(child_frame: StateChanges) -> None: + """ + Merge child frame's changes into parent on failed completion. + + Merges only read operations from the child frame into the parent. + Write operations are discarded since the frame reverted. + This is called when a call frame fails/reverts. + + 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) - def merge_on_failure(self) -> None: - """ - Merge this frame's changes into parent on failed completion. + # 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)) - Merges only read operations from this frame into the parent. - Write operations are discarded since the frame reverted. - This is called when a call frame fails/reverts. - """ - if self.parent is None: - return + # Note: balance_changes, nonce_changes, and code_changes are NOT + # merged on failure - they are discarded - # Only merge reads and address accesses on failure - self.parent.touched_addresses.update(self.touched_addresses) - self.parent.storage_reads.update(self.storage_reads) - # Convert writes to reads (failed writes still accessed the slots) - for address, key, _idx in self.storage_writes.keys(): - self.parent.storage_reads.add((address, key)) +def create_child_frame(parent: StateChanges) -> StateChanges: + """ + Create a child frame for nested execution. + + Parameters + ---------- + parent : + The parent frame. + + Returns + ------- + child : StateChanges + A new child frame with parent reference set. - # Note: balance_changes, nonce_changes, and code_changes are NOT - # merged on failure - they are discarded + """ + return StateChanges(parent=parent) def handle_in_transaction_selfdestruct( @@ -336,24 +564,3 @@ def normalize_balance_changes_for_transaction( del block_frame.balance_changes[ (addr, current_block_access_index) ] - - -def create_child_frame(parent: StateChanges) -> StateChanges: - """ - Create a child frame for nested execution. - - The child frame will dynamically read the block_access_index from - the root (block) frame, ensuring all frames see the same current index. - - Parameters - ---------- - parent : StateChanges - The parent frame. - - Returns - ------- - child : StateChanges - A new child frame with parent link. - - """ - return StateChanges(parent=parent) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 04b74eee9e..26b7e99e45 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -25,7 +25,7 @@ from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage -from ..state_tracker import StateChanges +from ..state_tracker import StateChanges, merge_on_failure, merge_on_success from ..transactions import LegacyTransaction from ..trie import Trie @@ -187,8 +187,7 @@ 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 state changes from successful child frame (EIP-7928) - child_evm.state_changes.merge_on_success() + merge_on_success(child_evm.state_changes) def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: @@ -205,6 +204,4 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left - # Merge state changes from failed child frame (EIP-7928) - # Only reads are merged, writes are discarded - child_evm.state_changes.merge_on_failure() + 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 bce49462f2..cd1f24a70c 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -15,6 +15,7 @@ # track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization from ..state import account_exists, get_account, increment_nonce, set_code +from ..state_tracker import capture_pre_code, track_address from ..utils.hexadecimal import hex_to_address from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS from . import Evm, Message @@ -175,12 +176,12 @@ def apply_delegation_tracking( The address delegated to. """ - evm.state_changes.track_address(original_address) + track_address(evm.state_changes, original_address) if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) - evm.state_changes.track_address(delegated_address) + track_address(evm.state_changes, delegated_address) def access_delegation( @@ -239,7 +240,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - message.block_env.block_state_changes.track_address(authority) + track_address(message.block_env.block_state_changes, authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -256,14 +257,12 @@ def set_delegation(message: Message) -> U256: else: code_to_set = EOA_DELEGATION_MARKER + auth.address - # Use transaction frame, not block frame (EIP-7928) state_changes = ( message.transaction_state_changes or message.block_env.block_state_changes ) - # Capture pre-code just before setting to enable no-op filtering - state_changes.capture_pre_code(authority, authority_code) + capture_pre_code(state_changes, authority, authority_code) set_code(state, authority, code_to_set, state_changes) increment_nonce(state, authority, state_changes) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index dae8c20280..3d23b8f136 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -20,6 +20,7 @@ # 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 @@ -83,7 +84,7 @@ def balance(evm: Evm) -> None: check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, gas_cost) # OPERATION @@ -353,7 +354,7 @@ def extcodesize(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, access_gas_cost) # OPERATION @@ -399,7 +400,7 @@ def extcodecopy(evm: Evm) -> None: check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, total_gas_cost) # OPERATION @@ -493,7 +494,7 @@ def extcodehash(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, access_gas_cost) # OPERATION diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index db1536a707..8edff23534 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -20,6 +20,11 @@ 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 ..gas import ( @@ -58,7 +63,8 @@ def sload(evm: Evm) -> None: check_gas(evm, gas_cost) if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) - evm.state_changes.track_storage_read( + track_storage_read( + evm.state_changes, evm.message.current_target, key, ) @@ -120,10 +126,11 @@ def sstore(evm: Evm) -> None: # Track storage access BEFORE checking gas (EIP-7928) # Even if we run out of gas, the access attempt should be tracked - evm.state_changes.capture_pre_storage( - evm.message.current_target, key, current_value + capture_pre_storage( + evm.state_changes, evm.message.current_target, key, current_value ) - evm.state_changes.track_storage_read( + track_storage_read( + evm.state_changes, evm.message.current_target, key, ) @@ -157,10 +164,8 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) - evm.state_changes.track_storage_write( - evm.message.current_target, - key, - new_value, + track_storage_write( + evm.state_changes, evm.message.current_target, key, new_value ) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 136f194655..389adf6f54 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -27,6 +27,7 @@ move_ether, set_account_balance, ) +from ...state_tracker import capture_pre_balance, track_address from ...utils.address import ( compute_contract_address, compute_create2_contract_address, @@ -114,24 +115,16 @@ def generic_create( evm.accessed_addresses.add(contract_address) - evm.state_changes.track_address(contract_address) + track_address(evm.state_changes, contract_address) if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): - increment_nonce( - state, - evm.message.current_target, - evm.state_changes, - ) + increment_nonce(state, evm.message.current_target, evm.state_changes) push(evm.stack, U256(0)) return - increment_nonce( - state, - evm.message.current_target, - evm.state_changes, - ) + increment_nonce(state, evm.message.current_target, evm.state_changes) child_message = Message( block_env=evm.message.block_env, @@ -430,7 +423,7 @@ def call(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(to) + track_address(evm.state_changes, to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -527,7 +520,7 @@ def callcode(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(original_address) + track_address(evm.state_changes, original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -543,8 +536,8 @@ def callcode(evm: Evm) -> None: # in parent frame. CALLCODE transfers value from/to current_target # (same address), affecting current storage context, not child frame if value != 0 and sender_balance >= value: - evm.state_changes.capture_pre_balance( - evm.message.current_target, sender_balance + capture_pre_balance( + evm.state_changes, evm.message.current_target, sender_balance ) if sender_balance < value: @@ -609,7 +602,7 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(beneficiary) - evm.state_changes.track_address(beneficiary) + track_address(evm.state_changes, beneficiary) charge_gas(evm, gas_cost) @@ -629,13 +622,8 @@ def selfdestruct(evm: Evm) -> None: # register account for deletion only if it was created # in the same transaction if originator in evm.message.block_env.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), - evm.state_changes, + evm.message.block_env.state, originator, U256(0), evm.state_changes ) evm.accounts_to_delete.add(originator) @@ -698,7 +686,7 @@ def delegatecall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(original_address) + track_address(evm.state_changes, original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -783,7 +771,7 @@ def staticcall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(to) + track_address(evm.state_changes, to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 9d6190b0e8..a63b745624 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -44,7 +44,13 @@ rollback_transaction, set_code, ) -from ..state_tracker import StateChanges, create_child_frame +from ..state_tracker import ( + StateChanges, + create_child_frame, + merge_on_failure, + merge_on_success, + track_address, +) from ..vm import Message from ..vm.eoa_delegation import get_delegated_code_address, set_delegation from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas @@ -67,35 +73,39 @@ MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE -def create_call_frame(parent_frame: StateChanges) -> StateChanges: +def get_parent_frame(message: Message) -> StateChanges: """ - Create a child frame for call-level state tracking. + Get the appropriate parent frame for a message's state changes. - Used for contract calls (CALL, DELEGATECALL, STATICCALL, etc.) where - state changes need to be isolated and potentially reverted. + Frame selection logic: + - Nested calls: Parent EVM's frame + - Top-level calls: Transaction frame + - System transactions: Block frame Parameters ---------- - parent_frame : - The parent frame (transaction or another call frame). + message : + The message being processed. Returns ------- - call_frame : StateChanges - A new child frame linked to the parent. + parent_frame : StateChanges + The parent frame to use for creating child frames. """ - return create_child_frame(parent_frame) + if message.parent_evm is not None: + return message.parent_evm.state_changes + elif message.transaction_state_changes is not None: + return message.transaction_state_changes + else: + return message.block_env.block_state_changes def get_message_state_frame(message: Message) -> StateChanges: """ Determine and create the appropriate state tracking frame for a message. - Frame selection logic: - - Nested calls: Create child of parent EVM's frame - - Top-level calls: Create child of transaction frame - - System transactions: Use block frame directly (no isolation needed) + Creates a call frame as a child of the appropriate parent frame. Parameters ---------- @@ -108,16 +118,14 @@ def get_message_state_frame(message: Message) -> StateChanges: The state tracking frame to use for this message execution. """ - if message.parent_evm is not None: - # Nested call - create child of parent EVM's frame - return create_call_frame(message.parent_evm.state_changes) - elif message.transaction_state_changes is not None: - # Top-level transaction call - create child of transaction frame - # This ensures contract execution is isolated and can be reverted - return create_call_frame(message.transaction_state_changes) + parent_frame = get_parent_frame(message) + if ( + message.parent_evm is not None + or message.transaction_state_changes is not None + ): + return create_child_frame(parent_frame) else: - # System transaction - use block frame directly - return message.block_env.block_state_changes + return parent_frame @dataclass @@ -188,8 +196,8 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code_address = delegated_address # EIP-7928: Track delegation target when loaded as call target - message.block_env.block_state_changes.track_address( - delegated_address + track_address( + message.block_env.block_state_changes, delegated_address ) evm = process_message(message) @@ -252,9 +260,8 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - # Create a temporary child frame for tracking changes that may be rolled - # back on OOG during code deposit. This frame is merged only on success. - create_frame = create_child_frame(message.block_env.block_state_changes) + parent_frame = get_parent_frame(message) + create_frame = create_child_frame(parent_frame) increment_nonce(state, message.current_target, create_frame) evm = process_message(message) @@ -270,24 +277,19 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) - # Merge create_frame on failure - keeps reads, discards writes - # (address access is preserved, nonce change is discarded) - create_frame.merge_on_failure() + merge_on_failure(create_frame) evm.gas_left = Uint(0) evm.output = b"" evm.error = error else: set_code( - state, - message.current_target, - contract_code, - create_frame, + state, message.current_target, contract_code, create_frame ) commit_transaction(state, transient_storage) - # Merge create_frame on success - includes nonce and code changes - create_frame.merge_on_success() + merge_on_success(create_frame) else: rollback_transaction(state, transient_storage) + merge_on_failure(create_frame) return evm @@ -313,8 +315,10 @@ def process_message(message: Message) -> Evm: begin_transaction(state, transient_storage) + parent_frame = get_parent_frame(message) state_changes = get_message_state_frame(message) - state_changes.track_address(message.current_target) + + track_address(state_changes, message.current_target) if message.should_transfer_value and message.value != 0: move_ether( @@ -327,15 +331,13 @@ def process_message(message: Message) -> Evm: evm = execute_code(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) - # Merge call frame state changes into parent - evm.state_changes.merge_on_failure() + if state_changes != parent_frame: + merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) - # Merge call frame state changes into parent - evm.state_changes.merge_on_success() + if state_changes != parent_frame: + merge_on_success(evm.state_changes) return evm diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 2100983144..2d3f8b9e3c 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -413,7 +413,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: # Post-execution operations use index N+1 if self.fork.is_after_fork("amsterdam"): - block_env.block_state_changes.increment_index() + from ethereum.forks.amsterdam.state_tracker import ( + increment_block_access_index, + ) + + increment_block_access_index(block_env.block_state_changes) if not self.fork.proof_of_stake: if self.options.state_reward is None: 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 de354bbeb1..b849137c58 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 @@ -910,3 +910,62 @@ def test_bal_sstore_static_context( contract_b: Account(storage={0: 0}), # SSTORE failed }, ) + + +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(tx_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, + }, + ) 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 bc45ef2d21..4ca8a63ecd 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -52,6 +52,7 @@ | `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_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_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 | From 521db608a9ac43a66192b02395012e05cb0c538c Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 10:08:31 -0300 Subject: [PATCH 20/71] fix(spec-specs): Default code to b"" in tracker, skip empty setting --- src/ethereum/forks/amsterdam/state_tracker.py | 11 ++--- .../test_block_access_lists.py | 45 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 2e9385d272..e157588dac 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -393,7 +393,6 @@ def merge_on_success(child_frame: StateChanges) -> None: for (addr, idx), final_balance in child_frame.balance_changes.items(): if addr in child_frame.pre_balances: if child_frame.pre_balances[addr] != final_balance: - # Net change occurred - merge the final balance parent_frame.balance_changes[(addr, idx)] = final_balance # else: Net-zero change - skip entirely else: @@ -418,14 +417,10 @@ def merge_on_success(child_frame: StateChanges) -> None: # Merge code changes - filter net-zero changes # code_changes keyed by (address, index) for (addr, idx), final_code in child_frame.code_changes.items(): - if addr in child_frame.pre_code: - if child_frame.pre_code[addr] != final_code: - # Net change occurred - merge the final code - parent_frame.code_changes[(addr, idx)] = final_code - # else: Net-zero change - skip entirely - else: - # No pre-code captured, merge as-is + pre_code = child_frame.pre_code.get(addr, b"") + if pre_code != final_code: parent_frame.code_changes[(addr, idx)] = final_code + # else: Net-zero change - skip entirely def merge_on_failure(child_frame: StateChanges) -> None: 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 baead4938e..7a342b98d5 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 @@ -2088,3 +2088,48 @@ def test_bal_multiple_storage_writes_same_slot( contract: Account(storage={1: 3}), }, ) + + +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(tx_index=1, post_nonce=1)], + ), + contract_address: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_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""), + }, + ) 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 4ca8a63ecd..44211095a0 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -85,3 +85,4 @@ | `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. | ✅ Completed | From 45636737ee4076d00fbd2401bfa8e37becae84c7 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 12:06:16 -0300 Subject: [PATCH 21/71] fix(spec-specs): Fix BAL cross-transaction tracking and nonce dedup - add commit_transaction_frame() - no net-zero filtering for cross-tx changes - keep max nonce per transaction when building BAL, remove block-level code filtering - filter net-zero code changes at tracking time (for 7702 txs) - use commit_transaction_frame() instead of merge_on_success() for tx->block commits --- .../amsterdam/block_access_lists/builder.py | 13 ++-- src/ethereum/forks/amsterdam/fork.py | 4 +- src/ethereum/forks/amsterdam/state.py | 7 +- src/ethereum/forks/amsterdam/state_tracker.py | 44 ++++++++++- .../test_block_access_lists.py | 73 +++++++++++++++++++ .../test_cases.md | 2 + 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 4ed7aa767c..9af8d2a24c 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -278,14 +278,15 @@ def add_nonce_change( 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 nonce per transaction + # 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: - # Update the existing nonce change with the new nonce - existing_changes[i] = NonceChange( - block_access_index=block_access_index, new_nonce=new_nonce - ) + # 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 @@ -514,6 +515,8 @@ def build_block_access_list( add_nonce_change(builder, address, block_access_index, new_nonce) # Add all code changes + # Net-zero filtering for code changes should happen at the + # transaction level (in merge_on_success), not at the block level. for ( address, block_access_index, diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index dcc46d1c4f..fa4af90fd8 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -66,11 +66,11 @@ ) from .state_tracker import ( capture_pre_balance, + commit_transaction_frame, create_child_frame, get_block_access_index, handle_in_transaction_selfdestruct, increment_block_access_index, - merge_on_success, normalize_balance_changes_for_transaction, track_address, track_balance_change, @@ -1067,7 +1067,7 @@ def process_transaction( block_env.state, ) - merge_on_success(tx_state_changes) + commit_transaction_frame(tx_state_changes) # EIP-7928: Handle in-transaction self-destruct AFTER merge # Convert storage writes to reads and remove nonce/code changes diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index af384ec4df..d16c9d9d69 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -647,7 +647,12 @@ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) - track_code_change(state_changes, address, code) + + # Only track code change if it's not net-zero within this frame + # Compare against pre-code captured in this frame, default to b"" + pre_code = state_changes.pre_code.get(address, b"") + if pre_code != code: + track_code_change(state_changes, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index e157588dac..58ca854bf8 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -400,10 +400,8 @@ def merge_on_success(child_frame: StateChanges) -> None: parent_frame.balance_changes[(addr, idx)] = final_balance # Merge nonce changes - keep only highest nonce per address - # Nonces are monotonically increasing, so just keep the max address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} for addr, idx, nonce in child_frame.nonce_changes: - # Keep the highest nonce value for each address if ( addr not in address_final_nonces or nonce > address_final_nonces[addr][1] @@ -423,6 +421,48 @@ def merge_on_success(child_frame: StateChanges) -> None: # else: Net-zero change - skip entirely +def commit_transaction_frame(tx_frame: StateChanges) -> None: + """ + Commit a transaction frame's changes to the block frame. + + Merges ALL changes from the transaction frame into the block frame + without net-zero filtering. Each transaction's changes are recorded + at their respective transaction index, even if a later transaction + reverts a change back to its original value. + + This is different from merge_on_success() which filters net-zero + changes within a single transaction's execution. + + Parameters + ---------- + tx_frame : + The transaction frame to commit. + + """ + assert tx_frame.parent is not None + block_frame = tx_frame.parent + + # 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 merge_on_failure(child_frame: StateChanges) -> None: """ Merge child frame's changes into parent on failed completion. 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 7a342b98d5..14a9ab4bdf 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 @@ -2133,3 +2133,76 @@ def test_bal_create_transaction_empty_code( 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 recorded at tx_index=1) + Tx2: slot 0 = 0xABCD -> 0x0 (change MUST be recorded at tx_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(tx_index=1, post_nonce=1), + BalNonceChange(tx_index=2, post_nonce=2), + ], + ), + contract: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0xABCD), + # CRITICAL: tx2's write to 0x0 MUST appear + # even though it returns slot to original value + BalStorageChange(tx_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}), + }, + ) 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 44211095a0..5f80be1fbc 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -52,6 +52,8 @@ | `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_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_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_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 | From 259bbb450c4dbeaf752d1394cc2db80328a268b7 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 14:29:24 -0300 Subject: [PATCH 22/71] fix(spec-specs): Move destroy_account before BAL normalization --- src/ethereum/forks/amsterdam/fork.py | 13 ++++-- .../test_selfdestruct_revert.py | 41 ++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index fa4af90fd8..9abb065f0e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1057,8 +1057,15 @@ def process_transaction( block_output.block_logs += tx_output.logs + # EIP-7928: Handle in-transaction self-destruct BEFORE normalization + # Destroy accounts first so normalization sees correct post-tx state + # Only accounts created in same tx are in accounts_to_delete per EIP-6780 + for address in tx_output.accounts_to_delete: + destroy_account(block_env.state, address) + # EIP-7928: Normalize balance changes for this transaction before merging - # into block frame. + # into block frame. Must happen AFTER destroy_account so net-zero filtering + # sees the correct post-transaction balance (0 for destroyed accounts). normalize_balance_changes_for_transaction( tx_state_changes, BlockAccessIndex( @@ -1069,9 +1076,8 @@ def process_transaction( commit_transaction_frame(tx_state_changes) - # EIP-7928: Handle in-transaction self-destruct AFTER merge + # EIP-7928: Handle in-transaction self-destruct normalization AFTER merge # Convert storage writes to reads and remove nonce/code changes - # Only accounts created in same tx are in accounts_to_delete per EIP-6780 for address in tx_output.accounts_to_delete: handle_in_transaction_selfdestruct( block_env.block_state_changes, @@ -1080,7 +1086,6 @@ def process_transaction( get_block_access_index(block_env.block_state_changes) ), ) - destroy_account(block_env.state, address) def process_withdrawals( diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index e0bfa59ec2..2c7bbaea3e 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -8,8 +8,12 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, + BlockAccessListExpectation, Bytecode, Environment, + Fork, Initcode, Op, StateTestFiller, @@ -343,6 +347,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 +432,41 @@ 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 + balance_changes=[], # No net balance change + ) + account_expectations[selfdestruct_recipient_address] = ( + BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=1 + if selfdestruct_on_outer_call == 1 + else 2, + ) + ], + ) + ) + + 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( From a1fcc34887201da2166d4ba6723889e5d359992b Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 17:52:41 -0300 Subject: [PATCH 23/71] fix(spec-specs): Check delegation access gas before reading --- .../forks/amsterdam/vm/eoa_delegation.py | 81 +++++------- .../forks/amsterdam/vm/instructions/system.py | 123 ++++++++++++------ tests/prague/eip7702_set_code_tx/test_gas.py | 23 ++++ 3 files changed, 144 insertions(+), 83 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index cd1f24a70c..72d6d32215 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -116,11 +116,15 @@ def recover_authority(authorization: Authorization) -> Address: return Address(keccak256(public_key)[12:32]) -def check_delegation( +def calculate_delegation_cost( evm: Evm, address: Address -) -> Tuple[bool, Address, Address, Bytes, Uint]: +) -> Tuple[bool, Address, Optional[Address], Uint]: """ - Check delegation info without modifying state or tracking. + Check if address has delegation and calculate delegation target gas cost. + + This function reads the original account's code to check for delegation + and tracks it in state_changes. It calculates the delegation target's + gas cost but does NOT read the delegation target yet. Parameters ---------- @@ -131,77 +135,64 @@ def check_delegation( Returns ------- - delegation : `Tuple[bool, Address, Address, Bytes, Uint]` - (is_delegated, original_address, final_address, code, - additional_gas_cost) + delegation_info : `Tuple[bool, Address, Optional[Address], Uint]` + (is_delegated, original_address, delegated_address_or_none, + delegation_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, address, code, Uint(0) + return False, address, None, Uint(0) delegated_address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) + # Calculate gas cost for delegation target access if delegated_address in evm.accessed_addresses: - additional_gas_cost = GAS_WARM_ACCESS + delegation_gas_cost = GAS_WARM_ACCESS else: - additional_gas_cost = GAS_COLD_ACCOUNT_ACCESS + delegation_gas_cost = GAS_COLD_ACCOUNT_ACCESS - delegated_code = get_account(state, delegated_address).code + return True, address, delegated_address, delegation_gas_cost - return ( - True, - address, - delegated_address, - delegated_code, - additional_gas_cost, - ) - -def apply_delegation_tracking( - evm: Evm, original_address: Address, delegated_address: Address -) -> None: +def read_delegation_target(evm: Evm, delegated_address: Address) -> Bytes: """ - Apply delegation tracking after gas check passes. + Read the delegation target's code and track the access. + + Should ONLY be called AFTER verifying we have gas for the access. + + This function: + 1. Reads the delegation target's code from state + 2. Adds it to accessed_addresses (if not already there) + 3. Tracks it in state_changes for BAL Parameters ---------- evm : `Evm` The execution frame. - original_address : `Address` - The original address that was called. delegated_address : `Address` - The address delegated to. + The delegation target address. + + Returns + ------- + code : `Bytes` + The delegation target's code. """ - track_address(evm.state_changes, original_address) + state = evm.message.block_env.state + # Add to accessed addresses for warm/cold gas accounting if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) + # Track the address for BAL track_address(evm.state_changes, delegated_address) - -def access_delegation( - evm: Evm, address: Address -) -> Tuple[bool, Address, Bytes, Uint]: - """ - Access delegation info and track state changes. - - DEPRECATED: Use check_delegation and apply_delegation_tracking - for proper gas check ordering. - - """ - is_delegated, orig_addr, final_addr, code, gas_cost = check_delegation( - evm, address - ) - - if is_delegated: - apply_delegation_tracking(evm, orig_addr, final_addr) - - return is_delegated, final_addr, code, gas_cost + return get_account(state, delegated_address).code def set_delegation(message: Message) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 389adf6f54..3513e09a58 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -34,8 +34,8 @@ to_address_masked, ) from ...vm.eoa_delegation import ( - apply_delegation_tracking, - check_delegation, + calculate_delegation_cost, + read_delegation_target, ) from .. import ( Evm, @@ -397,13 +397,30 @@ def call(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) + # check gas for base access before reading `to` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + + # read `to` account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, to) + ) = calculate_delegation_cost(evm, to) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, to).code + final_address = to + access_gas_cost += delegation_gas_cost code_address = final_address @@ -421,12 +438,6 @@ def call(evm: Evm) -> None: access_gas_cost + create_gas_cost + transfer_gas_cost, ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, to) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext @@ -497,13 +508,30 @@ def callcode(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(code_address) + # check gas for base access before reading `code_address` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + + # read code_address account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, code_address) + ) = calculate_delegation_cost(evm, code_address) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, code_address).code + final_address = code_address + access_gas_cost += delegation_gas_cost code_address = final_address @@ -518,12 +546,6 @@ def callcode(evm: Evm) -> None: access_gas_cost + transfer_gas_cost, ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, original_address) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -665,16 +687,35 @@ def delegatecall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + + # check gas for base access before reading `code_address` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + if is_cold_access: evm.accessed_addresses.add(code_address) + # read `code_address` account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, code_address) + ) = calculate_delegation_cost(evm, code_address) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + # Now safe to read delegation target since we verified gas + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, code_address).code + final_address = code_address + access_gas_cost += delegation_gas_cost code_address = final_address @@ -684,12 +725,6 @@ def delegatecall(evm: Evm) -> None: U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, original_address) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -749,13 +784,31 @@ def staticcall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) + # check gas for base access before reading `to` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + + # read `to` account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, to) + ) = calculate_delegation_cost(evm, to) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + # Now safe to read delegation target since we verified gas + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, to).code + final_address = to + access_gas_cost += delegation_gas_cost code_address = final_address @@ -769,12 +822,6 @@ def staticcall(evm: Evm) -> None: access_gas_cost, ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, to) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION diff --git a/tests/prague/eip7702_set_code_tx/test_gas.py b/tests/prague/eip7702_set_code_tx/test_gas.py index 048a84c44b..b415684bf7 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,25 @@ 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(tx_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 +1299,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, ) From c8962937ae8697aa3110833d05aeecc04d31beb2 Mon Sep 17 00:00:00 2001 From: fselmo Date: Sat, 15 Nov 2025 19:22:46 -0300 Subject: [PATCH 24/71] fix(spec-specs): Track code per auth; filter pre at tx frame --- .../amsterdam/block_access_lists/builder.py | 3 +- src/ethereum/forks/amsterdam/state.py | 47 +++++ src/ethereum/forks/amsterdam/state_tracker.py | 10 +- .../forks/amsterdam/vm/eoa_delegation.py | 17 +- .../test_block_access_lists_eip7702.py | 165 ++++++++++++++++++ .../test_cases.md | 4 +- 6 files changed, 238 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 9af8d2a24c..ae05445b66 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -515,8 +515,7 @@ def build_block_access_list( add_nonce_change(builder, address, block_access_index, new_nonce) # Add all code changes - # Net-zero filtering for code changes should happen at the - # transaction level (in merge_on_success), not at the block level. + # Filtering happens at transaction level in eoa_delegation.py for ( address, block_access_index, diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index d16c9d9d69..c1d331942a 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -655,6 +655,53 @@ def write_code(sender: Account) -> None: track_code_change(state_changes, address, code) +def set_authority_code( + state: State, + address: Address, + code: Bytes, + state_changes: StateChanges, + current_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. Unlike set_code(), it tracks changes based + on the current code rather than pre_code to handle multiple authorizations + to the same address within a single transaction correctly. + + Parameters + ---------- + state: + The current state. + + address: + Address of the authority account whose code needs to be set. + + code: + The delegation designation bytecode to set. + + state_changes: + State changes frame for tracking (EIP-7928). + + current_code: + The current code before this change. Used to determine if tracking + is needed (only track if code actually changes from current value). + + """ + + def write_code(sender: Account) -> None: + sender.code = code + + modify_state(state, address, write_code) + + # Only track if code is actually changing from current value + # This allows multiple auths to same address to be tracked individually + # Net-zero filtering happens in commit_transaction_frame + if current_code != code: + track_code_change(state_changes, address, 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 index 58ca854bf8..7b98396318 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -372,7 +372,7 @@ def merge_on_success(child_frame: StateChanges) -> None: parent_frame.pre_storage[slot] = value for addr, code in child_frame.pre_code.items(): if addr not in parent_frame.pre_code: - parent_frame.pre_code[addr] = code + capture_pre_code(parent_frame, addr, code) # Merge storage operations, filtering noop writes parent_frame.storage_reads.update(child_frame.storage_reads) @@ -458,9 +458,13 @@ def commit_transaction_frame(tx_frame: StateChanges) -> None: for addr, idx, nonce in tx_frame.nonce_changes: block_frame.nonce_changes.add((addr, idx, nonce)) - # Merge code changes + # Merge code changes - filter net-zero changes within the transaction + # Compare final code against transaction's pre-code for (addr, idx), final_code in tx_frame.code_changes.items(): - block_frame.code_changes[(addr, idx)] = final_code + pre_code = tx_frame.pre_code.get(addr, b"") + if pre_code != final_code: + block_frame.code_changes[(addr, idx)] = final_code + # else: Net-zero change within this transaction - skip def merge_on_failure(child_frame: StateChanges) -> None: diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 72d6d32215..ec95fd1a47 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -14,7 +14,12 @@ # track_address_access removed - now using state_changes.track_address() 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 from ..utils.hexadecimal import hex_to_address from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS @@ -253,8 +258,16 @@ def set_delegation(message: Message) -> U256: or message.block_env.block_state_changes ) + # Capture pre-code before any changes (first-write-wins) capture_pre_code(state_changes, authority, authority_code) - set_code(state, authority, code_to_set, state_changes) + + # Set delegation code + # Uses authority_code (current) for tracking to handle multiple auths + # Net-zero filtering happens in commit_transaction_frame + set_authority_code( + state, authority, code_to_set, state_changes, authority_code + ) + increment_nonce(state, authority, state_changes) if message.code_address is None: 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 2a315b990f..e9bed5df85 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 @@ -663,3 +663,168 @@ def test_bal_7702_null_address_delegation_no_code_change( 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(tx_index=1, post_nonce=2) + ], + code_changes=[], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=10) + ] + ), + relayer: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_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(tx_index=1, post_nonce=2)], + code_changes=[ + # Should show final code (CONTRACT_B), not CONTRACT_A + BalCodeChange( + tx_index=1, + new_code=Spec7702.delegation_designation(contract_b), + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + relayer: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_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), + }, + ) 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 5f80be1fbc..39d38a4d27 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -39,7 +39,9 @@ | `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. +| `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 | From f5ae832cb91de90bad388a73b7ff3701ef57e415 Mon Sep 17 00:00:00 2001 From: fselmo Date: Sun, 16 Nov 2025 18:05:39 -0300 Subject: [PATCH 25/71] fix(spec-specs): Use proper frames for system transactions --- .../amsterdam/block_access_lists/builder.py | 11 +- src/ethereum/forks/amsterdam/fork.py | 10 + .../test_block_access_lists_cross_index.py | 260 ++++++++++++++++++ 3 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index ae05445b66..c1dbf98222 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -484,19 +484,14 @@ def build_block_access_list( for address, slot in state_changes.storage_reads: add_storage_read(builder, address, slot) - # Add all storage writes, filtering net-zero changes + # 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(): - # Check if this is a net-zero change by comparing with pre-state - if (address, slot) in state_changes.pre_storage: - if state_changes.pre_storage[(address, slot)] == value: - # Net-zero change - convert to read only - add_storage_read(builder, address, slot) - continue - # Convert U256 to Bytes32 for storage value_bytes = Bytes32(value.to_bytes(U256(32), "big")) add_storage_write( diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 9abb065f0e..f5a7081fd9 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -71,6 +71,7 @@ get_block_access_index, handle_in_transaction_selfdestruct, increment_block_access_index, + merge_on_success, normalize_balance_changes_for_transaction, track_address, track_balance_change, @@ -633,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.block_state_changes) + tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, gas_price=block_env.base_fee_per_gas, @@ -664,10 +669,15 @@ def process_system_transaction( accessed_storage_keys=set(), disable_precompiles=False, parent_evm=None, + transaction_state_changes=system_tx_state_changes, ) system_tx_output = process_message_call(system_tx_message) + # Merge system transaction changes back to block frame + # System transactions always succeed (or block is invalid) + merge_on_success(system_tx_state_changes) + return system_tx_output 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..920b8bc344 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py @@ -0,0 +1,260 @@ +""" +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 + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_index=2, + post_value=0, + ), + ], + ), + BalStorageSlot( + slot=0x03, # Target count + slot_changes=[ + BalStorageChange( + # Incremented during tx + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_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 + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_index=2, + post_value=0, + ), + ], + ), + BalStorageSlot( + slot=0x03, + slot_changes=[ + BalStorageChange( + # Incremented during tx + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_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(tx_index=1, post_value=42), + ], + ), + BalStorageSlot( + slot=4, + slot_changes=[ + BalStorageChange(tx_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}), + }, + ) From 2096c397a0744dc6468898686a651d87c03803ed Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 20 Nov 2025 22:58:11 -0300 Subject: [PATCH 26/71] fix(spec-specs): Track address at init collision --- src/ethereum/forks/amsterdam/vm/interpreter.py | 3 +++ .../eip7610_create_collision/test_initcollision.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index a63b745624..57f890a12e 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -173,6 +173,9 @@ 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.transaction_state_changes, message.current_target + ) if is_collision: return MessageCallOutput( Uint(0), diff --git a/tests/paris/eip7610_create_collision/test_initcollision.py b/tests/paris/eip7610_create_collision/test_initcollision.py index 181b4575b1..b8382f634d 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, ) From 6ca222d7dbac4dc5d2625e1c30ce5f32a58834a1 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 20 Nov 2025 22:59:11 -0300 Subject: [PATCH 27/71] chore(spec-specs): Add Amsterdam docstring; update prepare msg --- src/ethereum/forks/amsterdam/__init__.py | 7 ++++++- src/ethereum/forks/amsterdam/fork.py | 9 ++++++--- src/ethereum/forks/amsterdam/utils/message.py | 5 +++++ src/ethereum/forks/amsterdam/vm/__init__.py | 2 +- src/ethereum/forks/amsterdam/vm/instructions/system.py | 1 + 5 files changed, 19 insertions(+), 5 deletions(-) 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/fork.py b/src/ethereum/forks/amsterdam/fork.py index f5a7081fd9..358f194f6e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -995,9 +995,12 @@ def process_transaction( tx_hash=get_transaction_hash(encode_transaction(tx)), ) - message = prepare_message(block_env, tx_env, tx) - # Set transaction frame so call frames become children of it - message.transaction_state_changes = tx_state_changes + message = prepare_message( + block_env, + tx_env, + tx, + tx_state_changes, + ) tx_output = process_message_call(message) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index 107cdcaf7a..def5b36e20 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 StateChanges from ..transactions import Transaction from ..vm import BlockEnvironment, Message, TransactionEnvironment from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS @@ -27,6 +28,7 @@ def prepare_message( block_env: BlockEnvironment, tx_env: TransactionEnvironment, tx: Transaction, + transaction_state_changes: StateChanges, ) -> Message: """ Execute a transaction against the provided environment. @@ -39,6 +41,8 @@ def prepare_message( Environment for the transaction. tx : Transaction to be executed. + transaction_state_changes : + State changes specific to this transaction. Returns ------- @@ -87,4 +91,5 @@ def prepare_message( accessed_storage_keys=set(tx_env.access_list_storage_keys), disable_precompiles=False, parent_evm=None, + transaction_state_changes=transaction_state_changes, ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 26b7e99e45..d414aa50f9 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -142,7 +142,7 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] - transaction_state_changes: Optional[StateChanges] = None + transaction_state_changes: StateChanges @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 3513e09a58..67666014e2 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -144,6 +144,7 @@ def generic_create( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=False, parent_evm=evm, + transaction_state_changes=evm.message.transaction_state_changes, ) child_evm = process_create_message(child_message) From acb5632dfd7b16ee0cb2ca4e41f4082e7b851c9a Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 20 Nov 2025 23:30:18 -0300 Subject: [PATCH 28/71] chore: Add pre-amsterdam BAL tests to doc for tracking --- .../amsterdam/eip7928_block_level_access_lists/test_cases.md | 3 +++ .../cancun/eip6780_selfdestruct/test_selfdestruct_revert.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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 39d38a4d27..be92e54608 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -90,3 +90,6 @@ | `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. | ✅ 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 | diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index 2c7bbaea3e..59812174ef 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -441,7 +441,10 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 selfdestruct_with_transfer_contract_address ] = BalAccountExpectation( storage_reads=[0, 1], # Storage was accessed - balance_changes=[], # No net balance change + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_changes=[], ) account_expectations[selfdestruct_recipient_address] = ( BalAccountExpectation( From cc6cf0790dfaaeaaf4e5af9b8d026c691b369b44 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 24 Nov 2025 13:50:04 -0700 Subject: [PATCH 29/71] fix(spec-specs): Calculate all gas we can before accessing state - Calculate all gas that we can without state access and check this gas before ever accessing state. This is the most sensible way for an implementation to behave and indeed was revealed to be the way clients are behaving, which differed from the specs. - Use fork.gas_costs to calculate gas costs, NOT hard-coded values. - Create a BAL expectation for the test that yielded discrepancies between clients and specs so this doesn't slip through again. Document this test in `test_cases.md`. --- .../forks/amsterdam/vm/instructions/system.py | 171 ++++++++++-------- .../test_cases.md | 1 + .../test_call_and_callcode_gas_calculation.py | 104 +++++++++-- 3 files changed, 187 insertions(+), 89 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 67666014e2..1fca8b1459 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -395,14 +395,22 @@ def call(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + + check_gas( + evm, + access_gas_cost + transfer_gas_cost + extend_memory.cost, + ) + + # need to access account to check if account is alive, check gas before + create_gas_cost = GAS_NEW_ACCOUNT + if value == 0 or is_account_alive(evm.message.block_env.state, to): + create_gas_cost = Uint(0) + if is_cold_access: evm.accessed_addresses.add(to) - # check gas for base access before reading `to` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) - - # read `to` account and assess delegation cost ( is_delegated, original_address, @@ -410,35 +418,36 @@ def call(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, to) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - if is_delegated: assert delegated_address is not None + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + + transfer_gas_cost + + create_gas_cost + + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + 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, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, to).code final_address = to - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - 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, - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext @@ -509,11 +518,14 @@ def callcode(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(code_address) - # check gas for base access before reading `code_address` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + + check_gas( + evm, + access_gas_cost + extend_memory.cost + transfer_gas_cost, + ) - # read code_address account and assess delegation cost + # need to access account to get delegation code, check gas before ( is_delegated, original_address, @@ -521,32 +533,34 @@ def callcode(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, code_address) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - if is_delegated: assert delegated_address is not None + # Recalculate with delegation cost and check gas + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + transfer_gas_cost + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + transfer_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, code_address).code final_address = code_address - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - 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, - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -688,15 +702,12 @@ def delegatecall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) - - # check gas for base access before reading `code_address` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) - if is_cold_access: evm.accessed_addresses.add(code_address) - # read `code_address` account and assess delegation cost + check_gas(evm, access_gas_cost + extend_memory.cost) + + # need to access account to get delegation code, check gas before ( is_delegated, original_address, @@ -704,28 +715,33 @@ def delegatecall(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, code_address) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - # Now safe to read delegation target since we verified gas - if is_delegated: assert delegated_address is not None + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, code_address).code final_address = code_address - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - message_call_gas = calculate_message_call_gas( - U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -785,11 +801,9 @@ def staticcall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) - # check gas for base access before reading `to` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) + check_gas(evm, access_gas_cost + extend_memory.cost) - # read `to` account and assess delegation cost + # need to access account to get delegation code, check gas before ( is_delegated, original_address, @@ -797,32 +811,33 @@ def staticcall(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, to) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - # Now safe to read delegation target since we verified gas - if is_delegated: assert delegated_address is not None + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, to).code final_address = to - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - message_call_gas = calculate_message_call_gas( - U256(0), - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost, - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION 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 be92e54608..fd843a4123 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -93,3 +93,4 @@ | `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 read for `is_account_alive` check. 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 | 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..015217521d 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -40,6 +40,11 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessListExpectation, Bytecode, Environment, Op, @@ -71,33 +76,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 - cost += callee_init_stack_gas + sufficient = callee_init_stack_gas + cost - return cost + return sufficient @pytest.fixture -def callee_code(pre: Alloc, callee_opcode: Op, fork: Fork) -> Bytecode: +def empty_account(pre: Alloc) -> Address: + """A guaranteed-to-be-empty account.""" + return pre.empty_account() + + +@pytest.fixture +def callee_code( + callee_opcode: Op, fork: Fork, empty_account: Address +) -> Bytecode: """ Code called by the caller contract: PUSH1 0x00 * 4 @@ -119,7 +133,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, @@ -197,6 +211,67 @@ 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(tx_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(tx_index=1, post_balance=4) + ], + storage_reads=[0] if gas_shortage else [], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=1), + ], + ), + ] + if not gas_shortage + else [], + ), + callee_address: BalAccountExpectation( + balance_changes=( + [BalBalanceChange(tx_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 +282,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( From 0baa67f9a211ecc72b0c4df77a30c1d4c790b9c2 Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 25 Nov 2025 20:36:27 +0000 Subject: [PATCH 30/71] fix(test-tools): Remove named forks from blobSchedule; turn off BPOs --- .../plugins/consume/simulators/helpers/ruleset.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py index 0561727b0c..15ab56bc9c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py @@ -296,7 +296,6 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 0, - **get_blob_schedule_entries(Osaka), }, PragueToOsakaAtTime15k: { "HIVE_FORK_HOMESTEAD": 0, @@ -314,7 +313,6 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 15000, - **get_blob_schedule_entries(Osaka), }, BPO1: { "HIVE_FORK_HOMESTEAD": 0, @@ -496,11 +494,10 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 0, - "HIVE_BPO1_TIMESTAMP": 0, - "HIVE_BPO2_TIMESTAMP": 0, - "HIVE_BPO3_TIMESTAMP": 0, - "HIVE_BPO4_TIMESTAMP": 0, + # TODO: While we are still reworking BPO interaction with T8N, + # turn off BPO timestamps for now. + # "HIVE_BPO1_TIMESTAMP": 0, + # "HIVE_BPO2_TIMESTAMP": 0, "HIVE_AMSTERDAM_TIMESTAMP": 0, - **get_blob_schedule_entries(Amsterdam), }, } From d5aec93cda72ed1035e2258ed58bc6bff799f886 Mon Sep 17 00:00:00 2001 From: felix Date: Wed, 26 Nov 2025 10:24:46 +0000 Subject: [PATCH 31/71] fix: add bal exception for erigon (#1809) --- .../testing/src/execution_testing/client_clis/clis/erigon.py | 1 + 1 file changed, 1 insertion(+) 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" ), From 94e23939fa1225a78f7b101add2f0487f2c4bd3f Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 26 Nov 2025 09:46:27 -0700 Subject: [PATCH 32/71] feat(test): Better describe the BAL for selfdestruct revert --- .../test_selfdestruct_revert.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index 59812174ef..ab2d22f112 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -10,6 +10,10 @@ Alloc, BalAccountExpectation, BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, BlockAccessListExpectation, Bytecode, Environment, @@ -458,6 +462,31 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 ], ) ) + else: + account_expectations[ + selfdestruct_with_transfer_contract_address + ] = BalAccountExpectation( + storage_reads=[1], + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + balance_changes=[BalBalanceChange(tx_index=1, post_balance=1)], + code_changes=[ + BalCodeChange( + tx_index=1, + new_code=selfdestruct_with_transfer_contract_code, + ), + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=1), + ], + ), + ], + ) + account_expectations[selfdestruct_recipient_address] = ( + BalAccountExpectation.empty() + ) expected_block_access_list = BlockAccessListExpectation( account_expectations=account_expectations From 072caa62748822d506bcead14571d7e5cef81fbe Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 30 Nov 2025 15:50:31 +0100 Subject: [PATCH 33/71] test(eip7928): add EXTCODECOPY OOG memory expansion BAL test --- .../test_block_access_lists_opcodes.py | 90 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 91 insertions(+) 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 b849137c58..4df25a0869 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 @@ -611,6 +611,96 @@ def test_bal_extcodecopy_and_oog( ) +@pytest.mark.parametrize( + "memory_offset,copy_size,gas_shortfall", + [ + pytest.param(0x10000, 32, "large", id="large_offset"), + pytest.param(256, 32, "boundary", id="boundary"), + ], +) +def test_bal_extcodecopy_oog_at_memory_expansion( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + memory_offset: int, + copy_size: int, + gas_shortfall: str, +) -> None: + """ + Test EXTCODECOPY OOG at memory expansion - target should NOT appear 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() + + target_contract = pre.deploy_contract(code=Bytecode(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) + + dest_push + + 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() + + 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 gas_shortfall == "large": + # 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 + else: + # Calculate memory cost and provide exactly 1 less than needed + words = (memory_offset + copy_size + 31) // 32 + memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) + execution_cost = push_cost + cold_access_cost + copy_cost + memory_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost - 1 + + 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: 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, 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 fd843a4123..9613a8f2c7 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -94,3 +94,4 @@ | `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 read for `is_account_alive` check. 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_extcodecopy_oog_at_memory_expansion` | Ensure BAL excludes target when EXTCODECOPY OOGs at memory expansion | Parameterized: (1) large_offset: 64KB memory offset, gas covers cold access + copy but NOT memory expansion. (2) boundary: 256 byte offset, gas is exactly 1 less than needed. | BAL **MUST NOT** include target contract. Gas must be checked for ALL components (cold access + copy + memory expansion) BEFORE recording account access. | ✅ Completed | From 3113d0a05a3aa4b26b83c53dbfdef770a9653d1c Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 1 Dec 2025 09:58:44 -0700 Subject: [PATCH 34/71] refactor(test-tests): parametrize existing test oog case instead --- .../test_block_access_lists_opcodes.py | 151 +++++++----------- .../test_cases.md | 3 +- 2 files changed, 57 insertions(+), 97 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 4df25a0869..99ad5dcddb 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 @@ -529,105 +529,42 @@ def test_bal_delegatecall_and_oog( @pytest.mark.parametrize( - "fails_at_extcodecopy", - [True, False], - ids=["oog_at_extcodecopy", "successful_extcodecopy"], -) -def test_bal_extcodecopy_and_oog( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - fork: Fork, - fails_at_extcodecopy: bool, -) -> None: - """ - Ensure BAL handles EXTCODECOPY and OOG during EXTCODECOPY appropriately. - """ - 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) - ) - - # 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 - ) - - extcodecopy_contract = pre.deploy_contract(code=extcodecopy_contract_code) - - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() - - # 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 - - if fails_at_extcodecopy: - # subtract 1 gas to ensure OOG at EXTCODECOPY - tx_gas_limit -= 1 - - 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 should only appear if EXTCODECOPY succeeded - **( - {target_contract: None} - if fails_at_extcodecopy - else {target_contract: BalAccountExpectation.empty()} - ), - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - extcodecopy_contract: Account(), - target_contract: Account(), - }, - ) - - -@pytest.mark.parametrize( - "memory_offset,copy_size,gas_shortfall", + "oog_scenario,memory_offset,copy_size", [ - pytest.param(0x10000, 32, "large", id="large_offset"), - pytest.param(256, 32, "boundary", id="boundary"), + 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_oog_at_memory_expansion( +def test_bal_extcodecopy_and_oog( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, + oog_scenario: str, memory_offset: int, copy_size: int, - gas_shortfall: str, ) -> None: """ - Test EXTCODECOPY OOG at memory expansion - target should NOT appear in BAL. + 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. @@ -635,7 +572,10 @@ def test_bal_extcodecopy_oog_at_memory_expansion( alice = pre.fund_eoa() gas_costs = fork.gas_costs() - target_contract = pre.deploy_contract(code=Bytecode(Op.STOP)) + # 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: @@ -647,8 +587,8 @@ def test_bal_extcodecopy_oog_at_memory_expansion( extcodecopy_contract_code = Bytecode( Op.PUSH1(copy_size) - + Op.PUSH1(0) - + dest_push + + Op.PUSH1(0) # codeOffset + + dest_push # destOffset + Op.PUSH20(target_contract) + Op.EXTCODECOPY + Op.STOP @@ -659,20 +599,37 @@ def test_bal_extcodecopy_oog_at_memory_expansion( 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 gas_shortfall == "large": + if oog_scenario == "success": + # Provide enough gas for everything including memory expansion + words = (memory_offset + copy_size + 31) // 32 + memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) + 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 - else: + target_in_bal = False + elif oog_scenario == "oog_at_memory_boundary": # Calculate memory cost and provide exactly 1 less than needed words = (memory_offset + copy_size + 31) // 32 memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) 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, @@ -685,7 +642,11 @@ def test_bal_extcodecopy_oog_at_memory_expansion( expected_block_access_list=BlockAccessListExpectation( account_expectations={ extcodecopy_contract: BalAccountExpectation.empty(), - target_contract: None, + **( + {target_contract: BalAccountExpectation.empty()} + if target_in_bal + else {target_contract: None} + ), } ), ) 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 9613a8f2c7..5c7bcb42b4 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,7 +49,7 @@ | `test_bal_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_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 | @@ -94,4 +94,3 @@ | `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 read for `is_account_alive` check. 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_extcodecopy_oog_at_memory_expansion` | Ensure BAL excludes target when EXTCODECOPY OOGs at memory expansion | Parameterized: (1) large_offset: 64KB memory offset, gas covers cold access + copy but NOT memory expansion. (2) boundary: 256 byte offset, gas is exactly 1 less than needed. | BAL **MUST NOT** include target contract. Gas must be checked for ALL components (cold access + copy + memory expansion) BEFORE recording account access. | ✅ Completed | From 4c6988dc1beec35612b30f0db26bf6fc60221249 Mon Sep 17 00:00:00 2001 From: Stefan Date: Mon, 1 Dec 2025 17:09:31 +0100 Subject: [PATCH 35/71] test(eip7928): add cross-block precompile state leak test --- .../test_block_access_lists.py | 77 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 78 insertions(+) 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 14a9ab4bdf..f062d7eadf 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 @@ -2206,3 +2206,80 @@ def test_bal_cross_tx_storage_revert_to_zero( contract: Account(storage={0: 0x0}), }, ) + + +# RIPEMD-160 precompile address (used in Parity Touch Bug test) +RIPEMD_160 = Address(0x03) + + +def test_bal_cross_block_precompile_state_leak( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure internal EVM state for 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. + pre[RIPEMD_160] = Account(balance=1) + + # Contract that calls RIPEMD-160 with zero value + ripemd_caller = pre.deploy_contract( + code=Op.CALL(50_000, RIPEMD_160, 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, + ) + ], + ) + + # 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, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[block1, block2], + post={ + alice: Account(nonce=1), + bob: Account(nonce=1), + RIPEMD_160: Account(balance=1), + }, + ) 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 5c7bcb42b4..dbcf8ce31a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -94,3 +94,4 @@ | `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 read for `is_account_alive` check. 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_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 | From 14d1e612a5a53f1659baeb7f157141e6f7b5b1f5 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 1 Dec 2025 10:49:02 -0700 Subject: [PATCH 36/71] refactor(test-tests): Add BAL expectation to state leak test; fix lint --- .../test_block_access_lists.py | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) 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 f062d7eadf..09f578d286 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 @@ -2208,20 +2208,18 @@ def test_bal_cross_tx_storage_revert_to_zero( ) -# RIPEMD-160 precompile address (used in Parity Touch Bug test) -RIPEMD_160 = Address(0x03) - - -def test_bal_cross_block_precompile_state_leak( +def test_bal_cross_block_ripemd160_state_leak( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: """ - Ensure internal EVM state for precompile handling does not leak between blocks. + 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. + 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. @@ -2233,21 +2231,22 @@ def test_bal_cross_block_precompile_state_leak( - 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. + - 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. - pre[RIPEMD_160] = Account(balance=1) + 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, RIPEMD_160, 0, 0, 0, 0, 0) + Op.STOP + 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) + # 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 @@ -2259,6 +2258,16 @@ def test_bal_cross_block_precompile_state_leak( gas_limit=100_000, ) ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + ), + bob: None, + ripemd_caller: BalAccountExpectation.empty(), + ripemd160_addr: BalAccountExpectation.empty(), + } + ), ) # Block 2: Exception triggers internal exception handling. @@ -2272,6 +2281,16 @@ def test_bal_cross_block_precompile_state_leak( gas_limit=100_000, ) ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: None, + bob: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + ), + # this is the important check + ripemd160_addr: None, + } + ), ) blockchain_test( @@ -2280,6 +2299,6 @@ def test_bal_cross_block_precompile_state_leak( post={ alice: Account(nonce=1), bob: Account(nonce=1), - RIPEMD_160: Account(balance=1), + ripemd160_addr: Account(balance=1), }, ) From 7bedd1211f0f8be28e2369219d121d5ea293befd Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 4 Dec 2025 18:23:25 +0100 Subject: [PATCH 37/71] eip7928: add SELFDESTRUCT OOG BAL test --- .../test_block_access_lists.py | 70 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 71 insertions(+) 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 09f578d286..beee7982aa 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 @@ -373,6 +373,76 @@ def test_bal_self_destruct( ) +def test_bal_self_destruct_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that SELFDESTRUCT beneficiary is NOT included in BAL when OOG. + + When SELFDESTRUCT runs out of gas, the operation fails and the beneficiary + address should NOT be added to the Block Access List. + + This test: + 1. Deploys a contract with SELFDESTRUCT bytecode + 2. Calls the contract with limited gas so SELFDESTRUCT fails OOG + 3. Verifies beneficiary is NOT in BAL (the CALL reverts, undoing BAL changes) + + SELFDESTRUCT gas cost to cold new account: 5000 + 2600 + 25000 = 32600 gas + """ + alice = pre.fund_eoa() + + # Beneficiary address for SELFDESTRUCT + beneficiary = Address(0xBEEF) + + # Contract: PUSH20 SELFDESTRUCT + selfdestruct_code = Op.SELFDESTRUCT(beneficiary) + selfdestruct_contract = pre.deploy_contract(code=selfdestruct_code, balance=1000) + + # Caller contract: CALL with limited gas to cause OOG on SELFDESTRUCT + # SELFDESTRUCT needs 32600 gas, we give it only 100 + caller_code = ( + Op.CALL(gas=100, address=selfdestruct_contract, value=0, + args_offset=0, args_size=0, ret_offset=0, ret_size=0) + + Op.STOP + ) + caller_contract = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller_contract, + gas_limit=100_000, + gas_price=0xA, + ) + + # The inner CALL fails OOG, so SELFDESTRUCT doesn't complete. + # Beneficiary should NOT be in BAL. + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + caller_contract: BalAccountExpectation.empty(), + selfdestruct_contract: BalAccountExpectation.empty(), + # beneficiary should NOT appear - SELFDESTRUCT failed OOG + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + caller_contract: Account(code=caller_code), + # Contract still exists - SELFDESTRUCT failed + selfdestruct_contract: Account(balance=1000, code=selfdestruct_code), + }, + ) + + @pytest.mark.parametrize( "account_access_opcode", [ 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 dbcf8ce31a..06287528ef 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -95,3 +95,4 @@ | `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 read for `is_account_alive` check. 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_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_self_destruct_oog` | Ensure BAL does not include SELFDESTRUCT beneficiary when operation fails due to OOG | Alice calls `Caller` contract which CALLs `SelfDestructContract` with limited gas (100). `SelfDestructContract` attempts SELFDESTRUCT to `Beneficiary`. SELFDESTRUCT requires 32600 gas (5000 base + 2600 cold + 25000 new account). | BAL **MUST** include Alice with `nonce_changes`, `Caller` with empty changes, `SelfDestructContract` with empty changes. BAL **MUST NOT** include `Beneficiary` (SELFDESTRUCT failed OOG, CALL reverted, BAL changes rolled back). Contract balance unchanged. | ✅ Completed | From 73b8e632dcd08b12a7cd8ef15f2c4ecdbca8c884 Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Thu, 4 Dec 2025 23:48:41 +0000 Subject: [PATCH 38/71] refactor(tests): move selfdestruct bal tests to oog file; add gas boundaries --- .../forks/amsterdam/vm/instructions/system.py | 6 +- .../test_block_access_lists.py | 229 +--------------- .../test_block_access_lists_opcodes.py | 248 +++++++++++++++++- 3 files changed, 251 insertions(+), 232 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 1fca8b1459..288594bfe7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -625,6 +625,9 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: gas_cost += GAS_COLD_ACCOUNT_ACCESS + check_gas(evm, gas_cost) + + # is_account_alive requires account to be accessed, check gas before if ( not is_account_alive(evm.message.block_env.state, beneficiary) and get_account( @@ -634,11 +637,8 @@ def selfdestruct(evm: Evm) -> None: ): gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT - check_gas(evm, gas_cost) - if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address(evm.state_changes, beneficiary) charge_gas(evm, gas_cost) 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 beee7982aa..50fb9e0ad2 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 ( @@ -21,7 +21,6 @@ Fork, Hash, Header, - Initcode, Op, Transaction, compute_create_address, @@ -217,232 +216,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 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, - ) - - -def test_bal_self_destruct_oog( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Test that SELFDESTRUCT beneficiary is NOT included in BAL when OOG. - - When SELFDESTRUCT runs out of gas, the operation fails and the beneficiary - address should NOT be added to the Block Access List. - - This test: - 1. Deploys a contract with SELFDESTRUCT bytecode - 2. Calls the contract with limited gas so SELFDESTRUCT fails OOG - 3. Verifies beneficiary is NOT in BAL (the CALL reverts, undoing BAL changes) - - SELFDESTRUCT gas cost to cold new account: 5000 + 2600 + 25000 = 32600 gas - """ - alice = pre.fund_eoa() - - # Beneficiary address for SELFDESTRUCT - beneficiary = Address(0xBEEF) - - # Contract: PUSH20 SELFDESTRUCT - selfdestruct_code = Op.SELFDESTRUCT(beneficiary) - selfdestruct_contract = pre.deploy_contract(code=selfdestruct_code, balance=1000) - - # Caller contract: CALL with limited gas to cause OOG on SELFDESTRUCT - # SELFDESTRUCT needs 32600 gas, we give it only 100 - caller_code = ( - Op.CALL(gas=100, address=selfdestruct_contract, value=0, - args_offset=0, args_size=0, ret_offset=0, ret_size=0) - + Op.STOP - ) - caller_contract = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller_contract, - gas_limit=100_000, - gas_price=0xA, - ) - - # The inner CALL fails OOG, so SELFDESTRUCT doesn't complete. - # Beneficiary should NOT be in BAL. - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - ), - caller_contract: BalAccountExpectation.empty(), - selfdestruct_contract: BalAccountExpectation.empty(), - # beneficiary should NOT appear - SELFDESTRUCT failed OOG - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - caller_contract: Account(code=caller_code), - # Contract still exists - SELFDESTRUCT failed - selfdestruct_contract: Account(balance=1000, code=selfdestruct_code), - }, - ) - - @pytest.mark.parametrize( "account_access_opcode", [ 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 99ad5dcddb..bae1544eee 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,13 +15,15 @@ """ from enum import Enum -from typing import Callable +from typing import Callable, Dict import pytest from execution_testing import ( Account, + Address, Alloc, BalAccountExpectation, + BalBalanceChange, BalNonceChange, BalStorageChange, BalStorageSlot, @@ -30,6 +32,7 @@ BlockchainTestFiller, Bytecode, Fork, + Initcode, Op, Transaction, compute_create_address, @@ -662,6 +665,249 @@ def test_bal_extcodecopy_and_oog( ) +@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(1_000_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, + ) + + 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 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("oog_before_state_access", [True, False]) +def test_bal_self_destruct_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_before_state_access: bool, +) -> None: + """ + Test SELFDESTRUCT BAL behavior at gas boundaries. + + SELFDESTRUCT has two gas checkpoints: + 1. static checks: G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS + OOG here = no state access, beneficiary NOT in BAL + 2. state access: same as static checks, plus G_NEW_ACCOUNT for new account + OOG here = enough gas to access state but not enough for new account, + beneficiary IS in BAL + """ + alice = pre.fund_eoa() + # always use new account so we incur extra G_NEW_ACCOUNT cost + # there is no other gas boundary to test between cold access + # and new account + beneficiary = pre.empty_account() + + # selfdestruct_contract: PUSH20 SELFDESTRUCT + selfdestruct_code = Op.SELFDESTRUCT(beneficiary) + selfdestruct_contract = pre.deploy_contract( + code=selfdestruct_code, balance=1000 + ) + + # Gas needed inside the CALL for SELFDESTRUCT: + # - PUSH20: G_VERY_LOW = 3 + # - SELFDESTRUCT: G_SELF_DESTRUCT + # - G_COLD_ACCOUNT_ACCESS (beneficiary cold access) + gas_costs = fork.gas_costs() + exact_static_gas = ( + gas_costs.G_VERY_LOW + + gas_costs.G_SELF_DESTRUCT + + gas_costs.G_COLD_ACCOUNT_ACCESS + ) + + # subtract one from the exact gas to trigger OOG before state access + oog_gas = ( + exact_static_gas - 1 if oog_before_state_access else exact_static_gas + ) + + # caller_contract: CALL with oog_gas + caller_code = Op.CALL(gas=oog_gas, address=selfdestruct_contract) + caller_contract = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller_contract, + gas_limit=100_000, + ) + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + caller_contract: BalAccountExpectation.empty(), + selfdestruct_contract: BalAccountExpectation.empty(), + # beneficiary only in BAL if we passed check_gas (state accessed) + beneficiary: None + if oog_before_state_access + else BalAccountExpectation.empty(), + } + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + caller_contract: Account(code=caller_code), + # selfdestruct_contract still exists - SELFDESTRUCT failed + selfdestruct_contract: Account( + balance=1000, code=selfdestruct_code + ), + }, + ) + + def test_bal_storage_write_read_same_frame( pre: Alloc, blockchain_test: BlockchainTestFiller, From 6b09aef57034e0fd5b5d834e8809a5416e1f2571 Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Fri, 5 Dec 2025 00:11:55 +0000 Subject: [PATCH 39/71] update test_cases.md --- .../amsterdam/eip7928_block_level_access_lists/test_cases.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 06287528ef..9ddca8a362 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -93,6 +93,6 @@ | `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 read for `is_account_alive` check. 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_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_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_self_destruct_oog` | Ensure BAL does not include SELFDESTRUCT beneficiary when operation fails due to OOG | Alice calls `Caller` contract which CALLs `SelfDestructContract` with limited gas (100). `SelfDestructContract` attempts SELFDESTRUCT to `Beneficiary`. SELFDESTRUCT requires 32600 gas (5000 base + 2600 cold + 25000 new account). | BAL **MUST** include Alice with `nonce_changes`, `Caller` with empty changes, `SelfDestructContract` with empty changes. BAL **MUST NOT** include `Beneficiary` (SELFDESTRUCT failed OOG, CALL reverted, BAL changes rolled back). Contract balance unchanged. | ✅ Completed | +| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ Completed | From 8077e4f4c67a24e52861ae93ab8a63c2bd25f969 Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 1 Dec 2025 23:25:51 +0000 Subject: [PATCH 40/71] feat(tests): Port oog create refund test; add BAL >= Amsterdam --- .../forks/amsterdam/vm/interpreter.py | 28 +- .../test_create_oog_from_eoa_refunds.py | 417 +++++++++++++++ .../CreateOOGFromEOARefundsFiller.yml | 483 ------------------ 3 files changed, 439 insertions(+), 489 deletions(-) create mode 100644 tests/cancun/create/test_create_oog_from_eoa_refunds.py delete mode 100644 tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 57f890a12e..893a0d8833 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -267,7 +267,7 @@ def process_create_message(message: Message) -> Evm: create_frame = create_child_frame(parent_frame) increment_nonce(state, message.current_target, create_frame) - evm = process_message(message) + evm = process_message(message, parent_state_frame=create_frame) if not evm.error: contract_code = evm.output contract_code_gas = Uint(len(contract_code)) * GAS_CODE_DEPOSIT @@ -296,7 +296,10 @@ def process_create_message(message: Message) -> Evm: return evm -def process_message(message: Message) -> Evm: +def process_message( + message: Message, + parent_state_frame: Optional[StateChanges] = None, +) -> Evm: """ Move ether and execute the relevant code. @@ -304,6 +307,12 @@ def process_message(message: Message) -> Evm: ---------- message : Transaction specific items. + parent_state_frame : + Optional parent frame for state tracking. When provided (e.g., for + CREATE's init code), state changes are tracked as a child of this + frame instead of the default parent determined by the message. + This ensures proper frame hierarchy for CREATE operations where + init code changes must be children of the CREATE frame. Returns ------- @@ -318,8 +327,15 @@ def process_message(message: Message) -> Evm: begin_transaction(state, transient_storage) - parent_frame = get_parent_frame(message) - state_changes = get_message_state_frame(message) + if parent_state_frame is not None: + # Use provided parent for CREATE's init code execution. + # This ensures init code state changes are children of create_frame, + # so they are properly converted to reads if code deposit fails. + parent_changes = parent_state_frame + state_changes = create_child_frame(parent_state_frame) + else: + parent_changes = get_parent_frame(message) + state_changes = get_message_state_frame(message) track_address(state_changes, message.current_target) @@ -335,11 +351,11 @@ def process_message(message: Message) -> Evm: evm = execute_code(message, state_changes) if evm.error: rollback_transaction(state, transient_storage) - if state_changes != parent_frame: + if state_changes != parent_changes: merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) - if state_changes != parent_frame: + if state_changes != parent_changes: merge_on_success(evm.state_changes) return evm 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..ee7f14571a --- /dev/null +++ b/tests/cancun/create/test_create_oog_from_eoa_refunds.py @@ -0,0 +1,417 @@ +""" +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(tx_index=1, post_nonce=expected_nonce) + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange(tx_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(tx_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/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 From c425a90ef1265077a2a30eb495a9ebd791c1630f Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 8 Dec 2025 12:38:20 -0700 Subject: [PATCH 41/71] refactor(spec-specs): Refactor state changes and frame hierarchy (#1841) * refactor(spec-specs): Refactor state changes and their frames * chore(spec-specs): cleanup BAL logic; organize gas check for SSTORE * refactor(spec-specs): Changes from comments on PR #1841 * enhance: don't set defaults for state_changes --- src/ethereum/forks/amsterdam/fork.py | 116 ++--- src/ethereum/forks/amsterdam/state.py | 110 +--- src/ethereum/forks/amsterdam/state_tracker.py | 477 ++++++++---------- src/ethereum/forks/amsterdam/utils/message.py | 11 +- src/ethereum/forks/amsterdam/vm/__init__.py | 8 +- .../forks/amsterdam/vm/eoa_delegation.py | 35 +- .../amsterdam/vm/instructions/storage.py | 75 +-- .../forks/amsterdam/vm/instructions/system.py | 85 +++- .../forks/amsterdam/vm/interpreter.py | 163 +++--- .../evm_tools/t8n/__init__.py | 13 +- 10 files changed, 490 insertions(+), 603 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 358f194f6e..697086e2b4 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -30,7 +30,6 @@ from . import vm from .block_access_lists.builder import build_block_access_list -from .block_access_lists.rlp_types import BlockAccessIndex 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 @@ -65,16 +64,16 @@ state_root, ) from .state_tracker import ( + StateChanges, capture_pre_balance, commit_transaction_frame, create_child_frame, - get_block_access_index, - handle_in_transaction_selfdestruct, + filter_net_zero_frame_changes, increment_block_access_index, - merge_on_success, - normalize_balance_changes_for_transaction, track_address, track_balance_change, + track_nonce_change, + track_selfdestruct, ) from .transactions import ( AccessListTransaction, @@ -249,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( @@ -636,7 +636,7 @@ def process_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.block_state_changes) + system_tx_state_changes = create_child_frame(block_env.state_changes) tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, @@ -649,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, @@ -669,14 +673,15 @@ def process_system_transaction( accessed_storage_keys=set(), disable_precompiles=False, parent_evm=None, - transaction_state_changes=system_tx_state_changes, + is_create=False, + state_changes=call_frame, ) system_tx_output = process_message_call(system_tx_message) - # Merge system transaction changes back to block frame + # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) - merge_on_success(system_tx_state_changes) + commit_transaction_frame(tx_env.state_changes) return system_tx_output @@ -816,7 +821,7 @@ def apply_body( # 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.block_state_changes) + increment_block_access_index(block_env.state_changes) process_withdrawals(block_env, block_output, withdrawals) @@ -824,9 +829,9 @@ def apply_body( block_env=block_env, block_output=block_output, ) - # Build block access list from block_env.block_state_changes + # Build block access list from block_env.state_changes block_output.block_access_list = build_block_access_list( - block_env.block_state_changes + block_env.state_changes ) return block_output @@ -909,9 +914,10 @@ def process_transaction( """ # 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.block_state_changes) - tx_state_changes = create_child_frame(block_env.block_state_changes) + 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 @@ -949,16 +955,27 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender, tx_state_changes) + + # 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 ) set_account_balance( - block_env.state, + block_env.state, sender, U256(sender_balance_after_gas_fee) + ) + track_balance_change( + tx_state_changes, sender, U256(sender_balance_after_gas_fee), - tx_state_changes, ) access_list_addresses = set() @@ -993,13 +1010,13 @@ 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, - tx_state_changes, ) tx_output = process_message_call(message) @@ -1029,11 +1046,11 @@ def process_transaction( sender_balance_after_refund = get_account( block_env.state, sender ).balance + U256(gas_refund_amount) - set_account_balance( - block_env.state, + set_account_balance(block_env.state, sender, sender_balance_after_refund) + track_balance_change( + tx_env.state_changes, sender, sender_balance_after_refund, - tx_state_changes, ) coinbase_balance_after_mining_fee = get_account( @@ -1041,10 +1058,12 @@ def process_transaction( ).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, - tx_state_changes, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( @@ -1070,35 +1089,19 @@ def process_transaction( block_output.block_logs += tx_output.logs - # EIP-7928: Handle in-transaction self-destruct BEFORE normalization - # Destroy accounts first so normalization sees correct post-tx state - # Only accounts created in same tx are in accounts_to_delete per EIP-6780 for address in tx_output.accounts_to_delete: destroy_account(block_env.state, address) - # EIP-7928: Normalize balance changes for this transaction before merging - # into block frame. Must happen AFTER destroy_account so net-zero filtering - # sees the correct post-transaction balance (0 for destroyed accounts). - normalize_balance_changes_for_transaction( - tx_state_changes, - BlockAccessIndex( - get_block_access_index(block_env.block_state_changes) - ), - block_env.state, - ) + # EIP-7928: Filter net-zero changes before committing to block frame. + # Must happen AFTER destroy_account so filtering sees correct state. + filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) - commit_transaction_frame(tx_state_changes) + commit_transaction_frame(tx_env.state_changes) - # EIP-7928: Handle in-transaction self-destruct normalization AFTER merge + # EIP-7928: Track in-transaction self-destruct normalization AFTER merge # Convert storage writes to reads and remove nonce/code changes for address in tx_output.accounts_to_delete: - handle_in_transaction_selfdestruct( - block_env.block_state_changes, - address, - BlockAccessIndex( - get_block_access_index(block_env.block_state_changes) - ), - ) + track_selfdestruct(block_env.state_changes, address) def process_withdrawals( @@ -1109,13 +1112,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.block_state_changes, address) - capture_pre_balance( - block_env.block_state_changes, address, pre_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) @@ -1131,22 +1133,16 @@ def increase_recipient_balance(recipient: Account) -> None: new_balance = get_account(block_env.state, wd.address).balance track_balance_change( - block_env.block_state_changes, wd.address, new_balance + 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: Normalize balance changes after all withdrawals - # Filters out net-zero changes - - normalize_balance_changes_for_transaction( - block_env.block_state_changes, - BlockAccessIndex( - get_block_access_index(block_env.block_state_changes) - ), - block_env.state, - ) + # EIP-7928: Filter net-zero balance changes for withdrawals + filter_net_zero_frame_changes(block_env.state_changes, block_env.state) 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 c1d331942a..fcf12e971b 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -21,17 +21,9 @@ from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U256, Uint from .fork_types import EMPTY_ACCOUNT, Account, Address, Root -from .state_tracker import ( - StateChanges, - capture_pre_balance, - track_address, - track_balance_change, - track_code_change, - track_nonce_change, -) from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set if TYPE_CHECKING: @@ -517,18 +509,22 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - state_changes: StateChanges, ) -> None: """ Move funds between accounts. - """ - sender_balance = get_account(state, sender_address).balance - recipient_balance = get_account(state, recipient_address).balance - track_address(state_changes, sender_address) - capture_pre_balance(state_changes, sender_address, sender_balance) - track_address(state_changes, recipient_address) - capture_pre_balance(state_changes, recipient_address, recipient_balance) + 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: if sender.balance < amount: @@ -541,23 +537,8 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - sender_new_balance = get_account(state, sender_address).balance - recipient_new_balance = get_account(state, recipient_address).balance - - track_balance_change( - state_changes, sender_address, U256(sender_new_balance) - ) - track_balance_change( - state_changes, recipient_address, U256(recipient_new_balance) - ) - -def set_account_balance( - state: State, - address: Address, - amount: U256, - state_changes: StateChanges, -) -> None: +def set_account_balance(state: State, address: Address, amount: U256) -> None: """ Sets the balance of an account. @@ -567,32 +548,20 @@ def set_account_balance( 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. - state_changes: - State changes frame for tracking (EIP-7928). - """ - current_balance = get_account(state, address).balance - - track_address(state_changes, address) - capture_pre_balance(state_changes, address, current_balance) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - track_balance_change(state_changes, address, amount) -def increment_nonce( - state: State, - address: Address, - state_changes: "StateChanges", -) -> None: +def increment_nonce(state: State, address: Address) -> None: """ Increments the nonce of an account. @@ -604,9 +573,6 @@ def increment_nonce( address: Address of the account whose nonce needs to be incremented. - state_changes: - State changes frame for tracking (EIP-7928). - """ def increase_nonce(sender: Account) -> None: @@ -614,16 +580,8 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - account = get_account(state, address) - track_nonce_change(state_changes, address, U64(account.nonce)) - -def set_code( - state: State, - address: Address, - code: Bytes, - state_changes: StateChanges, -) -> None: +def set_code(state: State, address: Address, code: Bytes) -> None: """ Sets Account code. @@ -638,9 +596,6 @@ def set_code( code: The bytecode that needs to be set. - state_changes: - State changes frame for tracking (EIP-7928). - """ def write_code(sender: Account) -> None: @@ -648,27 +603,13 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track code change if it's not net-zero within this frame - # Compare against pre-code captured in this frame, default to b"" - pre_code = state_changes.pre_code.get(address, b"") - if pre_code != code: - track_code_change(state_changes, address, code) - -def set_authority_code( - state: State, - address: Address, - code: Bytes, - state_changes: StateChanges, - current_code: Bytes, -) -> None: +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. Unlike set_code(), it tracks changes based - on the current code rather than pre_code to handle multiple authorizations - to the same address within a single transaction correctly. + EIP-7702 Set Code Transactions. Parameters ---------- @@ -681,13 +622,6 @@ def set_authority_code( code: The delegation designation bytecode to set. - state_changes: - State changes frame for tracking (EIP-7928). - - current_code: - The current code before this change. Used to determine if tracking - is needed (only track if code actually changes from current value). - """ def write_code(sender: Account) -> None: @@ -695,12 +629,6 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track if code is actually changing from current value - # This allows multiple auths to same address to be tracked individually - # Net-zero filtering happens in commit_transaction_frame - if current_code != code: - track_code_change(state_changes, address, code) - def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: """ diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 7b98396318..19a929d0dd 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -1,21 +1,12 @@ """ -Hierarchical state change tracking for EIP-7928 Block Access Lists. +EIP-7928 Block Access Lists: Hierarchical State Change Tracking. -Implements a frame-based hierarchy: Block → Transaction → Call frames. -Each frame tracks state changes and merges upward on completion: -- Success: merge all changes (reads + writes) -- Failure: merge only reads (writes discarded) +Frame hierarchy mirrors EVM execution: Block -> Transaction -> Call frames. +Each frame tracks state accesses and merges to parent on completion. -Frame Hierarchy: - Block Frame: Root, lifetime = entire block, index 0..N+1 - Transaction Frame: Child of block, lifetime = single transaction - Call Frame: Child of transaction/call, lifetime = single message - -Block Access Index: 0=pre-exec, 1..N=transactions, N+1=post-exec -Stored in root frame, passed explicitly to operations. - -Pre-State Tracking: Values captured before modifications to enable -net-zero filtering. +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 """ @@ -38,12 +29,13 @@ class StateChanges: """ Tracks state changes within a single execution frame. - Frames form a hierarchy: Block → Transaction → Call frames. - Each frame holds a reference to its parent for upward traversal. + 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) + block_access_index: BlockAccessIndex = BlockAccessIndex(0) touched_addresses: Set[Address] = field(default_factory=set) storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) @@ -61,7 +53,7 @@ class StateChanges: default_factory=dict ) - # Pre-state captures for net-zero filtering + # Pre-state captures (transaction-scoped, only populated at tx frame) pre_balances: Dict[Address, U256] = field(default_factory=dict) pre_nonces: Dict[Address, U64] = field(default_factory=dict) pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( @@ -72,17 +64,17 @@ class StateChanges: def get_block_frame(state_changes: StateChanges) -> StateChanges: """ - Walk to block-level frame. + Walk to the root (block-level) frame. Parameters ---------- state_changes : - Any state changes frame. + Any frame in the hierarchy. Returns ------- block_frame : StateChanges - The block-level frame. + The root block-level frame. """ block_frame = state_changes @@ -91,125 +83,128 @@ def get_block_frame(state_changes: StateChanges) -> StateChanges: return block_frame -def get_block_access_index(root_frame: StateChanges) -> BlockAccessIndex: +def increment_block_access_index(root_frame: StateChanges) -> None: """ - Get current block access index from root frame. + Increment the block access index in the root frame. Parameters ---------- root_frame : - The root (block-level) state changes frame. - - Returns - ------- - index : BlockAccessIndex - The current block access index. + The root block-level frame. """ - return root_frame._block_access_index + root_frame.block_access_index = BlockAccessIndex( + root_frame.block_access_index + Uint(1) + ) -def increment_block_access_index(root_frame: StateChanges) -> None: +def get_transaction_frame(state_changes: StateChanges) -> StateChanges: """ - Increment block access index in root frame. + Walk to the transaction-level frame (child of block frame). Parameters ---------- - root_frame : - The root (block-level) state changes frame to increment. + state_changes : + Any frame in the hierarchy. + + Returns + ------- + tx_frame : StateChanges + The transaction-level frame. """ - root_frame._block_access_index = BlockAccessIndex( - root_frame._block_access_index + Uint(1) - ) + 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( - state_changes: StateChanges, address: Address, balance: U256 + tx_frame: StateChanges, address: Address, balance: U256 ) -> None: """ - Capture pre-balance (first-write-wins for net-zero filtering). + Capture pre-balance if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose balance is being captured. + The address whose balance to capture. balance : - The balance value before modification. + The current balance value. """ - if address not in state_changes.pre_balances: - state_changes.pre_balances[address] = balance + if address not in tx_frame.pre_balances: + tx_frame.pre_balances[address] = balance def capture_pre_nonce( - state_changes: StateChanges, address: Address, nonce: U64 + tx_frame: StateChanges, address: Address, nonce: U64 ) -> None: """ - Capture pre-nonce (first-write-wins). + Capture pre-nonce if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose nonce is being captured. + The address whose nonce to capture. nonce : - The nonce value before modification. + The current nonce value. """ - if address not in state_changes.pre_nonces: - state_changes.pre_nonces[address] = nonce + if address not in tx_frame.pre_nonces: + tx_frame.pre_nonces[address] = nonce def capture_pre_storage( - state_changes: StateChanges, address: Address, key: Bytes32, value: U256 + tx_frame: StateChanges, address: Address, key: Bytes32, value: U256 ) -> None: """ - Capture pre-storage (first-write-wins for noop filtering). + Capture pre-storage value if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose storage is being captured. + The address whose storage to capture. key : The storage key. value : - The storage value before modification. + The current storage value. """ slot = (address, key) - if slot not in state_changes.pre_storage: - state_changes.pre_storage[slot] = value + if slot not in tx_frame.pre_storage: + tx_frame.pre_storage[slot] = value def capture_pre_code( - state_changes: StateChanges, address: Address, code: Bytes + tx_frame: StateChanges, address: Address, code: Bytes ) -> None: """ - Capture pre-code (first-write-wins). + Capture pre-code if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose code is being captured. + The address whose code to capture. code : - The code value before modification. + The current code value. """ - if address not in state_changes.pre_code: - state_changes.pre_code[address] = code + if address not in tx_frame.pre_code: + tx_frame.pre_code[address] = code def track_address(state_changes: StateChanges, address: Address) -> None: """ - Track that an address was accessed. + Record that an address was accessed. Parameters ---------- @@ -226,7 +221,7 @@ def track_storage_read( state_changes: StateChanges, address: Address, key: Bytes32 ) -> None: """ - Track a storage read operation. + Record a storage read operation. Parameters ---------- @@ -248,7 +243,7 @@ def track_storage_write( value: U256, ) -> None: """ - Track a storage write operation with block access index. + Record a storage write keyed by (address, key, block_access_index). Parameters ---------- @@ -262,10 +257,8 @@ def track_storage_write( The new storage value. """ - block_frame = get_block_frame(state_changes) - state_changes.storage_writes[ - (address, key, get_block_access_index(block_frame)) - ] = value + idx = state_changes.block_access_index + state_changes.storage_writes[(address, key, idx)] = value def track_balance_change( @@ -274,7 +267,7 @@ def track_balance_change( new_balance: U256, ) -> None: """ - Track balance change keyed by (address, index). + Record a balance change keyed by (address, block_access_index). Parameters ---------- @@ -286,10 +279,8 @@ def track_balance_change( The new balance value. """ - block_frame = get_block_frame(state_changes) - state_changes.balance_changes[ - (address, get_block_access_index(block_frame)) - ] = new_balance + idx = state_changes.block_access_index + state_changes.balance_changes[(address, idx)] = new_balance def track_nonce_change( @@ -298,7 +289,7 @@ def track_nonce_change( new_nonce: U64, ) -> None: """ - Track a nonce change. + Record a nonce change as (address, block_access_index, new_nonce). Parameters ---------- @@ -310,10 +301,8 @@ def track_nonce_change( The new nonce value. """ - block_frame = get_block_frame(state_changes) - state_changes.nonce_changes.add( - (address, get_block_access_index(block_frame), new_nonce) - ) + idx = state_changes.block_access_index + state_changes.nonce_changes.add((address, idx, new_nonce)) def track_code_change( @@ -322,7 +311,7 @@ def track_code_change( new_code: Bytes, ) -> None: """ - Track a code change. + Record a code change keyed by (address, block_access_index). Parameters ---------- @@ -334,19 +323,55 @@ def track_code_change( The new code value. """ - block_frame = get_block_frame(state_changes) - state_changes.code_changes[ - (address, get_block_access_index(block_frame)) - ] = new_code + idx = state_changes.block_access_index + state_changes.code_changes[(address, idx)] = new_code + + +def track_selfdestruct( + state_changes: 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 + ---------- + state_changes : + The state changes tracker. + address : + The address that self-destructed. + + """ + idx = state_changes.block_access_index + + # Remove nonce changes from current transaction + state_changes.nonce_changes = { + (addr, i, nonce) + for addr, i, nonce in state_changes.nonce_changes + if not (addr == address and i == idx) + } + + # Remove code changes from current transaction + if (address, idx) in state_changes.code_changes: + del state_changes.code_changes[(address, idx)] + + # Convert storage writes from current transaction to reads + for addr, key, i in list(state_changes.storage_writes.keys()): + if addr == address and i == idx: + del state_changes.storage_writes[(addr, key, i)] + state_changes.storage_reads.add((addr, key)) def merge_on_success(child_frame: StateChanges) -> None: """ - Merge child frame's changes into parent on successful completion. + Merge child frame into parent on success. - Merges all tracked changes (reads and writes) from the child frame - into the parent frame. Filters out net-zero changes based on - captured pre-state values by comparing initial vs final values. + Child values overwrite parent values (most recent wins). No net-zero + filtering here - that happens once at transaction commit via + normalize_transaction(). Parameters ---------- @@ -356,50 +381,20 @@ def merge_on_success(child_frame: StateChanges) -> None: """ 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 pre-state captures for transaction-level normalization - # Only if parent doesn't have value (first capture wins) - for addr, balance in child_frame.pre_balances.items(): - if addr not in parent_frame.pre_balances: - parent_frame.pre_balances[addr] = balance - for addr, nonce in child_frame.pre_nonces.items(): - if addr not in parent_frame.pre_nonces: - parent_frame.pre_nonces[addr] = nonce - for slot, value in child_frame.pre_storage.items(): - if slot not in parent_frame.pre_storage: - parent_frame.pre_storage[slot] = value - for addr, code in child_frame.pre_code.items(): - if addr not in parent_frame.pre_code: - capture_pre_code(parent_frame, addr, code) - - # Merge storage operations, filtering noop writes + # Merge storage: reads union, writes overwrite (child supersedes parent) parent_frame.storage_reads.update(child_frame.storage_reads) - for (addr, key, idx), value in child_frame.storage_writes.items(): - # Only merge if value actually changed from pre-state - if (addr, key) in child_frame.pre_storage: - if child_frame.pre_storage[(addr, key)] != value: - parent_frame.storage_writes[(addr, key, idx)] = value - # If equal, it's a noop write - convert to read only - else: - parent_frame.storage_reads.add((addr, key)) - else: - # No pre-state captured, merge as-is - parent_frame.storage_writes[(addr, key, idx)] = value - - # Merge balance changes - filter net-zero changes - # balance_changes keyed by (address, index) - for (addr, idx), final_balance in child_frame.balance_changes.items(): - if addr in child_frame.pre_balances: - if child_frame.pre_balances[addr] != final_balance: - parent_frame.balance_changes[(addr, idx)] = final_balance - # else: Net-zero change - skip entirely - else: - # No pre-balance captured, merge as-is - parent_frame.balance_changes[(addr, idx)] = final_balance - - # Merge nonce changes - keep only highest nonce per address + 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 ( @@ -407,31 +402,46 @@ def merge_on_success(child_frame: StateChanges) -> None: or nonce > address_final_nonces[addr][1] ): address_final_nonces[addr] = (idx, nonce) - - # Merge final nonces (no net-zero filtering - nonces never decrease) for addr, (idx, final_nonce) in address_final_nonces.items(): parent_frame.nonce_changes.add((addr, idx, final_nonce)) - # Merge code changes - filter net-zero changes - # code_changes keyed by (address, index) - for (addr, idx), final_code in child_frame.code_changes.items(): - pre_code = child_frame.pre_code.get(addr, b"") - if pre_code != final_code: - parent_frame.code_changes[(addr, idx)] = final_code - # else: Net-zero change - skip entirely + # 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 commit_transaction_frame(tx_frame: StateChanges) -> None: +def merge_on_failure(child_frame: StateChanges) -> None: """ - Commit a transaction frame's changes to the block frame. + 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 + - Merges ALL changes from the transaction frame into the block frame - without net-zero filtering. Each transaction's changes are recorded - at their respective transaction index, even if a later transaction - reverts a change back to its original value. +def commit_transaction_frame(tx_frame: StateChanges) -> None: + """ + Commit transaction frame to block frame. - This is different from merge_on_success() which filters net-zero - changes within a single transaction's execution. + Unlike merge_on_success(), this merges ALL changes without net-zero + filtering (each tx's changes recorded at their respective index). Parameters ---------- @@ -458,46 +468,17 @@ def commit_transaction_frame(tx_frame: StateChanges) -> None: for addr, idx, nonce in tx_frame.nonce_changes: block_frame.nonce_changes.add((addr, idx, nonce)) - # Merge code changes - filter net-zero changes within the transaction - # Compare final code against transaction's pre-code + # Merge code changes (net-zero filtering done in normalize_transaction) for (addr, idx), final_code in tx_frame.code_changes.items(): - pre_code = tx_frame.pre_code.get(addr, b"") - if pre_code != final_code: - block_frame.code_changes[(addr, idx)] = final_code - # else: Net-zero change within this transaction - skip - - -def merge_on_failure(child_frame: StateChanges) -> None: - """ - Merge child frame's changes into parent on failed completion. - - Merges only read operations from the child frame into the parent. - Write operations are discarded since the frame reverted. - This is called when a call frame fails/reverts. - - 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 + block_frame.code_changes[(addr, idx)] = final_code def create_child_frame(parent: StateChanges) -> StateChanges: """ - Create a child frame for nested execution. + 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 ---------- @@ -507,99 +488,69 @@ def create_child_frame(parent: StateChanges) -> StateChanges: Returns ------- child : StateChanges - A new child frame with parent reference set. + A new child frame with parent reference and inherited + block_access_index. """ - return StateChanges(parent=parent) - - -def handle_in_transaction_selfdestruct( - state_changes: StateChanges, - address: Address, - current_block_access_index: BlockAccessIndex, -) -> None: - """ - Handle account self-destructed in same transaction as creation. - - Per EIP-7928 and EIP-6780, accounts destroyed within their creation - transaction must have: - - Nonce changes from current transaction removed - - Code changes from current transaction removed - - Storage writes from current transaction converted to reads - - Balance changes handled by net-zero filtering - - Parameters - ---------- - state_changes : StateChanges - The state changes tracker (typically the block-level frame). - address : Address - The address that self-destructed. - current_block_access_index : BlockAccessIndex - The current block access index (transaction index). - - """ - # Remove nonce changes from current transaction - state_changes.nonce_changes = { - (addr, idx, nonce) - for addr, idx, nonce in state_changes.nonce_changes - if not (addr == address and idx == current_block_access_index) - } - - # Remove code changes from current transaction - if (address, current_block_access_index) in state_changes.code_changes: - del state_changes.code_changes[(address, current_block_access_index)] - - # Convert storage writes from current transaction to reads - for addr, key, idx in list(state_changes.storage_writes.keys()): - if addr == address and idx == current_block_access_index: - del state_changes.storage_writes[(addr, key, idx)] - state_changes.storage_reads.add((addr, key)) + return StateChanges( + parent=parent, + block_access_index=parent.block_access_index, + ) -def normalize_balance_changes_for_transaction( - block_frame: StateChanges, - current_block_access_index: BlockAccessIndex, +def filter_net_zero_frame_changes( + tx_frame: StateChanges, state: "State", ) -> None: """ - Normalize balance changes for the current transaction. + Filter net-zero changes from transaction frame before commit. - Removes balance changes where post-transaction balance equals - pre-transaction balance. This handles net-zero transfers across - the entire transaction. - - This function should be called after merging transaction frames - into the block frame to filter out addresses where balance didn't - actually change from transaction start to transaction end. + 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 ---------- - block_frame : StateChanges - The block-level state changes frame. - current_block_access_index : BlockAccessIndex - The current transaction's block access index. - state : State - The current state to read final balances from. + tx_frame : + The transaction-level state changes frame. + state : + The current state to read final values from. """ # Import locally to avoid circular import from .state import get_account - # Collect addresses that have balance changes in this transaction + idx = tx_frame.block_access_index + + # Filter storage: compare against pre_storage, convert net-zero to reads + for addr, key, i in list(tx_frame.storage_writes.keys()): + if i != idx: + continue + final_value = tx_frame.storage_writes[(addr, key, i)] + if (addr, key) in tx_frame.pre_storage: + if tx_frame.pre_storage[(addr, key)] == final_value: + # Net-zero write - convert to read + del tx_frame.storage_writes[(addr, key, i)] + tx_frame.storage_reads.add((addr, key)) + + # Filter balance: compare pre vs post, remove if equal addresses_to_check = [ - addr - for (addr, idx) in block_frame.balance_changes.keys() - if idx == current_block_access_index + addr for (addr, i) in tx_frame.balance_changes.keys() if i == idx ] - - # For each address, compare pre vs post balance for addr in addresses_to_check: - if addr in block_frame.pre_balances: - pre_balance = block_frame.pre_balances[addr] + if addr in tx_frame.pre_balances: + pre_balance = tx_frame.pre_balances[addr] post_balance = get_account(state, addr).balance - if pre_balance == post_balance: - # Remove balance change for this address - net-zero transfer - del block_frame.balance_changes[ - (addr, current_block_access_index) - ] + del tx_frame.balance_changes[(addr, idx)] + + # Filter code: compare pre vs post, remove if equal + for addr, i in list(tx_frame.code_changes.keys()): + if i != idx: + continue + final_code = tx_frame.code_changes[(addr, i)] + pre_code = tx_frame.pre_code.get(addr, b"") + if pre_code == final_code: + del tx_frame.code_changes[(addr, i)] + + # 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 def5b36e20..130532fef6 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -17,7 +17,7 @@ from ..fork_types import Address from ..state import get_account -from ..state_tracker import StateChanges +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 @@ -28,7 +28,6 @@ def prepare_message( block_env: BlockEnvironment, tx_env: TransactionEnvironment, tx: Transaction, - transaction_state_changes: StateChanges, ) -> Message: """ Execute a transaction against the provided environment. @@ -41,8 +40,6 @@ def prepare_message( Environment for the transaction. tx : Transaction to be executed. - transaction_state_changes : - State changes specific to this transaction. Returns ------- @@ -73,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, @@ -91,5 +91,6 @@ def prepare_message( accessed_storage_keys=set(tx_env.access_list_storage_keys), disable_precompiles=False, parent_evm=None, - transaction_state_changes=transaction_state_changes, + 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 d414aa50f9..6726880fcb 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -49,9 +49,7 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 - block_state_changes: StateChanges = field( - default_factory=lambda: StateChanges() - ) + state_changes: StateChanges @dataclass @@ -117,6 +115,7 @@ class TransactionEnvironment: authorizations: Tuple[Authorization, ...] index_in_block: Optional[Uint] tx_hash: Optional[Hash32] + state_changes: "StateChanges" = field(default_factory=StateChanges) @dataclass @@ -142,7 +141,8 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] - transaction_state_changes: StateChanges + is_create: bool + state_changes: "StateChanges" = field(default_factory=StateChanges) @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index ec95fd1a47..1f1aac9d97 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,7 +12,6 @@ from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError -# track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization from ..state import ( account_exists, @@ -20,7 +19,12 @@ increment_nonce, set_authority_code, ) -from ..state_tracker import capture_pre_code, track_address +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 @@ -190,11 +194,9 @@ def read_delegation_target(evm: Evm, delegated_address: Address) -> Bytes: """ state = evm.message.block_env.state - # Add to accessed addresses for warm/cold gas accounting if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) - # Track the address for BAL track_address(evm.state_changes, delegated_address) return get_account(state, delegated_address).code @@ -236,7 +238,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - track_address(message.block_env.block_state_changes, authority) + track_address(message.tx_env.state_changes, authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -253,22 +255,19 @@ def set_delegation(message: Message) -> U256: else: code_to_set = EOA_DELEGATION_MARKER + auth.address - state_changes = ( - message.transaction_state_changes - or message.block_env.block_state_changes - ) + tx_frame = message.tx_env.state_changes + # EIP-7928: Capture pre-code before any changes + capture_pre_code(tx_frame, authority, authority_code) - # Capture pre-code before any changes (first-write-wins) - capture_pre_code(state_changes, authority, authority_code) + set_authority_code(state, authority, code_to_set) - # Set delegation code - # Uses authority_code (current) for tracking to handle multiple auths - # Net-zero filtering happens in commit_transaction_frame - set_authority_code( - state, authority, code_to_set, state_changes, authority_code - ) + 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, state_changes) + 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/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 8edff23534..de7ef935f5 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -26,7 +26,7 @@ track_storage_write, ) from .. import Evm -from ..exceptions import OutOfGasError, WriteInStaticContext +from ..exceptions import WriteInStaticContext from ..gas import ( GAS_CALL_STIPEND, GAS_COLD_SLOAD, @@ -55,25 +55,26 @@ def sload(evm: Evm) -> None: key = pop(evm.stack).to_be_bytes32() # GAS - gas_cost = ( - GAS_WARM_ACCESS - if (evm.message.current_target, key) in evm.accessed_storage_keys - else GAS_COLD_SLOAD - ) - check_gas(evm, gas_cost) - if (evm.message.current_target, key) not in evm.accessed_storage_keys: - evm.accessed_storage_keys.add((evm.message.current_target, key)) - track_storage_read( - evm.state_changes, + is_cold_access = ( evm.message.current_target, key, - ) + ) not in evm.accessed_storage_keys + gas_cost = GAS_COLD_SLOAD if is_cold_access else GAS_WARM_ACCESS + charge_gas(evm, gas_cost) # OPERATION state = evm.message.block_env.state value = get_storage(state, evm.message.current_target, key) + if is_cold_access: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + track_storage_read( + evm.state_changes, + evm.message.current_target, + key, + ) + push(evm.stack, value) # PROGRAM COUNTER @@ -93,19 +94,14 @@ def sstore(evm: Evm) -> None: # STACK key = pop(evm.stack).to_be_bytes32() new_value = pop(evm.stack) - if evm.gas_left <= GAS_CALL_STIPEND: - raise OutOfGasError - # Check static context before accessing storage + # check we have at least the stipend gas + check_gas(evm, GAS_CALL_STIPEND + Uint(1)) + + # check static context before accessing storage if evm.message.is_static: raise WriteInStaticContext - state = evm.message.block_env.state - original_value = get_storage_original( - state, evm.message.current_target, key - ) - current_value = get_storage(state, evm.message.current_target, key) - # GAS gas_cost = Uint(0) is_cold_access = ( @@ -116,28 +112,34 @@ def sstore(evm: Evm) -> None: if is_cold_access: gas_cost += GAS_COLD_SLOAD - if original_value == current_value and current_value != new_value: - if original_value == 0: - gas_cost += GAS_STORAGE_SET - else: - gas_cost += GAS_STORAGE_UPDATE - GAS_COLD_SLOAD - else: - gas_cost += GAS_WARM_ACCESS + state = evm.message.block_env.state + original_value = get_storage_original( + state, evm.message.current_target, key + ) + current_value = get_storage(state, evm.message.current_target, key) + + if is_cold_access: + evm.accessed_storage_keys.add((evm.message.current_target, key)) - # Track storage access BEFORE checking gas (EIP-7928) - # Even if we run out of gas, the access attempt should be tracked capture_pre_storage( - evm.state_changes, evm.message.current_target, key, current_value + evm.message.tx_env.state_changes, + evm.message.current_target, + key, + current_value, ) track_storage_read( evm.state_changes, evm.message.current_target, key, ) - check_gas(evm, gas_cost) - if is_cold_access: - evm.accessed_storage_keys.add((evm.message.current_target, key)) + if original_value == current_value and current_value != new_value: + if original_value == 0: + gas_cost += GAS_STORAGE_SET + else: + gas_cost += GAS_STORAGE_UPDATE - GAS_COLD_SLOAD + else: + gas_cost += GAS_WARM_ACCESS charge_gas(evm, gas_cost) @@ -165,7 +167,10 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) track_storage_write( - evm.state_changes, evm.message.current_target, key, new_value + evm.state_changes, + evm.message.current_target, + key, + new_value, ) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 288594bfe7..e5e2ec306f 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 @@ -27,7 +27,13 @@ move_ether, set_account_balance, ) -from ...state_tracker import capture_pre_balance, track_address +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, @@ -120,11 +126,27 @@ def generic_create( if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): - increment_nonce(state, evm.message.current_target, evm.state_changes) + 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(state, evm.message.current_target, evm.state_changes) + # 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, @@ -144,7 +166,8 @@ def generic_create( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=False, parent_evm=evm, - transaction_state_changes=evm.message.transaction_state_changes, + is_create=True, + state_changes=child_state_changes, ) child_evm = process_create_message(child_message) @@ -321,8 +344,9 @@ def generic_call( evm.memory, memory_input_start_position, memory_input_size ) - # EIP-7928: Child message inherits transaction_state_changes from parent - # The actual child frame will be created automatically in process_message + # 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, @@ -341,7 +365,8 @@ def generic_call( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=disable_precompiles, parent_evm=evm, - transaction_state_changes=evm.message.transaction_state_changes, + is_create=False, + state_changes=child_state_changes, ) child_evm = process_message(child_message) @@ -570,11 +595,13 @@ def callcode(evm: Evm) -> None: ).balance # EIP-7928: For CALLCODE with value transfer, capture pre-balance - # in parent frame. CALLCODE transfers value from/to current_target + # 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.state_changes, evm.message.current_target, sender_balance + evm.message.tx_env.state_changes, + evm.message.current_target, + sender_balance, ) if sender_balance < value: @@ -643,25 +670,41 @@ def selfdestruct(evm: Evm) -> None: charge_gas(evm, gas_cost) + 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 + + # 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) - move_ether( - evm.message.block_env.state, + # 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, - beneficiary, - originator_balance, + originator_new_balance, + ) + track_balance_change( evm.state_changes, + beneficiary, + 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: - set_account_balance( - evm.message.block_env.state, originator, U256(0), evm.state_changes - ) + if originator in state.created_accounts: + 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 diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 893a0d8833..154c56de11 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 ( @@ -46,10 +46,13 @@ ) from ..state_tracker import ( StateChanges, - create_child_frame, + capture_pre_balance, 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 @@ -73,61 +76,6 @@ MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE -def get_parent_frame(message: Message) -> StateChanges: - """ - Get the appropriate parent frame for a message's state changes. - - Frame selection logic: - - Nested calls: Parent EVM's frame - - Top-level calls: Transaction frame - - System transactions: Block frame - - Parameters - ---------- - message : - The message being processed. - - Returns - ------- - parent_frame : StateChanges - The parent frame to use for creating child frames. - - """ - if message.parent_evm is not None: - return message.parent_evm.state_changes - elif message.transaction_state_changes is not None: - return message.transaction_state_changes - else: - return message.block_env.block_state_changes - - -def get_message_state_frame(message: Message) -> StateChanges: - """ - Determine and create the appropriate state tracking frame for a message. - - Creates a call frame as a child of the appropriate parent frame. - - Parameters - ---------- - message : - The message being processed. - - Returns - ------- - state_frame : StateChanges - The state tracking frame to use for this message execution. - - """ - parent_frame = get_parent_frame(message) - if ( - message.parent_evm is not None - or message.transaction_state_changes is not None - ): - return create_child_frame(parent_frame) - else: - return parent_frame - - @dataclass class MessageCallOutput: """ @@ -173,9 +121,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.transaction_state_changes, message.current_target - ) + track_address(message.tx_env.state_changes, message.current_target) if is_collision: return MessageCallOutput( Uint(0), @@ -197,11 +143,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 - - # EIP-7928: Track delegation target when loaded as call target - track_address( - message.block_env.block_state_changes, delegated_address - ) + track_address(message.block_env.state_changes, delegated_address) evm = process_message(message) @@ -263,11 +205,15 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - parent_frame = get_parent_frame(message) - create_frame = create_child_frame(parent_frame) + 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), + ) - increment_nonce(state, message.current_target, create_frame) - evm = process_message(message, parent_state_frame=create_frame) + evm = process_message(message) if not evm.error: contract_code = evm.output contract_code_gas = Uint(len(contract_code)) * GAS_CODE_DEPOSIT @@ -280,26 +226,28 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) - merge_on_failure(create_frame) + merge_on_failure(message.state_changes) evm.gas_left = Uint(0) evm.output = b"" evm.error = error else: - set_code( - state, message.current_target, contract_code, create_frame - ) + # 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(create_frame) + merge_on_success(message.state_changes) else: rollback_transaction(state, transient_storage) - merge_on_failure(create_frame) + merge_on_failure(message.state_changes) return evm -def process_message( - message: Message, - parent_state_frame: Optional[StateChanges] = None, -) -> Evm: +def process_message(message: Message) -> Evm: """ Move ether and execute the relevant code. @@ -307,12 +255,6 @@ def process_message( ---------- message : Transaction specific items. - parent_state_frame : - Optional parent frame for state tracking. When provided (e.g., for - CREATE's init code), state changes are tracked as a child of this - frame instead of the default parent determined by the message. - This ensures proper frame hierarchy for CREATE operations where - init code changes must be children of the CREATE frame. Returns ------- @@ -325,37 +267,54 @@ def process_message( if message.depth > STACK_DEPTH_LIMIT: raise StackDepthLimitError("Stack depth limit reached") + # take snapshot of state before processing the message begin_transaction(state, transient_storage) - if parent_state_frame is not None: - # Use provided parent for CREATE's init code execution. - # This ensures init code state changes are children of create_frame, - # so they are properly converted to reads if code deposit fails. - parent_changes = parent_state_frame - state_changes = create_child_frame(parent_state_frame) - else: - parent_changes = get_parent_frame(message) - state_changes = get_message_state_frame(message) - - track_address(state_changes, message.current_target) + 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, + state, message.caller, message.current_target, message.value + ) + + 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, - message.value, - state_changes, + U256(recipient_new_balance), ) - evm = execute_code(message, state_changes) + evm = execute_code(message, message.state_changes) if evm.error: rollback_transaction(state, transient_storage) - if state_changes != parent_changes: + if not message.is_create: merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) - if state_changes != parent_changes: + if not message.is_create: merge_on_success(evm.state_changes) return evm diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 2d3f8b9e3c..c029091467 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -16,7 +16,9 @@ from ethereum import trace from ethereum.exceptions import EthereumException, InvalidBlock from ethereum.fork_criteria import ByBlockNumber, ByTimestamp, Unscheduled -from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork +from ethereum_spec_tools.forks import TemporaryHardfork +from ethereum.forks.amsterdam.state_tracker import StateChanges +from ethereum_spec_tools.forks import Hardfork from ..loaders.fixture_loader import Load from ..utils import ( @@ -308,6 +310,9 @@ def block_environment(self) -> Any: ) kw_arguments["excess_blob_gas"] = self.env.excess_blob_gas + if self.fork.is_after_fork("amsterdam"): + kw_arguments["state_changes"] = StateChanges() + return block_environment(**kw_arguments) def backup_state(self) -> None: @@ -417,7 +422,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: increment_block_access_index, ) - increment_block_access_index(block_env.block_state_changes) + increment_block_access_index(block_env.state_changes) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -436,9 +441,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: self.fork.process_general_purpose_requests(block_env, block_output) if self.fork.is_after_fork("amsterdam"): - # Build block access list from block_env.block_state_changes + # Build block access list from block_env.state_changes block_output.block_access_list = self.fork.build_block_access_list( - block_env.block_state_changes + block_env.state_changes ) def run_blockchain_test(self) -> None: From 0ee07219158f789e6215387358d84ee3238fdb78 Mon Sep 17 00:00:00 2001 From: Guruprasad Kamath <48196632+gurukamath@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:40:24 +0100 Subject: [PATCH 42/71] fix(spec,tests): Change BAL to List[AccountChange] (#1844) * fix(spec): update BAL type * fix(tests): run amsterdam jsons * fix(test-specs): Add BAL to genesis beyond Amsterdam fork --------- Co-authored-by: fselmo --- .../src/execution_testing/specs/blockchain.py | 5 +++++ .../amsterdam/block_access_lists/builder.py | 8 ++++---- .../amsterdam/block_access_lists/rlp_types.py | 13 ++----------- .../amsterdam/block_access_lists/rlp_utils.py | 10 ++++------ src/ethereum/forks/amsterdam/vm/__init__.py | 4 +--- .../evm_tools/loaders/fixture_loader.py | 4 ++++ .../evm_tools/t8n/t8n_types.py | 18 ++++++------------ tests/json_infra/__init__.py | 6 +++++- .../helpers/load_blockchain_tests.py | 3 +++ 9 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index e08738c1ce..207b4a953f 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -551,11 +551,16 @@ def make_genesis( state_root = pre_alloc.state_root() genesis = FixtureHeader.genesis(self.fork, env, state_root) + genesis_bal = None + if self.fork.header_bal_hash_required(block_number=0, timestamp=0): + genesis_bal = BlockAccessList() + return ( pre_alloc, FixtureBlockBase( header=genesis, withdrawals=None if env.withdrawals is None else [], + block_access_list=genesis_bal, ).with_rlp(txs=[]), ) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index c1dbf98222..e860c84068 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -405,7 +405,7 @@ def _build_from_builder( [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 """ - account_changes_list = [] + block_access_list: BlockAccessList = [] for address, changes in builder.accounts.items(): storage_changes = [] @@ -444,11 +444,11 @@ def _build_from_builder( code_changes=code_changes, ) - account_changes_list.append(account_change) + block_access_list.append(account_change) - account_changes_list.sort(key=lambda x: x.address) + block_access_list.sort(key=lambda x: x.address) - return BlockAccessList(account_changes=tuple(account_changes_list)) + return block_access_list def build_block_access_list( diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py index e4d37d6a74..c4f49ff4aa 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py @@ -8,7 +8,7 @@ """ from dataclasses import dataclass -from typing import Tuple +from typing import List, Tuple from ethereum_types.bytes import Bytes, Bytes20, Bytes32 from ethereum_types.frozen import slotted_freezable @@ -118,13 +118,4 @@ class AccountChanges: code_changes: Tuple[CodeChange, ...] -@slotted_freezable -@dataclass -class BlockAccessList: - """ - Block-Level Access List for EIP-7928. - Contains all addresses accessed during block execution. - RLP encoded as a list of AccountChanges. - """ - - account_changes: Tuple[AccountChanges, ...] +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 index 738abce181..2cd5b827f3 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -71,7 +71,7 @@ def rlp_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes: """ # Encode as a list of AccountChanges directly (not wrapped) account_changes_list = [] - for account in block_access_list.account_changes: + for account in block_access_list: # Each account is encoded as: # [address, storage_changes, storage_reads, # balance_changes, nonce_changes, code_changes] @@ -146,7 +146,7 @@ def validate_block_access_list_against_execution( # 1. Validate structural constraints # Check that storage changes and reads don't overlap for the same slot - for account in block_access_list.account_changes: + for account in block_access_list: changed_slots = {sc.slot for sc in account.storage_changes} read_slots = set(account.storage_reads) @@ -155,9 +155,7 @@ def validate_block_access_list_against_execution( return False # 2. Validate ordering (addresses should be sorted lexicographically) - addresses = [ - account.address for account in block_access_list.account_changes - ] + addresses = [account.address for account in block_access_list] if addresses != sorted(addresses): return False @@ -165,7 +163,7 @@ def validate_block_access_list_against_execution( max_block_access_index = ( MAX_TXS + 1 ) # 0 for pre-exec, 1..MAX_TXS for txs, MAX_TXS+1 for post-exec - for account in block_access_list.account_changes: + for account in block_access_list: # Validate storage slots are sorted within each account storage_slots = [sc.slot for sc in account.storage_changes] if storage_slots != sorted(storage_slots): diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 6726880fcb..6c47b50acf 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -94,9 +94,7 @@ class BlockOutput: ) blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) - block_access_list: BlockAccessList = field( - default_factory=lambda: BlockAccessList(account_changes=()) - ) + block_access_list: BlockAccessList = field(default_factory=list) @dataclass 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/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 1aac5e4b3f..cb13727f0d 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -333,18 +333,17 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: ) ) - def _block_access_list_to_json(self, bal: Any) -> Any: + @staticmethod + def _block_access_list_to_json(account_changes: Any) -> Any: """ Convert BlockAccessList to JSON format matching the Pydantic models. """ - account_changes = [] - - for account in bal.account_changes: + json_account_changes = [] + for account in account_changes: account_data: Dict[str, Any] = { "address": "0x" + account.address.hex() } - # Add storage changes if present if account.storage_changes: storage_changes = [] for slot_change in account.storage_changes: @@ -364,14 +363,12 @@ def _block_access_list_to_json(self, bal: Any) -> Any: storage_changes.append(slot_data) account_data["storageChanges"] = storage_changes - # Add storage reads if present if account.storage_reads: account_data["storageReads"] = [ int.from_bytes(slot, "big") for slot in account.storage_reads ] - # Add balance changes if present if account.balance_changes: account_data["balanceChanges"] = [ { @@ -381,7 +378,6 @@ def _block_access_list_to_json(self, bal: Any) -> Any: for change in account.balance_changes ] - # Add nonce changes if present if account.nonce_changes: account_data["nonceChanges"] = [ { @@ -391,7 +387,6 @@ def _block_access_list_to_json(self, bal: Any) -> Any: for change in account.nonce_changes ] - # Add code changes if present if account.code_changes: account_data["codeChanges"] = [ { @@ -401,10 +396,9 @@ def _block_access_list_to_json(self, bal: Any) -> Any: for change in account.code_changes ] - account_changes.append(account_data) + json_account_changes.append(account_data) - # return as list directly - return account_changes + return json_account_changes def json_encode_receipts(self) -> Any: """ diff --git a/tests/json_infra/__init__.py b/tests/json_infra/__init__.py index fd2db5553f..6fa7c5dbbe 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%40v1.8.0/fixtures_bal.tar.gz", + "fixture_path": "tests/json_infra/fixtures/amsterdam_tests", + }, } diff --git a/tests/json_infra/helpers/load_blockchain_tests.py b/tests/json_infra/helpers/load_blockchain_tests.py index f390a1c1fd..6e86c48d56 100644 --- a/tests/json_infra/helpers/load_blockchain_tests.py +++ b/tests/json_infra/helpers/load_blockchain_tests.py @@ -138,6 +138,9 @@ def runtest(self) -> None: if hasattr(genesis_header, "requests_root"): parameters.append(()) + if hasattr(genesis_header, "block_access_list_hash"): + parameters.append([]) + genesis_block = load.fork.Block(*parameters) genesis_header_hash = hex_to_bytes( From 1420bdb93c5e4e77225c52655ddf317e6be9c747 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 8 Dec 2025 12:52:56 -0700 Subject: [PATCH 43/71] fix mkdocs --- tests/cancun/create/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/cancun/create/__init__.py 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.""" From 93aa59769de53a2fb32814c677bcd417cfcfcee1 Mon Sep 17 00:00:00 2001 From: Stefan <22667037+qu0b@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:14:15 +0100 Subject: [PATCH 44/71] Qu0b/add bal test cases (#1812) * rebase onto upstream * chore(fix) balance check in call before target access * merge test cases with usptream * merge test cases with usptream * chore(fix) format with ruff * chore(fix) revert call changes and add target to bal * merge test cases with usptream * improve wording * chore(formate) fix formatting and line length * refactor(test-tests): Use pre API where possible; explicit check for none in BAL * refactor(test-tests): Refactor opcode tests to bal opcodes test file --------- Co-authored-by: fselmo --- .../test_block_access_lists.py | 334 ++++++++++ .../test_block_access_lists_eip7702.py | 243 +++++++ .../test_block_access_lists_opcodes.py | 614 ++++++++++++++++++ .../test_cases.md | 17 +- 4 files changed, 1204 insertions(+), 4 deletions(-) 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 50fb9e0ad2..b17935e1e3 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 @@ -8,6 +8,7 @@ Account, Address, Alloc, + AuthorizationTuple, BalAccountExpectation, BalBalanceChange, BalCodeChange, @@ -23,6 +24,7 @@ Header, Op, Transaction, + add_kzg_version, compute_create_address, ) @@ -2145,3 +2147,335 @@ def test_bal_cross_block_ripemd160_state_leak( 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(tx_index=1, post_nonce=1)], + ), + # Type 1 sender + sender_1: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=2, post_nonce=1)], + ), + # Type 2 sender + sender_2: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=3, post_nonce=1)], + ), + # Type 3 sender + sender_3: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=4, post_nonce=1)], + ), + # Type 4 sender + sender_4: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=5, post_nonce=1)], + ), + # Contract touched by Type 0 + contract_0: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x01) + ], + ) + ], + ), + # Contract touched by Type 1 + contract_1: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_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(tx_index=3, post_value=0x03) + ], + ) + ], + ), + # Contract touched by Type 3 + contract_3: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=4, post_value=0x04) + ], + ) + ], + ), + # Alice (Type 4 delegation target, executes oracle code) + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=5, post_nonce=1)], + code_changes=[ + BalCodeChange( + tx_index=5, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_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...01), addr_mid (0x...0100), addr_high (0x01...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...01 (rightmost byte = 0x01) + # addr_mid: 0x00...0100 (second-rightmost byte = 0x01) + # addr_high: 0x01...00 (leftmost byte = 0x01) + addr_low = Address("0x0000000000000000000000000000000000000001") + addr_mid = Address("0x0000000000000000000000000000000000000100") + addr_high = Address("0x0100000000000000000000000000000000000000") + + # 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(tx_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_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index e9bed5df85..a0a6a1e121 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 @@ -828,3 +829,245 @@ def test_bal_7702_double_auth_swap( 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(tx_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + tx_index=1, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + # tx2: balance change from selfdestruct + balance_changes=[ + BalBalanceChange(tx_index=2, post_balance=alice_final_balance) + ], + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + relayer: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1), + BalNonceChange(tx_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(tx_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(tx_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + tx_index=1, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + # tx2 (withdrawal): balance change + balance_changes=[ + BalBalanceChange(tx_index=2, post_balance=alice_final_balance) + ], + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + relayer: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_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_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index bae1544eee..1c12cec89e 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 @@ -1266,3 +1266,617 @@ def test_bal_create_contract_init_revert( 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(tx_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(tx_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(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + # Balance changes: loses endowment (100) + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=factory_balance - endowment, + ) + ], + ), + # Oracle: storage changes for slot 0x01 + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_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(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + # Nonce incremented 1→2 even on failed CREATE2 + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + # Storage changes: slot 0 = 0xDEAD → 0 (CREATE2 returned 0) + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange(tx_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(tx_index=1, post_nonce=1)], + ), + contract: BalAccountExpectation( + # Persistent storage change for slot 0x02 + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange(tx_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_selfdestruct_to_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with SELFDESTRUCT to precompile (ecrecover 0x01). + + Victim (balance=100) selfdestructs to precompile 0x01. + + Expected BAL: + - Victim: balance_changes (100→0) + - Precompile 0x01: balance_changes (0→100), no code/nonce changes + """ + alice = pre.fund_eoa() + + contract_balance = 100 + ecrecover_precompile = Address(1) # 0x0000...0001 + + # Contract that selfdestructs to ecrecover precompile + victim_code = Op.SELFDESTRUCT(ecrecover_precompile) + + victim = pre.deploy_contract(code=victim_code, balance=contract_balance) + + # Caller that triggers the selfdestruct + caller_code = Op.CALL(100_000, victim, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + caller: BalAccountExpectation.empty(), + # Victim (selfdestructing contract): balance changes 100→0 + # Explicitly verify ALL fields to avoid false positives + victim: BalAccountExpectation( + nonce_changes=[], # Contract nonce unchanged + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=0) + ], + code_changes=[], # Code unchanged (post-Cancun) + storage_changes=[], # No storage changes + storage_reads=[], # No storage reads + ), + # Precompile receives selfdestruct balance + # Explicitly verify ALL fields to avoid false positives + ecrecover_precompile: BalAccountExpectation( + nonce_changes=[], # MUST NOT have nonce changes + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=contract_balance + ) + ], + 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), + caller: Account(), + # Victim still exists with 0 balance (post-Cancun SELFDESTRUCT) + victim: Account(balance=0), + # Precompile has received the balance + ecrecover_precompile: Account(balance=contract_balance), + }, + ) + + +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(tx_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(tx_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 9ddca8a362..07d184d957 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -54,10 +54,10 @@ | `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_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_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 | @@ -89,10 +89,19 @@ | `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. | ✅ 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 tx_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at tx_index=2 with `balance_changes` (receives selfdestruct). (3) Victim at tx_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_selfdestruct_to_precompile` | Ensure BAL captures SELFDESTRUCT with precompile as beneficiary | Caller triggers victim contract (balance=100) to execute `SELFDESTRUCT(0x0000...0001)` (ecrecover precompile). Precompile starts with balance=0. | BAL **MUST** include: (1) Contract with `balance_changes` (100→0, loses balance to selfdestruct). (2) Precompile address 0x01 with `balance_changes` (0→100, receives selfdestruct balance). Precompile **MUST NOT** have `code_changes` or `nonce_changes`. This complements `test_bal_withdrawal_to_precompiles` (withdrawal) and `test_bal_precompile_funded` (tx value). | ✅ Completed | +| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ 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 tx_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at tx_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_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_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ Completed | From 4a6a0f4e56468434deaa2766e0e3a70155d15d0a Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 8 Dec 2025 14:40:18 -0700 Subject: [PATCH 45/71] feat(test-tests): BAL test for nested storage write reset same tx (#1854) --- .../test_block_access_lists.py | 92 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 93 insertions(+) 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 b17935e1e3..b9dbcf6244 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 @@ -1935,6 +1935,98 @@ def test_bal_multiple_storage_writes_same_slot( ) +@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(tx_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, 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 07d184d957..2a318f5b63 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -54,6 +54,7 @@ | `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 | From ec4b443af79e8603e35505a2896cc6a51076c089 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 8 Dec 2025 18:36:18 -0700 Subject: [PATCH 46/71] fix(spec-specs): Fix issues after rebasing with forks/osaka --- .../evm_tools/loaders/fork_loader.py | 9 +++++++++ src/ethereum_spec_tools/evm_tools/t8n/__init__.py | 13 ++++--------- src/ethereum_spec_tools/evm_tools/t8n/env.py | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) 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 002bde2e84..9a14efa54c 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -132,6 +132,15 @@ def compute_block_access_list_hash(self) -> Any: "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 c029091467..4988ef25bc 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -16,9 +16,8 @@ from ethereum import trace from ethereum.exceptions import EthereumException, InvalidBlock from ethereum.fork_criteria import ByBlockNumber, ByTimestamp, Unscheduled -from ethereum_spec_tools.forks import TemporaryHardfork from ethereum.forks.amsterdam.state_tracker import StateChanges -from ethereum_spec_tools.forks import Hardfork +from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork from ..loaders.fixture_loader import Load from ..utils import ( @@ -310,7 +309,7 @@ def block_environment(self) -> Any: ) kw_arguments["excess_blob_gas"] = self.env.excess_blob_gas - if self.fork.is_after_fork("amsterdam"): + if self.fork.has_block_access_list_hash: kw_arguments["state_changes"] = StateChanges() return block_environment(**kw_arguments) @@ -377,10 +376,6 @@ def run_state_test(self) -> Any: self.result.rejected = self.txs.rejected_txs def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: - if self.fork.is_after_fork("amsterdam"): - self.fork.set_block_access_index( - block_env.state.change_tracker, Uint(0) - ) if self.fork.has_compute_requests_hash: self.fork.process_unchecked_system_transaction( block_env=block_env, @@ -417,7 +412,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: ) # Post-execution operations use index N+1 - if self.fork.is_after_fork("amsterdam"): + if self.fork.has_block_access_list_hash: from ethereum.forks.amsterdam.state_tracker import ( increment_block_access_index, ) @@ -440,7 +435,7 @@ 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.is_after_fork("amsterdam"): + 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 diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 8cadd58c9f..be719ba7af 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -145,7 +145,7 @@ 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.is_after_fork("amsterdam"): + if t8n.fork.has_block_access_list_hash: arguments["block_access_list_hash"] = Hash32(b"\0" * 32) parent_header = t8n.fork.Header(**arguments) From bde6054b122458b7df9f141ffe8daccf96ee265a Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Tue, 9 Dec 2025 16:07:17 +0000 Subject: [PATCH 47/71] fix(spec-specs): post-exec net-zero filtering post specs refactor --- src/ethereum/forks/amsterdam/fork.py | 3 ++ .../test_block_access_lists_cross_index.py | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 697086e2b4..ce16f2b1b2 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -679,6 +679,9 @@ def process_system_transaction( system_tx_output = process_message_call(system_tx_message) + # EIP-7928: Filter net-zero changes before committing to block frame. + filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) + # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) commit_transaction_frame(tx_env.state_changes) 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 index 920b8bc344..72ac91c923 100644 --- 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 @@ -258,3 +258,57 @@ def test_bal_noop_write_filtering( 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), + }, + ) From 5d7ed8016347c9c81cffd3e8b4a6ad601093f8df Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Tue, 9 Dec 2025 16:56:01 +0000 Subject: [PATCH 48/71] refactor(spec-specs): Move net-zero filtering inside commit tx frame --- src/ethereum/forks/amsterdam/fork.py | 11 +++-------- src/ethereum/forks/amsterdam/state_tracker.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index ce16f2b1b2..7da3ed03ce 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -679,12 +679,9 @@ def process_system_transaction( system_tx_output = process_message_call(system_tx_message) - # EIP-7928: Filter net-zero changes before committing to block frame. - filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) - # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) - commit_transaction_frame(tx_env.state_changes) + commit_transaction_frame(tx_env.state_changes, block_env.state) return system_tx_output @@ -1095,11 +1092,9 @@ def process_transaction( for address in tx_output.accounts_to_delete: destroy_account(block_env.state, address) - # EIP-7928: Filter net-zero changes before committing to block frame. + # EIP-7928: Commit transaction frame (includes net-zero filtering). # Must happen AFTER destroy_account so filtering sees correct state. - filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) - - commit_transaction_frame(tx_env.state_changes) + commit_transaction_frame(tx_env.state_changes, block_env.state) # EIP-7928: Track in-transaction self-destruct normalization AFTER merge # Convert storage writes to reads and remove nonce/code changes diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 19a929d0dd..05461ea89b 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -436,22 +436,30 @@ def merge_on_failure(child_frame: StateChanges) -> None: # merged on failure - they are discarded -def commit_transaction_frame(tx_frame: StateChanges) -> None: +def commit_transaction_frame( + tx_frame: StateChanges, + state: "State", +) -> None: """ Commit transaction frame to block frame. - Unlike merge_on_success(), this merges ALL changes without net-zero - filtering (each tx's changes recorded at their respective index). + 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. + state : + The current state (used for net-zero filtering). """ 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, state) + # Merge address accesses block_frame.touched_addresses.update(tx_frame.touched_addresses) @@ -468,7 +476,7 @@ def commit_transaction_frame(tx_frame: StateChanges) -> None: for addr, idx, nonce in tx_frame.nonce_changes: block_frame.nonce_changes.add((addr, idx, nonce)) - # Merge code changes (net-zero filtering done in normalize_transaction) + # Merge code changes for (addr, idx), final_code in tx_frame.code_changes.items(): block_frame.code_changes[(addr, idx)] = final_code From 29ef9f46d9b6d89be6bae485b0f0436f19f13b02 Mon Sep 17 00:00:00 2001 From: Stefan <22667037+qu0b@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:42:19 +0100 Subject: [PATCH 49/71] Qu0b/fix pre alloc group enginex (#1911) * isolate in test grp --- .../test_block_access_lists.py | 8 ++++++++ .../test_block_access_lists_eip4895.py | 4 ++++ .../test_block_access_lists_opcodes.py | 4 ++++ 3 files changed, 16 insertions(+) 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 b9dbcf6244..f7a4ca2830 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 @@ -1367,6 +1367,10 @@ def test_bal_coinbase_zero_tip( ) +@pytest.mark.pre_alloc_group( + "precompile_funded", + reason="Expects clean precompile balances, isolate in EngineX", +) @pytest.mark.parametrize( "value", [ @@ -2145,6 +2149,10 @@ def test_bal_cross_tx_storage_revert_to_zero( ) +@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, 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 index edb8295c17..16ce09ff90 100644 --- 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 @@ -585,6 +585,10 @@ def test_bal_zero_withdrawal( ) +@pytest.mark.pre_alloc_group( + "withdrawal_to_precompiles", + reason="Expects clean precompile balances, isolate in EngineX", +) @pytest.mark.parametrize_by_fork( "precompile", lambda fork: [ 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 1c12cec89e..448936448e 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 @@ -1698,6 +1698,10 @@ def test_bal_transient_storage_not_tracked( ) +@pytest.mark.pre_alloc_group( + "selfdestruct_to_precompile", + reason="Modifies precompile balance, must be isolated in EngineX format", +) def test_bal_selfdestruct_to_precompile( pre: Alloc, blockchain_test: BlockchainTestFiller, From 21263f7422f96368d1bb3a130ad2168fb723ee01 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 12 Dec 2025 11:40:03 -0700 Subject: [PATCH 50/71] feat(test-tests): Expand BAL CALL opcode OOG boundary test cases (#1882) * feat(test-tests): expand bal call opcode oog boundary tests Parametrize for: - value / no value - COLD / WARM target - 7702 delegation / no delegation - WARM / COLD delegation - mem expansion / no mem expansion Include tests both before and after state access to ensure BAL expectations at these gas boundaries are met. * refactor(tests): Use `fork.memory_expansion_gas_calculator()` * feat(test-tests): Refactor; Add second 7702 boundary at success minus 1 * refactor: changes from comments on PR #1882 * fix: update test names and descriptions after refactor --- .../test_block_access_lists.py | 49 +- .../test_block_access_lists_opcodes.py | 1264 +++++++++++++++-- .../test_cases.md | 14 +- whitelist.txt | 1 + 4 files changed, 1175 insertions(+), 153 deletions(-) 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 f7a4ca2830..32a1052841 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 @@ -286,56 +286,13 @@ def test_bal_account_access_target( blockchain_test(pre=pre, blocks=[block], post={}) -def test_bal_call_with_value_transfer( +def test_bal_callcode_nested_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( - 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) 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 448936448e..9e2355b93e 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 @@ -19,6 +19,7 @@ import pytest from execution_testing import ( + AccessList, Account, Address, Alloc, @@ -57,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", [ @@ -375,159 +406,1184 @@ 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.""" + """ + 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() - bob = pre.fund_eoa() + + target = ( + pre.empty_account() + if target_is_empty + else pre.deploy_contract(code=Op.STOP) + ) + + 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) + + 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=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, + } + elif value > 0: + account_expectations = { + caller: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=0)] + ), + target: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_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() - # 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 + # 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) - call_contract = pre.deploy_contract(code=call_contract_code) + # Access list for warming target (if needed) + access_list = ( + [AccessList(address=target, storage_keys=[])] + if target_is_warm + else None + ) - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list + ) - # 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 + # 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=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()} + # 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( + 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_call_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: + """ + 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. + """ + 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 + + 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 + ) + + 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 + + value_transferred = value > 0 and oog_boundary == OutOfGasBoundary.SUCCESS + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + caller: ( + BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_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(tx_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( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post=post_state, ) + +@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_delegatecall_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: + """ + 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() + + 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, + ) + + caller = pre.deploy_contract(code=delegatecall_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], - post={ - alice: Account(nonce=1), - call_contract: Account(), - }, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={alice: Account(nonce=1)}, ) @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"], ) -def test_bal_delegatecall_and_oog( +@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, - fails_at_delegatecall: bool, + oog_boundary: OutOfGasBoundary, + target_is_warm: bool, + delegation_is_warm: bool, + memory_expansion: bool, ) -> None: """ - Ensure BAL handles DELEGATECALL and OOG during DELEGATECALL - appropriately. + 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() - # 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 + ret_offset = 0 + + delegatecall_code = Op.DELEGATECALL( + gas=0, + address=target, + ret_size=ret_size, + ret_offset=ret_offset, ) - delegatecall_contract = pre.deploy_contract( - code=delegatecall_contract_code + 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 ) - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() + bytecode_cost = gas_costs.G_VERY_LOW * 6 - # 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 + 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=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 + + 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("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], - 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={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)}, ) @@ -609,8 +1665,9 @@ def test_bal_extcodecopy_and_oog( if oog_scenario == "success": # Provide enough gas for everything including memory expansion - words = (memory_offset + copy_size + 31) // 32 - memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) + 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 @@ -626,8 +1683,9 @@ def test_bal_extcodecopy_and_oog( target_in_bal = False elif oog_scenario == "oog_at_memory_boundary": # Calculate memory cost and provide exactly 1 less than needed - words = (memory_offset + copy_size + 31) // 32 - memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) + 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 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 2a318f5b63..77c298110b 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -7,8 +7,10 @@ | `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_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 | @@ -47,8 +49,12 @@ | `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_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 | diff --git a/whitelist.txt b/whitelist.txt index 0d4f493ffc..171afdf61b 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -1293,6 +1293,7 @@ VRS vscode vv +warmup Watcherfall wd wds From 30ab21ddf417c9192b73ab2bb39a1fee374f72cd Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 12 Dec 2025 12:20:44 -0700 Subject: [PATCH 51/71] feat(spec): update eip7928 to latest rlp specs wrt storage; rename tx_index (#1912) * feat(spec-test): Change storage RLP encoding to U256; tx_index rename * fix: unit tests exception message --- .../account_absent_values.py | 21 +- .../block_access_list/account_changes.py | 23 +- .../block_access_list/expectations.py | 23 +- .../test_types/block_access_list/modifiers.py | 73 +++-- .../test_types/block_access_list/t8n.py | 30 +- .../test_block_access_list_expectation.py | 190 ++++++++---- .../tests/test_block_access_list_t8n.py | 107 ++++--- .../amsterdam/block_access_lists/builder.py | 19 +- .../amsterdam/block_access_lists/rlp_types.py | 6 +- .../evm_tools/t8n/t8n_types.py | 17 +- .../test_block_access_lists.py | 286 +++++++++++++----- .../test_block_access_lists_cross_index.py | 24 +- .../test_block_access_lists_eip4895.py | 102 +++++-- .../test_block_access_lists_eip7702.py | 140 ++++++--- .../test_block_access_lists_invalid.py | 88 ++++-- .../test_block_access_lists_opcodes.py | 121 +++++--- .../test_cases.md | 12 +- .../test_create_oog_from_eoa_refunds.py | 12 +- .../test_selfdestruct_revert.py | 16 +- .../test_call_and_callcode_gas_calculation.py | 14 +- tests/prague/eip7702_set_code_tx/test_gas.py | 4 +- 21 files changed, 875 insertions(+), 453 deletions(-) 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 5c9c9537f3..e733362326 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) ], ) ], @@ -173,22 +173,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: @@ -199,11 +200,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 7f6820061f..92179f8e0d 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 @@ -15,7 +15,6 @@ CamelModel, HexNumber, RLPSerializable, - StorageKey, ) @@ -24,7 +23,7 @@ class BalNonceChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - tx_index: HexNumber = Field( + block_access_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", ) @@ -32,7 +31,7 @@ class BalNonceChange(CamelModel, RLPSerializable): ..., 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): @@ -40,7 +39,7 @@ class BalBalanceChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - tx_index: HexNumber = Field( + block_access_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", ) @@ -48,7 +47,7 @@ class BalBalanceChange(CamelModel, RLPSerializable): ..., 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): @@ -56,13 +55,13 @@ class BalCodeChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - tx_index: HexNumber = Field( + block_access_index: HexNumber = Field( HexNumber(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): @@ -70,15 +69,15 @@ class BalStorageChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - tx_index: HexNumber = Field( + block_access_index: HexNumber = Field( HexNumber(1), description="Transaction index where the change occurred", ) - post_value: StorageKey = Field( + post_value: HexNumber = 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): @@ -86,7 +85,7 @@ class BalStorageSlot(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - slot: StorageKey = Field(..., description="Storage slot key") + slot: HexNumber = Field(..., description="Storage slot key") slot_changes: List[BalStorageChange] = Field( default_factory=list, description="List of changes to this slot" ) @@ -112,7 +111,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[HexNumber] = 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 6150dfeabd..2c2eefa6a8 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 @@ -110,7 +110,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 } @@ -326,8 +326,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 ): @@ -361,27 +361,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..d28f7099ba 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 @@ -54,7 +54,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 +85,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 +101,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 +175,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 +205,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 +213,7 @@ def modify_storage( """ return _modify_field_value( address, - tx_index, + block_access_index, "storage_changes", BalStorageChange, value, @@ -216,11 +224,16 @@ 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, + "post_code", ) @@ -242,46 +255,46 @@ 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: + if nonce_change.block_access_index == tx1: nonce_indices[tx1] = True - nonce_change.tx_index = HexNumber(tx2) - elif nonce_change.tx_index == tx2: + nonce_change.block_access_index = HexNumber(tx2) + elif nonce_change.block_access_index == tx2: nonce_indices[tx2] = True - nonce_change.tx_index = HexNumber(tx1) + nonce_change.block_access_index = HexNumber(tx1) # Swap in balance changes if new_account.balance_changes: for balance_change in new_account.balance_changes: - if balance_change.tx_index == tx1: + if balance_change.block_access_index == tx1: balance_indices[tx1] = True - balance_change.tx_index = HexNumber(tx2) - elif balance_change.tx_index == tx2: + balance_change.block_access_index = HexNumber(tx2) + elif balance_change.block_access_index == tx2: balance_indices[tx2] = True - balance_change.tx_index = HexNumber(tx1) + balance_change.block_access_index = HexNumber(tx1) # 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: + if storage_change.block_access_index == tx1: balance_indices[tx1] = True - storage_change.tx_index = HexNumber(tx2) - elif storage_change.tx_index == tx2: + storage_change.block_access_index = HexNumber(tx2) + elif storage_change.block_access_index == tx2: balance_indices[tx2] = True - storage_change.tx_index = HexNumber(tx1) + storage_change.block_access_index = HexNumber(tx1) - # Note: storage_reads is just a list of StorageKey, no tx_index to + # 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: + if code_change.block_access_index == tx1: code_indices[tx1] = True - code_change.tx_index = HexNumber(tx2) - elif code_change.tx_index == tx2: + code_change.block_access_index = HexNumber(tx2) + elif code_change.block_access_index == tx2: code_indices[tx2] = True - code_change.tx_index = HexNumber(tx1) + code_change.block_access_index = HexNumber(tx1) new_root.append(new_account) 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 03b8224bbf..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 @@ -84,21 +84,21 @@ def validate_structure(self) -> None: if not change_list: continue - tx_indices = [c.tx_index for c in change_list] + bal_indices = [c.block_access_index for c in change_list] # Check both ordering and duplicates - if tx_indices != sorted(tx_indices): + if bal_indices != sorted(bal_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)}" + 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(tx_indices) != len(set(tx_indices)): + if len(bal_indices) != len(set(bal_indices)): duplicates = sorted( { idx - for idx in tx_indices - if tx_indices.count(idx) > 1 + for idx in bal_indices + if bal_indices.count(idx) > 1 } ) raise BlockAccessListValidationError( @@ -118,27 +118,29 @@ def validate_structure(self) -> None: f"{account.storage_changes[i].slot}" ) - # Check transaction index ordering and uniqueness within storage slots + # Check bal 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] + bal_indices = [ + c.block_access_index for c in storage_slot.slot_changes + ] # Check both ordering and duplicates - if tx_indices != sorted(tx_indices): + 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: {tx_indices}, Expected: {sorted(tx_indices)}" + f"Got: {bal_indices}, Expected: {sorted(bal_indices)}" ) - if len(tx_indices) != len(set(tx_indices)): + if len(bal_indices) != len(set(bal_indices)): duplicates = sorted( { idx - for idx in tx_indices - if tx_indices.count(idx) > 1 + for idx in bal_indices + if bal_indices.count(idx) > 1 } ) raise BlockAccessListValidationError( diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index 899d9647e4..f8b16d4474 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.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) + ], ), } ) @@ -474,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), ], ) ] @@ -486,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 ], ), @@ -508,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( [ @@ -527,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) + ] ) ) } @@ -547,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( [ @@ -566,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 + ) ] ) ), @@ -591,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) + ], ) ) @@ -620,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 + ) ], ) ] @@ -682,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( [ @@ -702,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" + ) ] ) ), @@ -732,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 + ) ], ) ], @@ -750,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""), ], ), ), @@ -800,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), ], ), ] @@ -811,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), ] ), ), @@ -833,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), ] ), ), @@ -856,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) + ], ) ] ) @@ -865,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) + ] ) ), } @@ -883,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) ], ) ] @@ -894,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 + ) ] ) ), @@ -912,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") + ], ) ] ) @@ -922,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" + ) ] ) ), @@ -965,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 + ) ], ) ], @@ -981,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_t8n.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py index 3c884cf2f4..c33cf8a2c7 100644 --- 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 @@ -127,7 +127,7 @@ def test_bal_storage_reads_ordering() -> None: "field_name", ["nonce_changes", "balance_changes", "code_changes"], ) -def test_bal_tx_indices_ordering(field_name: str) -> None: +def test_bal_block_access_indices_ordering(field_name: str) -> None: """ Test that transaction indices must be in ascending order within change lists. """ @@ -138,51 +138,63 @@ def test_bal_tx_indices_ordering(field_name: str) -> None: Union[BalNonceChange, BalBalanceChange, BalCodeChange] ] - # Correct order: tx_index 1, 2, 3 + # Correct order: block_access_index 1, 2, 3 if field_name == "nonce_changes": changes_valid = [ - BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), - BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(2)), - BalNonceChange(tx_index=HexNumber(3), post_nonce=HexNumber(3)), + BalNonceChange( + block_access_index=HexNumber(1), post_nonce=HexNumber(1) + ), + BalNonceChange( + block_access_index=HexNumber(2), post_nonce=HexNumber(2) + ), + BalNonceChange( + block_access_index=HexNumber(3), post_nonce=HexNumber(3) + ), ] changes_invalid = [ - BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), - BalNonceChange(tx_index=HexNumber(3), post_nonce=HexNumber(3)), - BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(2)), + BalNonceChange( + block_access_index=HexNumber(1), post_nonce=HexNumber(1) + ), + BalNonceChange( + block_access_index=HexNumber(3), post_nonce=HexNumber(3) + ), + BalNonceChange( + block_access_index=HexNumber(2), post_nonce=HexNumber(2) + ), ] elif field_name == "balance_changes": changes_valid = [ BalBalanceChange( - tx_index=HexNumber(1), post_balance=HexNumber(100) + block_access_index=HexNumber(1), post_balance=HexNumber(100) ), BalBalanceChange( - tx_index=HexNumber(2), post_balance=HexNumber(200) + block_access_index=HexNumber(2), post_balance=HexNumber(200) ), BalBalanceChange( - tx_index=HexNumber(3), post_balance=HexNumber(300) + block_access_index=HexNumber(3), post_balance=HexNumber(300) ), ] changes_invalid = [ BalBalanceChange( - tx_index=HexNumber(1), post_balance=HexNumber(100) + block_access_index=HexNumber(1), post_balance=HexNumber(100) ), BalBalanceChange( - tx_index=HexNumber(3), post_balance=HexNumber(300) + block_access_index=HexNumber(3), post_balance=HexNumber(300) ), BalBalanceChange( - tx_index=HexNumber(2), post_balance=HexNumber(200) + block_access_index=HexNumber(2), post_balance=HexNumber(200) ), ] elif field_name == "code_changes": changes_valid = [ - BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), - BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), - BalCodeChange(tx_index=HexNumber(3), new_code=b"code3"), + BalCodeChange(block_access_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(block_access_index=HexNumber(2), new_code=b"code2"), + BalCodeChange(block_access_index=HexNumber(3), new_code=b"code3"), ] changes_invalid = [ - BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), - BalCodeChange(tx_index=HexNumber(3), new_code=b"code3"), - BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), + BalCodeChange(block_access_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(block_access_index=HexNumber(3), new_code=b"code3"), + BalCodeChange(block_access_index=HexNumber(2), new_code=b"code2"), ] bal_valid = BlockAccessList( @@ -196,7 +208,7 @@ def test_bal_tx_indices_ordering(field_name: str) -> None: with pytest.raises( BlockAccessListValidationError, - match=f"Transaction indices not in ascending order in {field_name}", + match=f"Block access indices not in ascending order in {field_name}", ): bal_invalid.validate_structure() @@ -205,7 +217,7 @@ def test_bal_tx_indices_ordering(field_name: str) -> None: "field_name", ["nonce_changes", "balance_changes", "code_changes"], ) -def test_bal_duplicate_tx_indices(field_name: str) -> None: +def test_bal_duplicate_block_access_indices(field_name: str) -> None: """ Test that BAL must not have duplicate tx indices in change lists. """ @@ -213,34 +225,38 @@ def test_bal_duplicate_tx_indices(field_name: str) -> None: changes: List[Union[BalNonceChange, BalBalanceChange, BalCodeChange]] - # Duplicate tx_index=1 + # Duplicate block_access_index=1 if field_name == "nonce_changes": changes = [ - BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), BalNonceChange( - tx_index=HexNumber(1), post_nonce=HexNumber(2) - ), # duplicate tx_index - BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(3)), + block_access_index=HexNumber(1), post_nonce=HexNumber(1) + ), + BalNonceChange( + block_access_index=HexNumber(1), post_nonce=HexNumber(2) + ), # duplicate block_access_index + BalNonceChange( + block_access_index=HexNumber(2), post_nonce=HexNumber(3) + ), ] elif field_name == "balance_changes": changes = [ BalBalanceChange( - tx_index=HexNumber(1), post_balance=HexNumber(100) + block_access_index=HexNumber(1), post_balance=HexNumber(100) ), BalBalanceChange( - tx_index=HexNumber(1), post_balance=HexNumber(200) - ), # duplicate tx_index + block_access_index=HexNumber(1), post_balance=HexNumber(200) + ), # duplicate block_access_index BalBalanceChange( - tx_index=HexNumber(2), post_balance=HexNumber(300) + block_access_index=HexNumber(2), post_balance=HexNumber(300) ), ] elif field_name == "code_changes": changes = [ - BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(block_access_index=HexNumber(1), new_code=b"code1"), BalCodeChange( - tx_index=HexNumber(1), new_code=b"" - ), # duplicate tx_index - BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), + block_access_index=HexNumber(1), new_code=b"" + ), # duplicate block_access_index + BalCodeChange(block_access_index=HexNumber(2), new_code=b"code2"), ] bal = BlockAccessList( @@ -254,13 +270,13 @@ def test_bal_duplicate_tx_indices(field_name: str) -> None: bal.validate_structure() -def test_bal_storage_duplicate_tx_indices() -> None: +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 tx_index within the same slot + # Create storage changes with duplicate block_access_index within the same slot bal = BlockAccessList( [ BalAccountChange( @@ -270,15 +286,15 @@ def test_bal_storage_duplicate_tx_indices() -> None: slot=StorageKey(0), slot_changes=[ BalStorageChange( - tx_index=HexNumber(1), + block_access_index=HexNumber(1), post_value=StorageKey(100), ), BalStorageChange( - tx_index=HexNumber(1), + block_access_index=HexNumber(1), post_value=StorageKey(200), - ), # duplicate tx_index + ), # duplicate block_access_index BalStorageChange( - tx_index=HexNumber(2), + block_access_index=HexNumber(2), post_value=StorageKey(300), ), ], @@ -309,10 +325,12 @@ def test_bal_multiple_violations() -> None: address=bob, # Should come after alice nonce_changes=[ BalNonceChange( - tx_index=HexNumber(1), post_nonce=HexNumber(1) + block_access_index=HexNumber(1), + post_nonce=HexNumber(1), ), BalNonceChange( - tx_index=HexNumber(1), post_nonce=HexNumber(2) + block_access_index=HexNumber(1), + post_nonce=HexNumber(2), ), # duplicate ], ), @@ -342,7 +360,8 @@ def test_bal_single_account_valid() -> None: address=Address(0xA), nonce_changes=[ BalNonceChange( - tx_index=HexNumber(1), post_nonce=HexNumber(1) + block_access_index=HexNumber(1), + post_nonce=HexNumber(1), ) ], ) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index e860c84068..ff5426746a 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Dict, List, Set -from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.bytes import Bytes from ethereum_types.numeric import U64, U256 from ..fork_types import Address @@ -45,7 +45,7 @@ class AccountData: transaction index where it occurred. """ - storage_changes: Dict[Bytes32, List[StorageChange]] = field( + storage_changes: Dict[U256, List[StorageChange]] = field( default_factory=dict ) """ @@ -53,7 +53,7 @@ class AccountData: Each change includes the transaction index and new value. """ - storage_reads: Set[Bytes32] = field(default_factory=set) + storage_reads: Set[U256] = field(default_factory=set) """ Set of storage slots that were read but not modified. """ @@ -121,9 +121,9 @@ def ensure_account(builder: BlockAccessListBuilder, address: Address) -> None: def add_storage_write( builder: BlockAccessListBuilder, address: Address, - slot: Bytes32, + slot: U256, block_access_index: BlockAccessIndex, - new_value: Bytes32, + new_value: U256, ) -> None: """ Add a storage write operation to the block access list. @@ -171,7 +171,7 @@ def add_storage_write( def add_storage_read( - builder: BlockAccessListBuilder, address: Address, slot: Bytes32 + builder: BlockAccessListBuilder, address: Address, slot: U256 ) -> None: """ Add a storage read operation to the block access list. @@ -482,7 +482,7 @@ def build_block_access_list( # Add all storage reads for address, slot in state_changes.storage_reads: - add_storage_read(builder, address, slot) + add_storage_read(builder, address, U256(int.from_bytes(slot))) # Add all storage writes # Net-zero filtering happens at transaction commit time, not here. @@ -492,10 +492,9 @@ def build_block_access_list( slot, block_access_index, ), value in state_changes.storage_writes.items(): - # Convert U256 to Bytes32 for storage - value_bytes = Bytes32(value.to_bytes(U256(32), "big")) + u256_slot = U256(int.from_bytes(slot)) add_storage_write( - builder, address, slot, block_access_index, value_bytes + builder, address, u256_slot, block_access_index, value ) # Add all balance changes (balance_changes is keyed by (address, index)) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py index c4f49ff4aa..e604d43da1 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py @@ -10,14 +10,14 @@ from dataclasses import dataclass from typing import List, Tuple -from ethereum_types.bytes import Bytes, Bytes20, Bytes32 +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 = Bytes32 -StorageValue = Bytes32 +StorageKey = U256 +StorageValue = U256 CodeData = Bytes BlockAccessIndex = Uint # uint16 in the spec, but using Uint for compatibility Balance = U256 # Post-transaction balance in wei 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 cb13727f0d..0e84189598 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -348,16 +348,16 @@ def _block_access_list_to_json(account_changes: Any) -> Any: storage_changes = [] for slot_change in account.storage_changes: slot_data: Dict[str, Any] = { - "slot": int.from_bytes(slot_change.slot, "big"), + "slot": int(slot_change.slot), "slotChanges": [], } for change in slot_change.changes: slot_data["slotChanges"].append( { - "txIndex": int(change.block_access_index), - "postValue": int.from_bytes( - change.new_value, "big" + "blockAccessIndex": int( + change.block_access_index ), + "postValue": int(change.new_value), } ) storage_changes.append(slot_data) @@ -365,14 +365,13 @@ def _block_access_list_to_json(account_changes: Any) -> Any: if account.storage_reads: account_data["storageReads"] = [ - int.from_bytes(slot, "big") - for slot in account.storage_reads + int(slot) for slot in account.storage_reads ] if account.balance_changes: account_data["balanceChanges"] = [ { - "txIndex": int(change.block_access_index), + "blockAccessIndex": int(change.block_access_index), "postBalance": int(change.post_balance), } for change in account.balance_changes @@ -381,7 +380,7 @@ def _block_access_list_to_json(account_changes: Any) -> Any: if account.nonce_changes: account_data["nonceChanges"] = [ { - "txIndex": int(change.block_access_index), + "blockAccessIndex": int(change.block_access_index), "postNonce": int(change.new_nonce), } for change in account.nonce_changes @@ -390,7 +389,7 @@ def _block_access_list_to_json(account_changes: Any) -> Any: if account.code_changes: account_data["codeChanges"] = [ { - "txIndex": int(change.block_access_index), + "blockAccessIndex": int(change.block_access_index), "newCode": "0x" + change.new_code.hex(), } for change in account.code_changes 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 32a1052841..f56a142c8b 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 @@ -55,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) + ], ), } ), @@ -110,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 + ) ], ), } @@ -190,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 + ) ], ), } @@ -275,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(), @@ -314,16 +329,22 @@ def test_bal_callcode_nested_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(), @@ -380,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 + ) ], ) ], @@ -448,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], @@ -511,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 ) ], ), @@ -571,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, @@ -628,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(), @@ -688,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 + ) ], ), ], @@ -762,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], @@ -806,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), ) @@ -848,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), ) @@ -928,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 @@ -942,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, ) ], @@ -955,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 @@ -1003,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(), @@ -1044,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], @@ -1083,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=[], @@ -1165,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(), @@ -1207,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 @@ -1295,16 +1355,19 @@ def test_bal_coinbase_zero_tip( 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=5) + BalBalanceChange(block_access_index=1, post_balance=5) ] ), # Coinbase must be included even with zero tip @@ -1418,11 +1481,15 @@ def test_bal_precompile_funded( 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) + ], ), precompile: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=value) + BalBalanceChange( + block_access_index=1, post_balance=value + ) ] if value > 0 else [], @@ -1480,7 +1547,9 @@ def test_bal_precompile_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) + ], ), oracle: BalAccountExpectation.empty(), precompile: BalAccountExpectation.empty(), @@ -1529,11 +1598,15 @@ def test_bal_nonexistent_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) + ], ), bob: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=value) + BalBalanceChange( + block_access_index=1, post_balance=value + ) ] if value > 0 else [], @@ -1611,7 +1684,9 @@ def test_bal_nonexistent_account_access_read_only( 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.empty(), bob: BalAccountExpectation.empty(), @@ -1696,12 +1771,15 @@ def test_bal_nonexistent_account_access_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: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=oracle_final_balance + block_access_index=1, + post_balance=oracle_final_balance, ) ] if oracle_has_balance_change @@ -1710,7 +1788,8 @@ def test_bal_nonexistent_account_access_value_transfer( bob: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=bob_final_balance + block_access_index=1, + post_balance=bob_final_balance, ) ] if bob_has_balance_change @@ -1786,20 +1865,24 @@ def test_bal_multiple_balance_changes_same_account( account_expectations={ alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), bob: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=2, post_nonce=1) + BalNonceChange( + block_access_index=2, post_nonce=1 + ) ], balance_changes=[ BalBalanceChange( - tx_index=1, + block_access_index=1, post_balance=bob_balance_after_tx0, ), BalBalanceChange( - tx_index=2, + block_access_index=2, post_balance=bob_balance_after_tx1, ), ], @@ -1807,7 +1890,8 @@ def test_bal_multiple_balance_changes_same_account( charlie: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=2, post_balance=spend_amount + block_access_index=2, + post_balance=spend_amount, ) ], ), @@ -1859,9 +1943,15 @@ def test_bal_multiple_storage_writes_same_slot( account_expectations={ alice: BalAccountExpectation( 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 + ), ], ), contract: BalAccountExpectation( @@ -1870,13 +1960,13 @@ def test_bal_multiple_storage_writes_same_slot( slot=1, slot_changes=[ BalStorageChange( - tx_index=1, post_value=1 + block_access_index=1, post_value=1 ), BalStorageChange( - tx_index=2, post_value=2 + block_access_index=2, post_value=2 ), BalStorageChange( - tx_index=3, post_value=3 + block_access_index=3, post_value=3 ), ], ), @@ -1960,7 +2050,7 @@ def test_bal_nested_delegatecall_storage_writes_net_zero( account_expectations = { alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], ), root_contract: BalAccountExpectation( storage_reads=[0], @@ -2008,10 +2098,10 @@ def test_bal_create_transaction_empty_code( account_expectations = { alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], ), contract_address: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], code_changes=[], # ensure no code_changes recorded ), } @@ -2043,8 +2133,8 @@ def test_bal_cross_tx_storage_revert_to_zero( blobhash scenario where slot changes were being incorrectly filtered as net-zero across transaction boundaries. - Tx1: slot 0 = 0x0 -> 0xABCD (change recorded at tx_index=1) - Tx2: slot 0 = 0xABCD -> 0x0 (change MUST be recorded at tx_index=2) + 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() @@ -2070,8 +2160,8 @@ def test_bal_cross_tx_storage_revert_to_zero( account_expectations = { alice: 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( @@ -2079,10 +2169,12 @@ def test_bal_cross_tx_storage_revert_to_zero( BalStorageSlot( slot=0, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0xABCD), + 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(tx_index=2, post_value=0x0), + BalStorageChange(block_access_index=2, post_value=0x0), ], ), ], @@ -2163,7 +2255,9 @@ def test_bal_cross_block_ripemd160_state_leak( 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, ripemd_caller: BalAccountExpectation.empty(), @@ -2187,7 +2281,9 @@ def test_bal_cross_block_ripemd160_state_leak( account_expectations={ alice: None, bob: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ] ), # this is the important check ripemd160_addr: None, @@ -2321,23 +2417,33 @@ def test_bal_all_transaction_types( account_expectations={ # Type 0 sender sender_0: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), # Type 1 sender sender_1: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=2, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=2, post_nonce=1) + ], ), # Type 2 sender sender_2: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=3, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=3, post_nonce=1) + ], ), # Type 3 sender sender_3: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=4, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=4, post_nonce=1) + ], ), # Type 4 sender sender_4: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=5, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=5, post_nonce=1) + ], ), # Contract touched by Type 0 contract_0: BalAccountExpectation( @@ -2345,7 +2451,9 @@ def test_bal_all_transaction_types( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x01) + BalStorageChange( + block_access_index=1, post_value=0x01 + ) ], ) ], @@ -2356,7 +2464,9 @@ def test_bal_all_transaction_types( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=2, post_value=0x02) + BalStorageChange( + block_access_index=2, post_value=0x02 + ) ], ) ], @@ -2370,7 +2480,9 @@ def test_bal_all_transaction_types( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=3, post_value=0x03) + BalStorageChange( + block_access_index=3, post_value=0x03 + ) ], ) ], @@ -2381,17 +2493,21 @@ def test_bal_all_transaction_types( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=4, post_value=0x04) + BalStorageChange( + block_access_index=4, post_value=0x04 + ) ], ) ], ), # Alice (Type 4 delegation target, executes oracle code) alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=5, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=5, post_nonce=1) + ], code_changes=[ BalCodeChange( - tx_index=5, + block_access_index=5, new_code=Spec7702.delegation_designation(oracle), ) ], @@ -2399,7 +2515,9 @@ def test_bal_all_transaction_types( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=5, post_value=0x05) + BalStorageChange( + block_access_index=5, post_value=0x05 + ) ], ) ], @@ -2507,7 +2625,9 @@ def test_bal_lexicographic_address_ordering( 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) + ], ), contract: BalAccountExpectation.empty(), # These addresses appear in BAL due to BALANCE access 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 index 72ac91c923..9d32fdf123 100644 --- 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 @@ -82,12 +82,12 @@ def test_bal_withdrawal_contract_cross_index( slot_changes=[ BalStorageChange( # Incremented during tx - tx_index=1, + block_access_index=1, post_value=1, ), BalStorageChange( # Reset during post-exec - tx_index=2, + block_access_index=2, post_value=0, ), ], @@ -97,12 +97,12 @@ def test_bal_withdrawal_contract_cross_index( slot_changes=[ BalStorageChange( # Incremented during tx - tx_index=1, + block_access_index=1, post_value=1, ), BalStorageChange( # Reset during post-exec - tx_index=2, + block_access_index=2, post_value=0, ), ], @@ -154,12 +154,12 @@ def test_bal_consolidation_contract_cross_index( slot_changes=[ BalStorageChange( # Incremented during tx - tx_index=1, + block_access_index=1, post_value=1, ), BalStorageChange( # Reset during post-exec - tx_index=2, + block_access_index=2, post_value=0, ), ], @@ -169,12 +169,12 @@ def test_bal_consolidation_contract_cross_index( slot_changes=[ BalStorageChange( # Incremented during tx - tx_index=1, + block_access_index=1, post_value=1, ), BalStorageChange( # Reset during post-exec - tx_index=2, + block_access_index=2, post_value=0, ), ], @@ -232,13 +232,17 @@ def test_bal_noop_write_filtering( BalStorageSlot( slot=2, slot_changes=[ - BalStorageChange(tx_index=1, post_value=42), + BalStorageChange( + block_access_index=1, post_value=42 + ), ], ), BalStorageSlot( slot=4, slot_changes=[ - BalStorageChange(tx_index=1, post_value=200), + BalStorageChange( + block_access_index=1, post_value=200 + ), ], ), ], 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 index 16ce09ff90..df35ba66ef 100644 --- 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 @@ -62,7 +62,9 @@ def test_bal_withdrawal_empty_block( account_expectations={ charlie: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=11 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=11 * GWEI + ) ], ), } @@ -115,16 +117,20 @@ def test_bal_withdrawal_and_transaction( 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: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=5) + BalBalanceChange(block_access_index=1, post_balance=5) ], ), charlie: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=10 * GWEI) + BalBalanceChange( + block_access_index=2, post_balance=10 * GWEI + ) ], ), } @@ -169,7 +175,9 @@ def test_bal_withdrawal_to_nonexistent_account( account_expectations={ charlie: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) ], ), } @@ -216,7 +224,9 @@ def test_bal_withdrawal_no_evm_execution( account_expectations={ oracle: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) ], storage_reads=[], storage_changes=[], @@ -275,7 +285,9 @@ def test_bal_withdrawal_and_state_access_same_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_reads=[0x01], @@ -283,12 +295,16 @@ def test_bal_withdrawal_and_state_access_same_account( BalStorageSlot( slot=0x02, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x99) + BalStorageChange( + block_access_index=1, post_value=0x99 + ) ], ) ], balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=10 * GWEI) + BalBalanceChange( + block_access_index=2, post_balance=10 * GWEI + ) ], ), } @@ -343,12 +359,18 @@ def test_bal_withdrawal_and_value_transfer_same_address( 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: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=5 * GWEI), - BalBalanceChange(tx_index=2, post_balance=15 * GWEI), + BalBalanceChange( + block_access_index=1, post_balance=5 * GWEI + ), + BalBalanceChange( + block_access_index=2, post_balance=15 * GWEI + ), ], ), } @@ -388,7 +410,9 @@ def test_bal_multiple_withdrawals_same_address( account_expectations={ charlie: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=30 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=30 * GWEI + ) ], ), } @@ -443,17 +467,23 @@ def test_bal_withdrawal_and_selfdestruct( 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: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=100 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=100 * GWEI + ) ], ), oracle: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=0), - BalBalanceChange(tx_index=2, post_balance=50 * GWEI), + BalBalanceChange(block_access_index=1, post_balance=0), + BalBalanceChange( + block_access_index=2, post_balance=50 * GWEI + ), ], ), } @@ -510,13 +540,21 @@ def test_bal_withdrawal_and_new_contract( 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( - code_changes=[BalCodeChange(tx_index=1, new_code=code)], + code_changes=[ + BalCodeChange(block_access_index=1, new_code=code) + ], balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=5 * GWEI), - BalBalanceChange(tx_index=2, post_balance=15 * GWEI), + BalBalanceChange( + block_access_index=1, post_balance=5 * GWEI + ), + BalBalanceChange( + block_access_index=2, post_balance=15 * GWEI + ), ], ), } @@ -621,7 +659,9 @@ def test_bal_withdrawal_to_precompiles( account_expectations={ precompile: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) ], storage_reads=[], storage_changes=[], @@ -668,7 +708,8 @@ def test_bal_withdrawal_largest_amount( charlie: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=max_amount * GWEI + block_access_index=1, + post_balance=max_amount * GWEI, ) ], ), @@ -738,20 +779,23 @@ def test_bal_withdrawal_to_coinbase( 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: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=5) + BalBalanceChange(block_access_index=1, post_balance=5) ], ), coinbase: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=tip_to_coinbase + block_access_index=1, post_balance=tip_to_coinbase ), BalBalanceChange( - tx_index=2, post_balance=coinbase_final_balance + block_access_index=2, + post_balance=coinbase_final_balance, ), ], ), @@ -798,7 +842,9 @@ def test_bal_withdrawal_to_coinbase_empty_block( account_expectations={ coinbase: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + BalBalanceChange( + block_access_index=1, post_balance=10 * GWEI + ) ], ), } 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 a0a6a1e121..009b3550c2 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 @@ -70,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, @@ -89,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( @@ -181,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 @@ -211,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), ], ) @@ -306,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 @@ -333,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), ], ) @@ -395,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) @@ -464,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(), @@ -527,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 @@ -592,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 @@ -641,11 +663,13 @@ def test_bal_7702_null_address_delegation_no_code_change( # because setting code from b"" to b"" is a net-zero change account_expectations = { alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], code_changes=[], # explicit check for no code changes ), bob: BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] ), } @@ -720,18 +744,24 @@ def test_bal_7702_double_auth_reset( account_expectations={ alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=2) + BalNonceChange( + block_access_index=1, post_nonce=2 + ) ], code_changes=[], ), 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) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), contract_a: None, @@ -791,20 +821,22 @@ def test_bal_7702_double_auth_swap( account_expectations = { alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], code_changes=[ # Should show final code (CONTRACT_B), not CONTRACT_A BalCodeChange( - tx_index=1, + block_access_index=1, new_code=Spec7702.delegation_designation(contract_b), ) ], ), bob: BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + balance_changes=[ + 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)], ), # Neither contract appears in BAL during delegation setup contract_a: None, @@ -901,25 +933,29 @@ def test_bal_selfdestruct_to_7702_delegation( account_expectations = { alice: BalAccountExpectation( # tx1: nonce change for auth, code change for delegation - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], code_changes=[ BalCodeChange( - tx_index=1, + block_access_index=1, new_code=Spec7702.delegation_designation(oracle), ) ], # tx2: balance change from selfdestruct balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=alice_final_balance) + BalBalanceChange( + block_access_index=2, post_balance=alice_final_balance + ) ], ), bob: BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=10) + ] ), 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), ], ), caller: BalAccountExpectation.empty(), @@ -927,7 +963,9 @@ def test_bal_selfdestruct_to_7702_delegation( # Explicitly verify ALL fields to avoid false positives victim: BalAccountExpectation( nonce_changes=[], # Contract nonce unchanged - balance_changes=[BalBalanceChange(tx_index=2, post_balance=0)], + 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 @@ -1018,23 +1056,27 @@ def test_bal_withdrawal_to_7702_delegation( account_expectations = { alice: BalAccountExpectation( # tx1: nonce change for auth, code change for delegation - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], code_changes=[ BalCodeChange( - tx_index=1, + block_access_index=1, new_code=Spec7702.delegation_designation(oracle), ) ], # tx2 (withdrawal): balance change balance_changes=[ - BalBalanceChange(tx_index=2, post_balance=alice_final_balance) + BalBalanceChange( + block_access_index=2, post_balance=alice_final_balance + ) ], ), bob: BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + balance_changes=[ + 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 appear - withdrawals don't execute recipient code, # so delegation target is never accessed 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..13d18c7fef 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 @@ -75,7 +75,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 +120,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 +175,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 +184,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 +193,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 +203,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,21 +255,26 @@ 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, ), ], ), @@ -307,7 +321,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 +332,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 +378,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 +430,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 +493,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 +507,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 +516,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 +526,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,7 +534,7 @@ 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), @@ -549,13 +577,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 +630,16 @@ 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, tx_index=1, balance=999999)), + ).modify( + modify_balance( + receiver, block_access_index=1, balance=999999 + ) + ), ) ], ) 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 9e2355b93e..7a07299407 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 @@ -168,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 + ) ], ), ] @@ -505,11 +507,13 @@ def test_bal_call_no_delegation_and_oog_before_target_access( elif value > 0: account_expectations = { caller: BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=0)] + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ] ), target: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=value) + BalBalanceChange(block_access_index=1, post_balance=value) ] ), } @@ -783,7 +787,9 @@ def test_bal_call_7702_delegation_and_oog( account_expectations: Dict[Address, BalAccountExpectation | None] = { caller: ( BalAccountExpectation( - balance_changes=[BalBalanceChange(tx_index=1, post_balance=0)] + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ] ) if value_transferred else BalAccountExpectation.empty() @@ -797,7 +803,7 @@ def test_bal_call_7702_delegation_and_oog( if value_transferred: account_expectations[target] = BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=value) + BalBalanceChange(block_access_index=1, post_balance=value) ] ) else: @@ -1797,18 +1803,21 @@ def test_bal_self_destruct( 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: BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=expected_recipient_balance + block_access_index=1, + post_balance=expected_recipient_balance, ) ] ), self_destructed_account: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=0) + BalBalanceChange(block_access_index=1, post_balance=0) ] if pre_funded else [], @@ -1822,7 +1831,9 @@ def test_bal_self_destruct( BalStorageSlot( slot=0x02, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ] @@ -1935,7 +1946,7 @@ def test_bal_self_destruct_oog( account_expectations: Dict[Address, BalAccountExpectation | None] = { alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], ), caller_contract: BalAccountExpectation.empty(), selfdestruct_contract: BalAccountExpectation.empty(), @@ -1996,14 +2007,18 @@ def test_bal_storage_write_read_same_frame( 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=[ BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -2085,14 +2100,18 @@ def test_bal_storage_write_read_cross_frame( 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=[ BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -2159,16 +2178,16 @@ def test_bal_create_oog_code_deposit( # nonce/code changes rolled back on OOG) account_expectations = { alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], ), factory: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], storage_changes=[ BalStorageSlot( slot=1, slot_changes=[ # SSTORE saves 0 (CREATE failed) - BalStorageChange(tx_index=1, post_value=0), + BalStorageChange(block_access_index=1, post_value=0), ], ) ], @@ -2240,7 +2259,9 @@ def test_bal_sstore_static_context( account_expectations={ alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), contract_a: BalAccountExpectation( @@ -2249,7 +2270,7 @@ def test_bal_sstore_static_context( slot=0x00, slot_changes=[ BalStorageChange( - tx_index=1, post_value=1 + block_access_index=1, post_value=1 ), ], ), @@ -2307,7 +2328,9 @@ def test_bal_create_contract_init_revert( account_expectations={ alice: BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=1) + BalNonceChange( + block_access_index=1, post_nonce=1 + ) ], ), caller: BalAccountExpectation.empty(), @@ -2386,7 +2409,9 @@ def test_bal_call_revert_insufficient_funds( 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) + ], ), contract: BalAccountExpectation( # Storage read for slot 0x01 @@ -2396,7 +2421,9 @@ def test_bal_call_revert_insufficient_funds( BalStorageSlot( slot=0x02, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0) + BalStorageChange( + block_access_index=1, post_value=0 + ) ], ) ], @@ -2525,14 +2552,18 @@ def test_bal_create_selfdestruct_to_self_with_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) + ], ), factory: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], # Balance changes: loses endowment (100) balance_changes=[ BalBalanceChange( - tx_index=1, + block_access_index=1, post_balance=factory_balance - endowment, ) ], @@ -2543,7 +2574,9 @@ def test_bal_create_selfdestruct_to_self_with_call( BalStorageSlot( slot=0x01, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -2646,17 +2679,23 @@ def test_bal_create2_collision( 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: BalAccountExpectation( # Nonce incremented 1→2 even on failed CREATE2 - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + 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(tx_index=1, post_value=0) + BalStorageChange( + block_access_index=1, post_value=0 + ) ], ) ], @@ -2726,7 +2765,9 @@ def test_bal_transient_storage_not_tracked( 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) + ], ), contract: BalAccountExpectation( # Persistent storage change for slot 0x02 @@ -2734,7 +2775,9 @@ def test_bal_transient_storage_not_tracked( BalStorageSlot( slot=0x02, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) + BalStorageChange( + block_access_index=1, post_value=0x42 + ) ], ) ], @@ -2798,7 +2841,9 @@ def test_bal_selfdestruct_to_precompile( 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) + ], ), caller: BalAccountExpectation.empty(), # Victim (selfdestructing contract): balance changes 100→0 @@ -2806,7 +2851,7 @@ def test_bal_selfdestruct_to_precompile( victim: BalAccountExpectation( nonce_changes=[], # Contract nonce unchanged balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=0) + BalBalanceChange(block_access_index=1, post_balance=0) ], code_changes=[], # Code unchanged (post-Cancun) storage_changes=[], # No storage changes @@ -2818,7 +2863,7 @@ def test_bal_selfdestruct_to_precompile( nonce_changes=[], # MUST NOT have nonce changes balance_changes=[ BalBalanceChange( - tx_index=1, post_balance=contract_balance + block_access_index=1, post_balance=contract_balance ) ], code_changes=[], # MUST NOT have code changes @@ -2907,7 +2952,9 @@ def test_bal_create_early_failure( 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: BalAccountExpectation( # NO nonce_changes - CREATE failed before increment_nonce @@ -2917,7 +2964,9 @@ def test_bal_create_early_failure( BalStorageSlot( slot=0x00, slot_changes=[ - BalStorageChange(tx_index=1, post_value=0) + BalStorageChange( + block_access_index=1, post_value=0 + ) ], ) ], 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 77c298110b..0432339ead 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -23,9 +23,9 @@ | `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_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 `block_access_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 `block_access_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 `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 | @@ -75,7 +75,7 @@ | `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` | 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 `tx_index=0` (pre-execution system operations). | ✅ Completed | +| `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 | @@ -101,13 +101,13 @@ | `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 tx_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at tx_index=2 with `balance_changes` (receives selfdestruct). (3) Victim at tx_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_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_selfdestruct_to_precompile` | Ensure BAL captures SELFDESTRUCT with precompile as beneficiary | Caller triggers victim contract (balance=100) to execute `SELFDESTRUCT(0x0000...0001)` (ecrecover precompile). Precompile starts with balance=0. | BAL **MUST** include: (1) Contract with `balance_changes` (100→0, loses balance to selfdestruct). (2) Precompile address 0x01 with `balance_changes` (0→100, receives selfdestruct balance). Precompile **MUST NOT** have `code_changes` or `nonce_changes`. This complements `test_bal_withdrawal_to_precompiles` (withdrawal) and `test_bal_precompile_funded` (tx value). | ✅ Completed | | `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ 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 tx_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at tx_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_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 | diff --git a/tests/cancun/create/test_create_oog_from_eoa_refunds.py b/tests/cancun/create/test_create_oog_from_eoa_refunds.py index ee7f14571a..050f54c91e 100644 --- a/tests/cancun/create/test_create_oog_from_eoa_refunds.py +++ b/tests/cancun/create/test_create_oog_from_eoa_refunds.py @@ -356,13 +356,17 @@ def test_create_oog_from_eoa_refunds( ) created_bal = BalAccountExpectation( nonce_changes=[ - BalNonceChange(tx_index=1, post_nonce=expected_nonce) + BalNonceChange( + block_access_index=1, post_nonce=expected_nonce + ) ], storage_changes=[ BalStorageSlot( slot=0, slot_changes=[ - BalStorageChange(tx_index=1, post_value=1) + BalStorageChange( + block_access_index=1, post_value=1 + ) ], ), ], @@ -404,7 +408,9 @@ def test_create_oog_from_eoa_refunds( bal_expectation = BlockAccessListExpectation( account_expectations={ sender: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), created_address: created_bal, } diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index ab2d22f112..634bfac822 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -454,7 +454,7 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 BalAccountExpectation( balance_changes=[ BalBalanceChange( - tx_index=1, + block_access_index=1, post_balance=1 if selfdestruct_on_outer_call == 1 else 2, @@ -467,11 +467,15 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 selfdestruct_with_transfer_contract_address ] = BalAccountExpectation( storage_reads=[1], - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - balance_changes=[BalBalanceChange(tx_index=1, post_balance=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=1) + ], code_changes=[ BalCodeChange( - tx_index=1, + block_access_index=1, new_code=selfdestruct_with_transfer_contract_code, ), ], @@ -479,7 +483,9 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 BalStorageSlot( slot=0, slot_changes=[ - BalStorageChange(tx_index=1, post_value=1), + BalStorageChange( + block_access_index=1, post_value=1 + ), ], ), ], 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 015217521d..ae3dd53df2 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -230,7 +230,7 @@ def expected_block_access_list( else: empty_account_expectation = BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=1) + BalBalanceChange(block_access_index=1, post_balance=1) ] ) else: @@ -246,14 +246,16 @@ def expected_block_access_list( empty_account: empty_account_expectation, caller_address: BalAccountExpectation( balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=4) + BalBalanceChange(block_access_index=1, post_balance=4) ], storage_reads=[0] if gas_shortage else [], storage_changes=[ BalStorageSlot( slot=0x00, slot_changes=[ - BalStorageChange(tx_index=1, post_value=1), + BalStorageChange( + block_access_index=1, post_value=1 + ), ], ), ] @@ -262,7 +264,11 @@ def expected_block_access_list( ), callee_address: BalAccountExpectation( balance_changes=( - [BalBalanceChange(tx_index=1, post_balance=2)] + [ + BalBalanceChange( + block_access_index=1, post_balance=2 + ) + ] if not gas_shortage and callee_opcode == Op.CALL else [] ), diff --git a/tests/prague/eip7702_set_code_tx/test_gas.py b/tests/prague/eip7702_set_code_tx/test_gas.py index b415684bf7..93c2747019 100644 --- a/tests/prague/eip7702_set_code_tx/test_gas.py +++ b/tests/prague/eip7702_set_code_tx/test_gas.py @@ -1279,7 +1279,9 @@ def test_call_to_pre_authorized_oog( # delegation is NOT tracked (OOG before reading it) account_expectations = { tx.sender: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], ), callee_address: BalAccountExpectation.empty(), # read for calculating delegation access cost: From ba0eec19ae48136e185eaa0c3b621337cc05bc2f Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 12 Dec 2025 14:28:25 -0700 Subject: [PATCH 52/71] fix(test-tests): Avoid hard-coding precompile range for lexicographic test --- .../test_block_access_lists.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 f56a142c8b..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 @@ -2557,9 +2557,10 @@ def test_bal_lexicographic_address_ordering( """ Test BAL enforces strict lexicographic byte-wise address ordering. - Addresses: addr_low (0x...01), addr_mid (0x...0100), addr_high (0x01...00). - Endian-trap: addr_endian_low (0x01...02), addr_endian_high (0x02...01). - Contract touches them in reverse order to verify sorting. + 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. @@ -2568,12 +2569,13 @@ def test_bal_lexicographic_address_ordering( # Create addresses with specific byte patterns for lexicographic testing # In lexicographic (byte-wise) order: low < mid < high - # addr_low: 0x00...01 (rightmost byte = 0x01) - # addr_mid: 0x00...0100 (second-rightmost byte = 0x01) - # addr_high: 0x01...00 (leftmost byte = 0x01) - addr_low = Address("0x0000000000000000000000000000000000000001") - addr_mid = Address("0x0000000000000000000000000000000000000100") - addr_high = Address("0x0100000000000000000000000000000000000000") + # 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) From 2344445f9e74e9dd2ca8bbed70754137150fc9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:33:47 +0100 Subject: [PATCH 53/71] feat(specs): EIP-7928 move bal from payload (#1917) * feat(specs): EIP-7928 move bal from payload * remove BAL from stf * fix linter --- src/ethereum/forks/amsterdam/blocks.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index 0d14066f47..143b3d18fe 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -19,7 +19,6 @@ from ethereum.crypto.hash import Hash32 -from .block_access_lists.rlp_types import BlockAccessList from .fork_types import Address, Bloom, Root from .transactions import ( AccessListTransaction, @@ -306,13 +305,6 @@ class Block: A tuple of withdrawals processed in this block. """ - block_access_list: BlockAccessList - """ - Block Access List containing all accounts and storage locations accessed - during block execution. Introduced in [EIP-7928]. - [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 - """ - @slotted_freezable @dataclass From d32a78a1707e87d2d99840ca38faa6d0ef052079 Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 29 Dec 2025 17:37:05 -0700 Subject: [PATCH 54/71] fix(test-tests): Use `ZeroPaddedHexNumber` instead of `HexNumber` for BALs (#1922) * fix(test-tests): Use ZeroPaddedHexNumber instead of HexNumber for consistency * chore(test-tests): add bal serialization roundtrip & 0-padded hex test (#33) * refactor(test): Remove unnecessary instantiation of classes to pydantic types --------- Co-authored-by: danceratopz --- .../block_access_list/account_changes.py | 28 ++--- .../test_types/block_access_list/modifiers.py | 37 ++++-- .../test_block_access_list_expectation.py | 8 +- .../test_block_access_list_serialization.py | 86 +++++++++++++ .../tests/test_block_access_list_t8n.py | 116 ++++++++++-------- 5 files changed, 199 insertions(+), 76 deletions(-) create mode 100644 packages/testing/src/execution_testing/test_types/tests/test_block_access_list_serialization.py 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 92179f8e0d..3378b05432 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,8 +13,8 @@ Address, Bytes, CamelModel, - HexNumber, RLPSerializable, + ZeroPaddedHexNumber, ) @@ -23,11 +23,11 @@ class BalNonceChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - block_access_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" ) @@ -39,11 +39,11 @@ class BalBalanceChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - block_access_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" ) @@ -55,8 +55,8 @@ class BalCodeChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - block_access_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") @@ -69,11 +69,11 @@ class BalStorageChange(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - block_access_index: HexNumber = Field( - HexNumber(1), + block_access_index: ZeroPaddedHexNumber = Field( + ZeroPaddedHexNumber(1), description="Transaction index where the change occurred", ) - post_value: HexNumber = Field( + post_value: ZeroPaddedHexNumber = Field( ..., description="Value after the transaction" ) @@ -85,7 +85,7 @@ class BalStorageSlot(CamelModel, RLPSerializable): model_config = CamelModel.model_config | {"extra": "forbid"} - slot: HexNumber = 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" ) @@ -111,7 +111,7 @@ class BalAccountChange(CamelModel, RLPSerializable): storage_changes: List[BalStorageSlot] = Field( default_factory=list, description="List of storage changes" ) - storage_reads: List[HexNumber] = 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/modifiers.py b/packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py index d28f7099ba..b71f8e73f8 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 ( @@ -257,20 +260,28 @@ def transform(bal: BlockAccessList) -> BlockAccessList: for nonce_change in new_account.nonce_changes: if nonce_change.block_access_index == tx1: nonce_indices[tx1] = True - nonce_change.block_access_index = HexNumber(tx2) + nonce_change.block_access_index = ZeroPaddedHexNumber( + tx2 + ) elif nonce_change.block_access_index == tx2: nonce_indices[tx2] = True - nonce_change.block_access_index = HexNumber(tx1) + nonce_change.block_access_index = ZeroPaddedHexNumber( + tx1 + ) # Swap in balance changes if new_account.balance_changes: for balance_change in new_account.balance_changes: if balance_change.block_access_index == tx1: balance_indices[tx1] = True - balance_change.block_access_index = HexNumber(tx2) + balance_change.block_access_index = ( + ZeroPaddedHexNumber(tx2) + ) elif balance_change.block_access_index == tx2: balance_indices[tx2] = True - balance_change.block_access_index = HexNumber(tx1) + balance_change.block_access_index = ( + ZeroPaddedHexNumber(tx1) + ) # Swap in storage changes (nested structure) if new_account.storage_changes: @@ -278,10 +289,14 @@ def transform(bal: BlockAccessList) -> BlockAccessList: for storage_change in storage_slot.slot_changes: if storage_change.block_access_index == tx1: balance_indices[tx1] = True - storage_change.block_access_index = HexNumber(tx2) + storage_change.block_access_index = ( + ZeroPaddedHexNumber(tx2) + ) elif storage_change.block_access_index == tx2: balance_indices[tx2] = True - storage_change.block_access_index = HexNumber(tx1) + storage_change.block_access_index = ( + ZeroPaddedHexNumber(tx1) + ) # Note: storage_reads is just a list of StorageKey, no block_access_index to # swap @@ -291,10 +306,14 @@ def transform(bal: BlockAccessList) -> BlockAccessList: for code_change in new_account.code_changes: if code_change.block_access_index == tx1: code_indices[tx1] = True - code_change.block_access_index = HexNumber(tx2) + code_change.block_access_index = ZeroPaddedHexNumber( + tx2 + ) elif code_change.block_access_index == tx2: code_indices[tx2] = True - code_change.block_access_index = HexNumber(tx1) + code_change.block_access_index = ZeroPaddedHexNumber( + tx1 + ) new_root.append(new_account) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index f8b16d4474..ae495e9bd7 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py @@ -567,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: @@ -614,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: @@ -759,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: @@ -898,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) 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 index c33cf8a2c7..942abf6bb2 100644 --- 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 @@ -9,7 +9,7 @@ import pytest -from execution_testing.base_types import Address, HexNumber, StorageKey +from execution_testing.base_types import Address from execution_testing.test_types.block_access_list import ( BalAccountChange, BalBalanceChange, @@ -61,9 +61,9 @@ def test_bal_storage_slot_ordering() -> None: BalAccountChange( address=addr, storage_changes=[ - BalStorageSlot(slot=StorageKey(0), slot_changes=[]), - BalStorageSlot(slot=StorageKey(1), slot_changes=[]), - BalStorageSlot(slot=StorageKey(2), slot_changes=[]), + BalStorageSlot(slot=0, slot_changes=[]), + BalStorageSlot(slot=1, slot_changes=[]), + BalStorageSlot(slot=2, slot_changes=[]), ], ) ] @@ -76,9 +76,9 @@ def test_bal_storage_slot_ordering() -> None: BalAccountChange( address=addr, storage_changes=[ - BalStorageSlot(slot=StorageKey(0), slot_changes=[]), - BalStorageSlot(slot=StorageKey(2), slot_changes=[]), - BalStorageSlot(slot=StorageKey(1), slot_changes=[]), + BalStorageSlot(slot=0, slot_changes=[]), + BalStorageSlot(slot=2, slot_changes=[]), + BalStorageSlot(slot=1, slot_changes=[]), ], ) ] @@ -100,7 +100,7 @@ def test_bal_storage_reads_ordering() -> None: [ BalAccountChange( address=addr, - storage_reads=[StorageKey(0), StorageKey(1), StorageKey(2)], + storage_reads=[0, 1, 2], ) ] ) @@ -111,7 +111,7 @@ def test_bal_storage_reads_ordering() -> None: [ BalAccountChange( address=addr, - storage_reads=[StorageKey(0), StorageKey(2), StorageKey(1)], + storage_reads=[0, 2, 1], ) ] ) @@ -142,59 +142,71 @@ def test_bal_block_access_indices_ordering(field_name: str) -> None: if field_name == "nonce_changes": changes_valid = [ BalNonceChange( - block_access_index=HexNumber(1), post_nonce=HexNumber(1) + block_access_index=1, + post_nonce=1, ), BalNonceChange( - block_access_index=HexNumber(2), post_nonce=HexNumber(2) + block_access_index=2, + post_nonce=2, ), BalNonceChange( - block_access_index=HexNumber(3), post_nonce=HexNumber(3) + block_access_index=3, + post_nonce=3, ), ] changes_invalid = [ BalNonceChange( - block_access_index=HexNumber(1), post_nonce=HexNumber(1) + block_access_index=1, + post_nonce=1, ), BalNonceChange( - block_access_index=HexNumber(3), post_nonce=HexNumber(3) + block_access_index=3, + post_nonce=3, ), BalNonceChange( - block_access_index=HexNumber(2), post_nonce=HexNumber(2) + block_access_index=2, + post_nonce=2, ), ] elif field_name == "balance_changes": changes_valid = [ BalBalanceChange( - block_access_index=HexNumber(1), post_balance=HexNumber(100) + block_access_index=1, + post_balance=100, ), BalBalanceChange( - block_access_index=HexNumber(2), post_balance=HexNumber(200) + block_access_index=2, + post_balance=200, ), BalBalanceChange( - block_access_index=HexNumber(3), post_balance=HexNumber(300) + block_access_index=3, + post_balance=300, ), ] changes_invalid = [ BalBalanceChange( - block_access_index=HexNumber(1), post_balance=HexNumber(100) + block_access_index=1, + post_balance=100, ), BalBalanceChange( - block_access_index=HexNumber(3), post_balance=HexNumber(300) + block_access_index=3, + post_balance=300, ), BalBalanceChange( - block_access_index=HexNumber(2), post_balance=HexNumber(200) + block_access_index=2, + post_balance=200, ), ] elif field_name == "code_changes": changes_valid = [ - BalCodeChange(block_access_index=HexNumber(1), new_code=b"code1"), - BalCodeChange(block_access_index=HexNumber(2), new_code=b"code2"), - BalCodeChange(block_access_index=HexNumber(3), new_code=b"code3"), + 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=HexNumber(1), new_code=b"code1"), - BalCodeChange(block_access_index=HexNumber(3), new_code=b"code3"), - BalCodeChange(block_access_index=HexNumber(2), new_code=b"code2"), + 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( @@ -229,34 +241,40 @@ def test_bal_duplicate_block_access_indices(field_name: str) -> None: if field_name == "nonce_changes": changes = [ BalNonceChange( - block_access_index=HexNumber(1), post_nonce=HexNumber(1) + block_access_index=1, + post_nonce=1, ), BalNonceChange( - block_access_index=HexNumber(1), post_nonce=HexNumber(2) + block_access_index=1, + post_nonce=2, ), # duplicate block_access_index BalNonceChange( - block_access_index=HexNumber(2), post_nonce=HexNumber(3) + block_access_index=2, + post_nonce=3, ), ] elif field_name == "balance_changes": changes = [ BalBalanceChange( - block_access_index=HexNumber(1), post_balance=HexNumber(100) + block_access_index=1, + post_balance=100, ), BalBalanceChange( - block_access_index=HexNumber(1), post_balance=HexNumber(200) + block_access_index=1, + post_balance=200, ), # duplicate block_access_index BalBalanceChange( - block_access_index=HexNumber(2), post_balance=HexNumber(300) + block_access_index=2, + post_balance=300, ), ] elif field_name == "code_changes": changes = [ - BalCodeChange(block_access_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(block_access_index=1, new_code=b"code1"), BalCodeChange( - block_access_index=HexNumber(1), new_code=b"" + block_access_index=1, new_code=b"" ), # duplicate block_access_index - BalCodeChange(block_access_index=HexNumber(2), new_code=b"code2"), + BalCodeChange(block_access_index=2, new_code=b"code2"), ] bal = BlockAccessList( @@ -283,19 +301,19 @@ def test_bal_storage_duplicate_block_access_indices() -> None: address=addr, storage_changes=[ BalStorageSlot( - slot=StorageKey(0), + slot=0, slot_changes=[ BalStorageChange( - block_access_index=HexNumber(1), - post_value=StorageKey(100), + block_access_index=1, + post_value=100, ), BalStorageChange( - block_access_index=HexNumber(1), - post_value=StorageKey(200), + block_access_index=1, + post_value=200, ), # duplicate block_access_index BalStorageChange( - block_access_index=HexNumber(2), - post_value=StorageKey(300), + block_access_index=2, + post_value=300, ), ], ) @@ -325,12 +343,12 @@ def test_bal_multiple_violations() -> None: address=bob, # Should come after alice nonce_changes=[ BalNonceChange( - block_access_index=HexNumber(1), - post_nonce=HexNumber(1), + block_access_index=1, + post_nonce=1, ), BalNonceChange( - block_access_index=HexNumber(1), - post_nonce=HexNumber(2), + block_access_index=1, + post_nonce=2, ), # duplicate ], ), @@ -360,8 +378,8 @@ def test_bal_single_account_valid() -> None: address=Address(0xA), nonce_changes=[ BalNonceChange( - block_access_index=HexNumber(1), - post_nonce=HexNumber(1), + block_access_index=1, + post_nonce=1, ) ], ) From 60fcfcc1e9a438fcd96199afd7b75dcc5a0aae3e Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 30 Dec 2025 13:57:01 -0700 Subject: [PATCH 55/71] =?UTF-8?q?refactor(spec-specs):=20Refactor=20specs?= =?UTF-8?q?=20to=20be=20more=20coherent=20wrt=20gas=20acco=E2=80=A6=20(#18?= =?UTF-8?q?97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(spec-specs): Refactor specs to be more coherent wrt gas accounting * feat(test): BAL test for call with value in static context --- .../forks/amsterdam/vm/eoa_delegation.py | 53 +--- .../amsterdam/vm/instructions/storage.py | 53 ++-- .../forks/amsterdam/vm/instructions/system.py | 300 ++++++++---------- .../test_block_access_lists_opcodes.py | 74 +++++ .../test_cases.md | 1 + 5 files changed, 236 insertions(+), 245 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 1f1aac9d97..4509c5917f 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 @@ -127,13 +126,9 @@ def recover_authority(authorization: Authorization) -> Address: def calculate_delegation_cost( evm: Evm, address: Address -) -> Tuple[bool, Address, Optional[Address], Uint]: +) -> Tuple[bool, Address, Uint]: """ - Check if address has delegation and calculate delegation target gas cost. - - This function reads the original account's code to check for delegation - and tracks it in state_changes. It calculates the delegation target's - gas cost but does NOT read the delegation target yet. + Get the delegation address and the cost of access from the address. Parameters ---------- @@ -144,9 +139,8 @@ def calculate_delegation_cost( Returns ------- - delegation_info : `Tuple[bool, Address, Optional[Address], Uint]` - (is_delegated, original_address, delegated_address_or_none, - delegation_gas_cost) + delegation : `Tuple[bool, Address, Uint]` + The delegation address and access gas cost. """ state = evm.message.block_env.state @@ -155,51 +149,16 @@ def calculate_delegation_cost( track_address(evm.state_changes, address) if not is_valid_delegation(code): - return False, address, None, Uint(0) + return False, address, Uint(0) delegated_address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) - # Calculate gas cost for delegation target access if delegated_address in evm.accessed_addresses: delegation_gas_cost = GAS_WARM_ACCESS else: delegation_gas_cost = GAS_COLD_ACCOUNT_ACCESS - return True, address, delegated_address, delegation_gas_cost - - -def read_delegation_target(evm: Evm, delegated_address: Address) -> Bytes: - """ - Read the delegation target's code and track the access. - - Should ONLY be called AFTER verifying we have gas for the access. - - This function: - 1. Reads the delegation target's code from state - 2. Adds it to accessed_addresses (if not already there) - 3. Tracks it in state_changes for BAL - - Parameters - ---------- - evm : `Evm` - The execution frame. - delegated_address : `Address` - The delegation target address. - - Returns - ------- - code : `Bytes` - The delegation target's code. - - """ - state = evm.message.block_env.state - - if delegated_address not in evm.accessed_addresses: - evm.accessed_addresses.add(delegated_address) - - track_address(evm.state_changes, delegated_address) - - return get_account(state, delegated_address).code + return True, delegated_address, delegation_gas_cost def set_delegation(message: Message) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index de7ef935f5..18afa2a2ba 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -55,20 +55,16 @@ def sload(evm: Evm) -> None: key = pop(evm.stack).to_be_bytes32() # GAS - is_cold_access = ( - evm.message.current_target, - key, - ) not in evm.accessed_storage_keys - gas_cost = GAS_COLD_SLOAD if is_cold_access else GAS_WARM_ACCESS - - charge_gas(evm, gas_cost) + if (evm.message.current_target, key) in evm.accessed_storage_keys: + charge_gas(evm, GAS_WARM_ACCESS) + else: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + charge_gas(evm, GAS_COLD_SLOAD) # OPERATION - state = evm.message.block_env.state - value = get_storage(state, evm.message.current_target, key) - - if is_cold_access: - evm.accessed_storage_keys.add((evm.message.current_target, key)) + value = get_storage( + evm.message.block_env.state, evm.message.current_target, key + ) track_storage_read( evm.state_changes, evm.message.current_target, @@ -91,6 +87,9 @@ 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) @@ -98,28 +97,17 @@ def sstore(evm: Evm) -> None: # check we have at least the stipend gas check_gas(evm, GAS_CALL_STIPEND + Uint(1)) - # check static context before accessing storage - if evm.message.is_static: - raise WriteInStaticContext - - # GAS - gas_cost = Uint(0) - is_cold_access = ( - evm.message.current_target, - key, - ) not in evm.accessed_storage_keys - - if is_cold_access: - gas_cost += GAS_COLD_SLOAD - state = evm.message.block_env.state original_value = get_storage_original( state, evm.message.current_target, key ) current_value = get_storage(state, evm.message.current_target, key) - if is_cold_access: + gas_cost = Uint(0) + + if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) + gas_cost += GAS_COLD_SLOAD capture_pre_storage( evm.message.tx_env.state_changes, @@ -141,9 +129,7 @@ def sstore(evm: Evm) -> None: else: gas_cost += GAS_WARM_ACCESS - charge_gas(evm, gas_cost) - - # REFUND COUNTER + # Refund Counter Calculation if current_value != new_value: if original_value != 0 and current_value != 0 and new_value == 0: # Storage is cleared for the first time in the transaction @@ -164,7 +150,7 @@ def sstore(evm: Evm) -> None: GAS_STORAGE_UPDATE - GAS_COLD_SLOAD - GAS_WARM_ACCESS ) - # OPERATION + charge_gas(evm, gas_cost) set_storage(state, evm.message.current_target, key, new_value) track_storage_write( evm.state_changes, @@ -214,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 e5e2ec306f..02604f68f2 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -16,7 +16,6 @@ from ethereum.utils.numeric import ceil32 -# track_address_access removed - now using state_changes.track_address() from ...fork_types import Address from ...state import ( account_has_code_or_nonce, @@ -41,7 +40,6 @@ ) from ...vm.eoa_delegation import ( calculate_delegation_cost, - read_delegation_target, ) from .. import ( Evm, @@ -169,7 +167,6 @@ def generic_create( is_create=True, state_changes=child_state_changes, ) - child_evm = process_create_message(child_message) if child_evm.error: @@ -407,6 +404,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, @@ -417,69 +417,56 @@ def call(evm: Evm) -> None: ) is_cold_access = to not in evm.accessed_addresses - access_gas_cost = ( - GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS - ) + 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, ) - # need to access account to check if account is alive, check gas before - create_gas_cost = GAS_NEW_ACCOUNT - if value == 0 or is_account_alive(evm.message.block_env.state, to): - create_gas_cost = Uint(0) - + # STATE ACCESS + state = evm.message.block_env.state if is_cold_access: evm.accessed_addresses.add(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 ( is_delegated, - original_address, - delegated_address, - delegation_gas_cost, + code_address, + delegation_access_cost, ) = calculate_delegation_cost(evm, to) - if is_delegated and delegation_gas_cost > Uint(0): - assert delegated_address is not None - message_call_gas = calculate_message_call_gas( - value, - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost - + transfer_gas_cost - + create_gas_cost - + delegation_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = read_delegation_target(evm, delegated_address) - final_address = delegated_address - else: - 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, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = get_account(evm.message.block_env.state, to).code - final_address = to - - code_address = final_address - disable_precompiles = is_delegated - + 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( + value, + gas, + Uint(evm.gas_left), + extend_memory.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"" @@ -499,7 +486,7 @@ def call(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER @@ -537,55 +524,48 @@ def callcode(evm: Evm) -> None: ) is_cold_access = code_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(code_address) + 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, ) - # need to access account to get delegation code, check gas before + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: + evm.accessed_addresses.add(code_address) + + extra_gas = access_gas_cost + transfer_gas_cost ( is_delegated, - original_address, - delegated_address, - delegation_gas_cost, + code_address, + delegation_access_cost, ) = calculate_delegation_cost(evm, code_address) - if is_delegated and delegation_gas_cost > Uint(0): - assert delegated_address is not None - # Recalculate with delegation cost and check gas - message_call_gas = calculate_message_call_gas( - value, - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost + transfer_gas_cost + delegation_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = read_delegation_target(evm, delegated_address) - final_address = delegated_address - else: - message_call_gas = calculate_message_call_gas( - value, - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost + transfer_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = get_account(evm.message.block_env.state, code_address).code - final_address = code_address - - code_address = final_address - disable_precompiles = is_delegated - + 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( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + extra_gas, + ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -623,7 +603,7 @@ def callcode(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER @@ -648,26 +628,26 @@ def selfdestruct(evm: Evm) -> None: # GAS gas_cost = GAS_SELF_DESTRUCT + 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) - # is_account_alive requires account to be accessed, check gas before + # 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 - if is_cold_access: - evm.accessed_addresses.add(beneficiary) - track_address(evm.state_changes, beneficiary) - charge_gas(evm, gas_cost) state = evm.message.block_env.state @@ -703,6 +683,8 @@ def selfdestruct(evm: Evm) -> None: # register account for deletion only if it was created # in the same transaction if originator in state.created_accounts: + # If beneficiary is the same as originator, then + # the ether is burnt. set_account_balance(state, originator, U256(0)) track_balance_change(evm.state_changes, originator, U256(0)) evm.accounts_to_delete.add(originator) @@ -742,49 +724,43 @@ def delegatecall(evm: Evm) -> None: ) is_cold_access = code_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(code_address) + 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) - # need to access account to get delegation code, check gas before + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: + evm.accessed_addresses.add(code_address) + + extra_gas = access_gas_cost ( is_delegated, - original_address, - delegated_address, - delegation_gas_cost, + code_address, + delegation_access_cost, ) = calculate_delegation_cost(evm, code_address) - if is_delegated and delegation_gas_cost > Uint(0): - assert delegated_address is not None - message_call_gas = calculate_message_call_gas( - U256(0), - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost + delegation_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = read_delegation_target(evm, delegated_address) - final_address = delegated_address - else: - message_call_gas = calculate_message_call_gas( - U256(0), - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = get_account(evm.message.block_env.state, code_address).code - final_address = 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_address = final_address - disable_precompiles = is_delegated + code = get_account(state, code_address).code + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + extra_gas, + ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -803,7 +779,7 @@ def delegatecall(evm: Evm) -> None: memory_output_start_position, memory_output_size, code, - disable_precompiles, + is_delegated, ) # PROGRAM COUNTER @@ -838,49 +814,43 @@ def staticcall(evm: Evm) -> None: ) is_cold_access = to 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(to) + 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) - # need to access account to get delegation code, check gas before + # STATE ACCESS + state = evm.message.block_env.state + if is_cold_access: + evm.accessed_addresses.add(to) + + extra_gas = access_gas_cost ( is_delegated, - original_address, - delegated_address, - delegation_gas_cost, + code_address, + delegation_access_cost, ) = calculate_delegation_cost(evm, to) - if is_delegated and delegation_gas_cost > Uint(0): - assert delegated_address is not None - message_call_gas = calculate_message_call_gas( - U256(0), - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost + delegation_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = read_delegation_target(evm, delegated_address) - final_address = delegated_address - else: - message_call_gas = calculate_message_call_gas( - U256(0), - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost, - ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - code = get_account(evm.message.block_env.state, to).code - final_address = 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_address = final_address - disable_precompiles = is_delegated + code = get_account(state, code_address).code + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + extra_gas, + ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -899,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/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 7a07299407..99f2ab58ba 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 @@ -2288,6 +2288,80 @@ def test_bal_sstore_static_context( ) +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, 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 0432339ead..29d6ebac68 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -112,3 +112,4 @@ | `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 | From aeae1510f2e29923bc392cbb9a01969236d3a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:22:38 +0100 Subject: [PATCH 56/71] feat(tests): add more 7928 test descriptions (#1815) * feat(tests): add more 7928 test descriptions * chore(test): remove test duplicated by test_bal_create_selfdestruct_to_self_with_call --------- Co-authored-by: fselmo --- tests/amsterdam/eip7928_block_level_access_lists/test_cases.md | 2 ++ 1 file changed, 2 insertions(+) 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 29d6ebac68..1059dac54c 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -113,3 +113,5 @@ | `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_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 | From dc1a0d22cd600ccd76e2fdf32c7974725a058d3c Mon Sep 17 00:00:00 2001 From: raxhvl <10168946+raxhvl@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:19:18 +0100 Subject: [PATCH 57/71] =?UTF-8?q?=E2=9C=A8=20feat(tests):=20EIP-7928=20tes?= =?UTF-8?q?ts=20targeting=20EIP-4788=20(#1887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(tests): 7928x4788 tests * 🧹 chore: Rename bal index * ✨ feat: zero indexed tx * 🥢 nit: Balance change for query contract * 🥢 nit: Exclude system address from BAL * 🥢 nit: Exclude system address from BAL * 📄 docs: Update test description --------- Co-authored-by: raxhvl Co-authored-by: felipe --- .../test_block_access_lists_eip4788.py | 459 ++++++++++++++++++ .../test_cases.md | 5 +- 2 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4788.py 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_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 1059dac54c..9af98fe766 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -23,7 +23,6 @@ | `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 `block_access_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 `block_access_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 `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 | @@ -113,5 +112,9 @@ | `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 | From 87cba9829c68f40e447e848d92d68707c506d873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:22:02 +0100 Subject: [PATCH 58/71] feat(tests): add invalid BAL tests for spurious block_access_index (#1953) --- tests/amsterdam/eip7928_block_level_access_lists/test_cases.md | 3 +++ 1 file changed, 3 insertions(+) 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 9af98fe766..6f70977982 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -118,3 +118,6 @@ | `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_spurious_entry_index_plus_2_with_cross_tx_read` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately read elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SLOAD(VictimContract, 0x01)` (legitimate read). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that every `block_access_index` in BAL is in-range (≤ `N` plus any allowed system ops), and **MUST NOT** accept out-of-range indices even if the `(address, slot)` appears elsewhere legitimately. | 🟡 Planned | +| `test_bal_spurious_entry_index_plus_2_with_cross_tx_write` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately written elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SSTORE(VictimContract, 0x01, 0x42)` (legitimate write). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate `block_access_index` bounds and reject spurious indices regardless of whether the referenced `(address, slot)` is otherwise accessed or mutated in the block. | 🟡 Planned | +| `test_bal_spurious_entry_index_plus_2_no_other_txs` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2` when no other transaction touches the referenced slot | Block with `N` txs that do not access `(VictimContract, slot=0x01)`. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject any BAL that contains out-of-range `block_access_index` values, independent of access patterns in the executed block. | 🟡 Planned | From 6b6d078d688a25b2b89e517e0aa9a64adf3ea880 Mon Sep 17 00:00:00 2001 From: raxhvl <10168946+raxhvl@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:32:46 +0100 Subject: [PATCH 59/71] =?UTF-8?q?=E2=9C=A8=20feat(tests):=20EIP-7928=20tes?= =?UTF-8?q?ts=20targeting=20EIP-7002=20(#1918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(test-tests): Add EIP-7002 BAL tests 🥢 nit: ✨ feat(tests): parameterised amount in clean sweep ✨ feat(tests): test_bal_7002_request_invalid ✨ feat(tests): test_bal_withdrawal_request_from_contract ✨ feat(tests): test_bal_7002_no_withdrawal_requests ♻️ refactor: ✨ feat(tests): parameter: validator key ✨ feat(tests): simplify ✨ feat(tests): test_bal_7002_partial_sweep ✨ feat(tests): test_bal_7002_clean_sweep ✨ feat: add more coverage ✨ feat: test_bal_withdrawal_request_from_eoa * fix(test-tests): lint * chore: update test docstring to match case description and test behavior * fix: balance_changes -> balance for post Accounts --------- Co-authored-by: raxhvl Co-authored-by: fselmo --- .../test_block_access_lists_eip7002.py | 810 ++++++++++++++++++ .../test_cases.md | 6 +- 2 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7002.py 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_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 6f70977982..09cd675424 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -23,7 +23,6 @@ | `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_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 `block_access_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 `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 | @@ -121,3 +120,8 @@ | `test_bal_spurious_entry_index_plus_2_with_cross_tx_read` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately read elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SLOAD(VictimContract, 0x01)` (legitimate read). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that every `block_access_index` in BAL is in-range (≤ `N` plus any allowed system ops), and **MUST NOT** accept out-of-range indices even if the `(address, slot)` appears elsewhere legitimately. | 🟡 Planned | | `test_bal_spurious_entry_index_plus_2_with_cross_tx_write` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately written elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SSTORE(VictimContract, 0x01, 0x42)` (legitimate write). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate `block_access_index` bounds and reject spurious indices regardless of whether the referenced `(address, slot)` is otherwise accessed or mutated in the block. | 🟡 Planned | | `test_bal_spurious_entry_index_plus_2_no_other_txs` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2` when no other transaction touches the referenced slot | Block with `N` txs that do not access `(VictimContract, slot=0x01)`. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject any BAL that contains out-of-range `block_access_index` values, independent of access patterns in the executed block. | 🟡 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 | From d8b5dad41ee5ddb0b599ff1928de4364a5ac9be2 Mon Sep 17 00:00:00 2001 From: raxhvl <10168946+raxhvl@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:24:52 +0100 Subject: [PATCH 60/71] =?UTF-8?q?=F0=9F=A7=AA=20test(EIP-7928):=20Test=20e?= =?UTF-8?q?xtraneous=20entries=20for=20BAL=20(#1992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧪 test(EIP-7928): test_bal_invalid_extraneous_entries * 🥢 nit: * chore: fix lint issues --------- Co-authored-by: raxhvl Co-authored-by: fselmo --- .../test_types/block_access_list/modifiers.py | 108 ++++++++++ .../test_block_access_lists_invalid.py | 193 ++++++++++++++++++ .../test_cases.md | 4 +- 3 files changed, 302 insertions(+), 3 deletions(-) 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 b71f8e73f8..6638be94e1 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 @@ -335,6 +335,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]: @@ -433,6 +539,8 @@ def transform(bal: BlockAccessList) -> BlockAccessList: # Account-level modifiers "remove_accounts", "append_account", + "append_change", + "append_storage", "duplicate_account", "reverse_accounts", "keep_only", 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 13d18c7fef..788eb0c2a3 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, @@ -643,3 +648,191 @@ def test_bal_invalid_balance_value( ) ], ) + + +@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( + # 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_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 09cd675424..b605f43136 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -117,11 +117,9 @@ | `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_spurious_entry_index_plus_2_with_cross_tx_read` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately read elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SLOAD(VictimContract, 0x01)` (legitimate read). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that every `block_access_index` in BAL is in-range (≤ `N` plus any allowed system ops), and **MUST NOT** accept out-of-range indices even if the `(address, slot)` appears elsewhere legitimately. | 🟡 Planned | -| `test_bal_spurious_entry_index_plus_2_with_cross_tx_write` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2`, even if its slot is legitimately written elsewhere in the block | Block with `N` txs. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. Additionally include another tx in the same block that performs `SSTORE(VictimContract, 0x01, 0x42)` (legitimate write). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate `block_access_index` bounds and reject spurious indices regardless of whether the referenced `(address, slot)` is otherwise accessed or mutated in the block. | 🟡 Planned | -| `test_bal_spurious_entry_index_plus_2_no_other_txs` | Ensure clients reject BALs containing a spurious entry at `bal_index = len(transactions)+2` when no other transaction touches the referenced slot | Block with `N` txs that do not access `(VictimContract, slot=0x01)`. BAL is modified to include an extra `StorageKey` entry with `block_access_index = N+2` for `(VictimContract, slot=0x01)`. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject any BAL that contains out-of-range `block_access_index` values, independent of access patterns in the executed block. | 🟡 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 | From c5db5ae373061fbd81d601c1c311f9115acaa8aa Mon Sep 17 00:00:00 2001 From: Guruprasad Kamath <48196632+gurukamath@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:02:29 +0100 Subject: [PATCH 61/71] refactor(eip7928): refactor net zero filtering in BALs (#1899) * refactor(eip7928): refactor net zero filtering in BALs * refactor(eip7928): handle selfdestruct correctly --- src/ethereum/forks/amsterdam/fork.py | 12 +- src/ethereum/forks/amsterdam/state_tracker.py | 138 +++++++++--------- .../forks/amsterdam/vm/interpreter.py | 3 + 3 files changed, 74 insertions(+), 79 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 7da3ed03ce..3e45c3e953 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -681,7 +681,7 @@ def process_system_transaction( # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) - commit_transaction_frame(tx_env.state_changes, block_env.state) + commit_transaction_frame(tx_env.state_changes) return system_tx_output @@ -1091,15 +1091,11 @@ def process_transaction( 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, block_env.state) - - # EIP-7928: Track in-transaction self-destruct normalization AFTER merge - # Convert storage writes to reads and remove nonce/code changes - for address in tx_output.accounts_to_delete: - track_selfdestruct(block_env.state_changes, address) + commit_transaction_frame(tx_env.state_changes) def process_withdrawals( @@ -1140,7 +1136,7 @@ def increase_recipient_balance(recipient: Account) -> None: 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, block_env.state) + 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_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 05461ea89b..3ed1360e62 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -12,7 +12,7 @@ """ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple +from typing import Dict, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U64, U256, Uint @@ -20,9 +20,6 @@ from .block_access_lists.rlp_types import BlockAccessIndex from .fork_types import Address -if TYPE_CHECKING: - from .state import State - @dataclass class StateChanges: @@ -135,30 +132,13 @@ def capture_pre_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_nonce( - tx_frame: StateChanges, address: Address, nonce: U64 -) -> None: - """ - Capture pre-nonce if not already captured (first-write-wins). - - Parameters - ---------- - tx_frame : - The transaction-level frame. - address : - The address whose nonce to capture. - nonce : - The current nonce value. - - """ - if address not in tx_frame.pre_nonces: - tx_frame.pre_nonces[address] = nonce - - def capture_pre_storage( tx_frame: StateChanges, address: Address, key: Bytes32, value: U256 ) -> None: @@ -177,6 +157,9 @@ def capture_pre_storage( 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 @@ -198,6 +181,9 @@ def capture_pre_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 @@ -328,7 +314,7 @@ def track_code_change( def track_selfdestruct( - state_changes: StateChanges, + tx_frame: StateChanges, address: Address, ) -> None: """ @@ -339,30 +325,42 @@ def track_selfdestruct( Parameters ---------- - state_changes : - The state changes tracker. + tx_frame : + The state changes tracker. Should be a transaction frame. address : The address that self-destructed. """ - idx = state_changes.block_access_index + # 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 - state_changes.nonce_changes = { + tx_frame.nonce_changes = { (addr, i, nonce) - for addr, i, nonce in state_changes.nonce_changes + 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 state_changes.code_changes: - del state_changes.code_changes[(address, idx)] + 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(state_changes.storage_writes.keys()): + for addr, key, i in list(tx_frame.storage_writes.keys()): if addr == address and i == idx: - del state_changes.storage_writes[(addr, key, i)] - state_changes.storage_reads.add((addr, key)) + del tx_frame.storage_writes[(addr, key, i)] + tx_frame.storage_reads.add((addr, key)) def merge_on_success(child_frame: StateChanges) -> None: @@ -436,10 +434,7 @@ def merge_on_failure(child_frame: StateChanges) -> None: # merged on failure - they are discarded -def commit_transaction_frame( - tx_frame: StateChanges, - state: "State", -) -> None: +def commit_transaction_frame(tx_frame: StateChanges) -> None: """ Commit transaction frame to block frame. @@ -450,15 +445,13 @@ def commit_transaction_frame( ---------- tx_frame : The transaction frame to commit. - state : - The current state (used for net-zero filtering). """ 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, state) + filter_net_zero_frame_changes(tx_frame) # Merge address accesses block_frame.touched_addresses.update(tx_frame.touched_addresses) @@ -506,10 +499,7 @@ def create_child_frame(parent: StateChanges) -> StateChanges: ) -def filter_net_zero_frame_changes( - tx_frame: StateChanges, - state: "State", -) -> None: +def filter_net_zero_frame_changes(tx_frame: StateChanges) -> None: """ Filter net-zero changes from transaction frame before commit. @@ -521,44 +511,50 @@ def filter_net_zero_frame_changes( ---------- tx_frame : The transaction-level state changes frame. - state : - The current state to read final values from. """ - # Import locally to avoid circular import - from .state import get_account - idx = tx_frame.block_access_index # Filter storage: compare against pre_storage, convert net-zero to reads - for addr, key, i in list(tx_frame.storage_writes.keys()): - if i != idx: - continue - final_value = tx_frame.storage_writes[(addr, key, i)] + 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 (addr, key) in tx_frame.pre_storage: - if tx_frame.pre_storage[(addr, key)] == final_value: + if pre_value == post_value: # Net-zero write - convert to read - del tx_frame.storage_writes[(addr, key, i)] + 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 = [ + addresses_to_check_balance = [ addr for (addr, i) in tx_frame.balance_changes.keys() if i == idx ] - for addr in addresses_to_check: - if addr in tx_frame.pre_balances: - pre_balance = tx_frame.pre_balances[addr] - post_balance = get_account(state, addr).balance - if pre_balance == post_balance: - del tx_frame.balance_changes[(addr, 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 - for addr, i in list(tx_frame.code_changes.keys()): - if i != idx: - continue - final_code = tx_frame.code_changes[(addr, i)] - pre_code = tx_frame.pre_code.get(addr, b"") - if pre_code == final_code: - del tx_frame.code_changes[(addr, i)] + 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/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 154c56de11..d73ba88a72 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -47,6 +47,7 @@ from ..state_tracker import ( StateChanges, capture_pre_balance, + capture_pre_code, merge_on_failure, merge_on_success, track_address, @@ -213,6 +214,8 @@ def process_create_message(message: Message) -> Evm: 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 From 75ca03ca93bbd0a64f8089ed143793e1a8e9cdf4 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 29 Dec 2025 13:41:20 -0700 Subject: [PATCH 62/71] feat(test): OOG and success selfdestruct tests to all precompiles feat(test): Expand fork range for selfdestruct to precompile tests - For successful tests, start at Homestead where precompiles were introduced (EIPs 196, 197, 198). - For OOG tests, start at Tangerine where operation gas costs were introduced (EIP 150). --- .../src/execution_testing/forks/__init__.py | 2 + .../execution_testing/forks/forks/forks.py | 4 +- .../test_block_access_lists_opcodes.py | 89 -------- .../test_cases.md | 2 +- tests/homestead/selfdestruct/__init__.py | 1 + .../selfdestruct/test_selfdestruct.py | 198 ++++++++++++++++++ tests/homestead/yul/__init__.py | 1 - tests/tangerine/__init__.py | 4 + .../eip150_operation_gas_costs/__init__.py | 1 + .../eip150_operation_gas_costs/spec.py | 21 ++ .../test_eip150_selfdestruct.py | 117 +++++++++++ 11 files changed, 347 insertions(+), 93 deletions(-) create mode 100644 tests/homestead/selfdestruct/__init__.py create mode 100644 tests/homestead/selfdestruct/test_selfdestruct.py delete mode 100644 tests/homestead/yul/__init__.py create mode 100644 tests/tangerine/__init__.py create mode 100644 tests/tangerine/eip150_operation_gas_costs/__init__.py create mode 100644 tests/tangerine/eip150_operation_gas_costs/spec.py create mode 100644 tests/tangerine/eip150_operation_gas_costs/test_eip150_selfdestruct.py diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 009d13837f..4000069af1 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -25,6 +25,7 @@ Paris, Prague, Shanghai, + Tangerine, ) from .forks.transition import ( BerlinToLondonAt5, @@ -97,6 +98,7 @@ "Frontier", "GrayGlacier", "Homestead", + "Tangerine", "InvalidForkError", "Istanbul", "London", diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index c6b45b426f..6beb9de746 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -935,7 +935,7 @@ class DAOFork(Homestead, ignore=True): pass -class Tangerine(DAOFork, ignore=True): +class Tangerine(DAOFork, transition_tool_name="TangerineWhistle"): """Tangerine fork (EIP-150).""" pass @@ -947,7 +947,7 @@ class SpuriousDragon(Tangerine, ignore=True): pass -class Byzantium(Homestead): +class Byzantium(SpuriousDragon): """Byzantium fork.""" @classmethod 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 99f2ab58ba..80d0c504a1 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 @@ -2873,95 +2873,6 @@ def test_bal_transient_storage_not_tracked( ) -@pytest.mark.pre_alloc_group( - "selfdestruct_to_precompile", - reason="Modifies precompile balance, must be isolated in EngineX format", -) -def test_bal_selfdestruct_to_precompile( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Test BAL with SELFDESTRUCT to precompile (ecrecover 0x01). - - Victim (balance=100) selfdestructs to precompile 0x01. - - Expected BAL: - - Victim: balance_changes (100→0) - - Precompile 0x01: balance_changes (0→100), no code/nonce changes - """ - alice = pre.fund_eoa() - - contract_balance = 100 - ecrecover_precompile = Address(1) # 0x0000...0001 - - # Contract that selfdestructs to ecrecover precompile - victim_code = Op.SELFDESTRUCT(ecrecover_precompile) - - victim = pre.deploy_contract(code=victim_code, balance=contract_balance) - - # Caller that triggers the selfdestruct - caller_code = Op.CALL(100_000, victim, 0, 0, 0, 0, 0) + Op.STOP - caller = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller, - 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) - ], - ), - caller: BalAccountExpectation.empty(), - # Victim (selfdestructing contract): balance changes 100→0 - # Explicitly verify ALL fields to avoid false positives - victim: BalAccountExpectation( - nonce_changes=[], # Contract nonce unchanged - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) - ], - code_changes=[], # Code unchanged (post-Cancun) - storage_changes=[], # No storage changes - storage_reads=[], # No storage reads - ), - # Precompile receives selfdestruct balance - # Explicitly verify ALL fields to avoid false positives - ecrecover_precompile: BalAccountExpectation( - nonce_changes=[], # MUST NOT have nonce changes - balance_changes=[ - BalBalanceChange( - block_access_index=1, post_balance=contract_balance - ) - ], - 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), - caller: Account(), - # Victim still exists with 0 balance (post-Cancun SELFDESTRUCT) - victim: Account(balance=0), - # Precompile has received the balance - ecrecover_precompile: Account(balance=contract_balance), - }, - ) - - def test_bal_create_early_failure( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index b605f43136..0d2fc153d1 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -103,7 +103,7 @@ | `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_selfdestruct_to_precompile` | Ensure BAL captures SELFDESTRUCT with precompile as beneficiary | Caller triggers victim contract (balance=100) to execute `SELFDESTRUCT(0x0000...0001)` (ecrecover precompile). Precompile starts with balance=0. | BAL **MUST** include: (1) Contract with `balance_changes` (100→0, loses balance to selfdestruct). (2) Precompile address 0x01 with `balance_changes` (0→100, receives selfdestruct balance). Precompile **MUST NOT** have `code_changes` or `nonce_changes`. This complements `test_bal_withdrawal_to_precompiles` (withdrawal) and `test_bal_precompile_funded` (tx value). | ✅ Completed | +| `test_bal_selfdestruct_to_precompile_and_oog` | Ensure BAL captures SELFDESTRUCT to precompile at different gas boundaries | Victim executes `SELFDESTRUCT(precompile)`. Parameterized by all precompiles and three scenarios: (1) Success, (2) OOG before state access, (3) OOG after state access. | Success: victim and precompile have `balance_changes`. OOG before state access: precompile **NOT** in BAL. OOG after state access: precompile in BAL with empty changes. | ✅ Completed | | `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ 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 | diff --git a/tests/homestead/selfdestruct/__init__.py b/tests/homestead/selfdestruct/__init__.py new file mode 100644 index 0000000000..8dd1611067 --- /dev/null +++ b/tests/homestead/selfdestruct/__init__.py @@ -0,0 +1 @@ +"""Tests for SELFDESTRUCT opcode behavior in various scenarios.""" diff --git a/tests/homestead/selfdestruct/test_selfdestruct.py b/tests/homestead/selfdestruct/test_selfdestruct.py new file mode 100644 index 0000000000..7df22fc549 --- /dev/null +++ b/tests/homestead/selfdestruct/test_selfdestruct.py @@ -0,0 +1,198 @@ +"""Test the SELFDESTRUCT opcode.""" + +from typing import Dict + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalNonceChange, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Initcode, + Op, + Transaction, + compute_create_address, +) +from execution_testing.forks import Byzantium, Cancun +from execution_testing.forks.helpers import Fork + + +@pytest.mark.pre_alloc_group( + "selfdestruct_to_precompile", + reason="Modifies precompile balance, must be isolated in EngineX format", +) +@pytest.mark.parametrize("same_tx_selfdestruct", [False, True]) +@pytest.mark.with_all_precompiles +@pytest.mark.valid_from("Homestead") +def test_selfdestruct_to_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + precompile: Address, + same_tx_selfdestruct: bool, +) -> None: + """ + Test successful SELFDESTRUCT to precompile with exact gas. + + Pre-Cancun: Contract is always destroyed. + >=Cancun (EIP-6780): Contract only destroyed if created in same + transaction. + """ + alice = pre.fund_eoa() + + victim_balance = 100 + victim_code = Op.SELFDESTRUCT(precompile) + + gas_costs = fork.gas_costs() + push_cost = gas_costs.G_VERY_LOW + selfdestruct_cost = gas_costs.G_SELF_DESTRUCT + new_account_cost = gas_costs.G_NEW_ACCOUNT + exact_gas = push_cost + selfdestruct_cost + new_account_cost + + if same_tx_selfdestruct: + # Deploy and selfdestruct in same transaction + # Factory creates victim via CREATE, then calls it + initcode = Initcode(deploy_code=victim_code) + initcode_bytes = bytes(initcode) + + # pre-calculate the factory and victim addresses + factory_address = next(pre._contract_address_iterator) # type: ignore + victim = compute_create_address(address=factory_address, nonce=1) + + factory_code = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.CREATE( + value=victim_balance, + offset=32 - len(initcode_bytes), + size=len(initcode_bytes), + ) + + Op.POP # Discard CREATE result, we know the address + + Op.CALL(gas=exact_gas, address=victim) + ) + # actual deploy using known address + factory = pre.deploy_contract( + address=factory_address, + code=factory_code, + balance=victim_balance, + ) + caller = factory + else: + # pre-existing contract + victim = pre.deploy_contract(code=victim_code, balance=victim_balance) + caller_code = Op.CALL(gas=exact_gas, address=victim) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=200_000, + protected=fork >= Byzantium, + ) + + # BAL expectations >= Amsterdam + expected_block_access_list = None + if fork.header_bal_hash_required(): + if same_tx_selfdestruct: + # Factory does CREATE (nonce 1->2) and transfers balance to victim + # Victim is created and destroyed in same tx - no net changes + account_expectations: Dict[ + Address, BalAccountExpectation | None + ] = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + code_changes=[], + storage_changes=[], + storage_reads=[], + ), + # Victim created and destroyed in same tx - empty changes + victim: BalAccountExpectation.empty(), + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=victim_balance + ) + ], + nonce_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], + ), + } + else: + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: BalAccountExpectation.empty(), + victim: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + nonce_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], + ), + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + block_access_index=1, post_balance=victim_balance + ) + ], + nonce_changes=[], + code_changes=[], + storage_changes=[], + storage_reads=[], + ), + } + expected_block_access_list = BlockAccessListExpectation( + account_expectations=account_expectations + ) + + # post state depends on fork and same_tx_selfdestruct + contract_destroyed = fork < Cancun or same_tx_selfdestruct + # Factory nonce is 2 after CREATE, otherwise caller nonce stays at 1 + caller_nonce = 2 if same_tx_selfdestruct else 1 + if contract_destroyed: + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account.NONEXISTENT, + precompile: Account(balance=victim_balance), + } + else: + # >=Cancun with pre-existing contract, code preserved + post = { + alice: Account(nonce=1), + caller: Account(nonce=caller_nonce), + victim: Account(balance=0, code=victim_code), + precompile: Account(balance=victim_balance), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=expected_block_access_list, + ) + ], + post=post, + ) 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/tangerine/__init__.py b/tests/tangerine/__init__.py new file mode 100644 index 0000000000..2d6e14d640 --- /dev/null +++ b/tests/tangerine/__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/eip150_operation_gas_costs/__init__.py b/tests/tangerine/eip150_operation_gas_costs/__init__.py new file mode 100644 index 0000000000..87e9060643 --- /dev/null +++ b/tests/tangerine/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/eip150_operation_gas_costs/spec.py b/tests/tangerine/eip150_operation_gas_costs/spec.py new file mode 100644 index 0000000000..edd24dd82d --- /dev/null +++ b/tests/tangerine/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/eip150_operation_gas_costs/test_eip150_selfdestruct.py b/tests/tangerine/eip150_operation_gas_costs/test_eip150_selfdestruct.py new file mode 100644 index 0000000000..039ec89ca9 --- /dev/null +++ b/tests/tangerine/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -0,0 +1,117 @@ +""" +Tests for EIP-150 SELFDESTRUCT operation gas costs in the Tangerine +Whistle fork. +""" + +from typing import Dict + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalNonceChange, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks import Byzantium +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 + + +@pytest.mark.pre_alloc_group( + "selfdestruct_to_precompile_oog", + reason="Modifies precompile balance, must be isolated in EngineX format", +) +@pytest.mark.parametrize("oog_before_state_access", [True, False]) +@pytest.mark.with_all_precompiles +@pytest.mark.valid_from("Tangerine") +def test_selfdestruct_to_precompile_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + precompile: Address, + oog_before_state_access: bool, +) -> None: + """ + Test SELFDESTRUCT to precompile with out-of-gas at different boundaries. + + - before_state_access: Precompile not touched (>= Amsterdam). + - after_state_access: Precompile touched but no balance change + (>= Amsterdam). + """ + alice = pre.fund_eoa() + + victim_balance = 100 + victim_code = Op.SELFDESTRUCT(precompile) + victim = pre.deploy_contract(code=victim_code, balance=victim_balance) + + gas_costs = fork.gas_costs() + push_cost = gas_costs.G_VERY_LOW + selfdestruct_cost = gas_costs.G_SELF_DESTRUCT + # exact gas would be: + # push_cost + selfdestruct_cost + new_account_cost + G_NEW_ACCOUNT + + if oog_before_state_access: + gas = push_cost + selfdestruct_cost - 1 + else: + gas = push_cost + selfdestruct_cost + + caller_code = Op.CALL(gas=gas, address=victim) + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=100_000, + protected=True if fork >= Byzantium else False, + ) + + # BAL expectations >= Amsterdam + expected_block_access_list = 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) + ], + ), + caller: BalAccountExpectation.empty(), + victim: BalAccountExpectation.empty(), + } + if oog_before_state_access: + # precompile not touched, not in BAL + account_expectations[precompile] = None + else: + # precompile touched, in BAL with empty expectation + account_expectations[precompile] = BalAccountExpectation.empty() + expected_block_access_list = BlockAccessListExpectation( + account_expectations=account_expectations + ) + + # OOG: victim keeps balance and code, precompile unchanged + post = { + alice: Account(nonce=1), + caller: Account(), + victim: Account(balance=victim_balance, code=victim_code), + precompile: Account.NONEXISTENT, + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=expected_block_access_list, + ) + ], + post=post, + ) From 5f6be00cd16dbb7b43469e19187556b049e743a9 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 30 Dec 2025 16:28:05 -0700 Subject: [PATCH 63/71] refactor(test): `Tangerine` -> `TangerineWhistle`; comments on PR #1954 Bonus: - fix(test): remove unnecessary isolation for enginex --- .../plugins/execute/eth_config/networks.yml | 2 +- .../execute/eth_config/tests/test_execute_eth_config.py | 2 +- .../cli/pytest_commands/plugins/forks/tests/test_forks.py | 5 ++++- .../pytest_commands/plugins/forks/tests/test_markers.py | 2 +- packages/testing/src/execution_testing/forks/__init__.py | 4 ++-- .../testing/src/execution_testing/forks/forks/forks.py | 8 ++++---- tests/homestead/selfdestruct/test_selfdestruct.py | 4 ---- tests/{tangerine => tangerine_whistle}/__init__.py | 0 .../eip150_operation_gas_costs/__init__.py | 0 .../eip150_operation_gas_costs/spec.py | 0 .../test_eip150_selfdestruct.py | 2 +- 11 files changed, 14 insertions(+), 15 deletions(-) rename tests/{tangerine => tangerine_whistle}/__init__.py (100%) rename tests/{tangerine => tangerine_whistle}/eip150_operation_gas_costs/__init__.py (100%) rename tests/{tangerine => tangerine_whistle}/eip150_operation_gas_costs/spec.py (100%) rename tests/{tangerine => tangerine_whistle}/eip150_operation_gas_costs/test_eip150_selfdestruct.py (98%) 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/tests/test_forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py index ed04021ded..67daaeee06 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 @@ -41,11 +41,14 @@ def test_all_forks({StateTest.pytest_parameter_name()}): forks_under_test = forks_from_until(all_forks[0], all_forks[-1]) 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..b740872cc3 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 @@ -26,7 +26,7 @@ def test_case(state_test): valid_until='"Cancun"', ), [], - {"passed": 10, "failed": 0, "skipped": 1, "errors": 0}, + {"passed": 11, "failed": 0, "skipped": 1, "errors": 0}, id="valid_until", ), pytest.param( diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 4000069af1..4a18c389b3 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -25,7 +25,7 @@ Paris, Prague, Shanghai, - Tangerine, + TangerineWhistle, ) from .forks.transition import ( BerlinToLondonAt5, @@ -98,7 +98,7 @@ "Frontier", "GrayGlacier", "Homestead", - "Tangerine", + "TangerineWhistle", "InvalidForkError", "Istanbul", "London", diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 6beb9de746..5cd9884e96 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -935,14 +935,14 @@ class DAOFork(Homestead, ignore=True): pass -class Tangerine(DAOFork, transition_tool_name="TangerineWhistle"): - """Tangerine fork (EIP-150).""" +class TangerineWhistle(DAOFork): + """TangerineWhistle fork (EIP-150).""" pass -class SpuriousDragon(Tangerine, ignore=True): - """SpuriousDragon fork (EIP-155, EIP-158).""" +class SpuriousDragon(TangerineWhistle, ignore=True): + """SpuriousDragon fork.""" pass diff --git a/tests/homestead/selfdestruct/test_selfdestruct.py b/tests/homestead/selfdestruct/test_selfdestruct.py index 7df22fc549..46890da597 100644 --- a/tests/homestead/selfdestruct/test_selfdestruct.py +++ b/tests/homestead/selfdestruct/test_selfdestruct.py @@ -22,10 +22,6 @@ from execution_testing.forks.helpers import Fork -@pytest.mark.pre_alloc_group( - "selfdestruct_to_precompile", - reason="Modifies precompile balance, must be isolated in EngineX format", -) @pytest.mark.parametrize("same_tx_selfdestruct", [False, True]) @pytest.mark.with_all_precompiles @pytest.mark.valid_from("Homestead") diff --git a/tests/tangerine/__init__.py b/tests/tangerine_whistle/__init__.py similarity index 100% rename from tests/tangerine/__init__.py rename to tests/tangerine_whistle/__init__.py diff --git a/tests/tangerine/eip150_operation_gas_costs/__init__.py b/tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py similarity index 100% rename from tests/tangerine/eip150_operation_gas_costs/__init__.py rename to tests/tangerine_whistle/eip150_operation_gas_costs/__init__.py diff --git a/tests/tangerine/eip150_operation_gas_costs/spec.py b/tests/tangerine_whistle/eip150_operation_gas_costs/spec.py similarity index 100% rename from tests/tangerine/eip150_operation_gas_costs/spec.py rename to tests/tangerine_whistle/eip150_operation_gas_costs/spec.py diff --git a/tests/tangerine/eip150_operation_gas_costs/test_eip150_selfdestruct.py b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py similarity index 98% rename from tests/tangerine/eip150_operation_gas_costs/test_eip150_selfdestruct.py rename to tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py index 039ec89ca9..ec639e4b7e 100644 --- a/tests/tangerine/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -33,7 +33,7 @@ ) @pytest.mark.parametrize("oog_before_state_access", [True, False]) @pytest.mark.with_all_precompiles -@pytest.mark.valid_from("Tangerine") +@pytest.mark.valid_from("TangerineWhistle") def test_selfdestruct_to_precompile_oog( pre: Alloc, blockchain_test: BlockchainTestFiller, From 0bbb25e42e82df9c8f6ed2224cefc8606d2f7c51 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 30 Dec 2025 17:11:03 -0700 Subject: [PATCH 64/71] fix(tool): Fix EvmOneTransitionTool parsing for TangerineWhistle (add space) --- .../execution_testing/client_clis/clis/evmone.py | 6 ++++++ .../client_clis/transition_tool.py | 13 ++++++++++--- tests/homestead/selfdestruct/test_selfdestruct.py | 12 +++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) 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 da9c0a1d7d..e3e5c89c72 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/tests/homestead/selfdestruct/test_selfdestruct.py b/tests/homestead/selfdestruct/test_selfdestruct.py index 46890da597..991f9b73c5 100644 --- a/tests/homestead/selfdestruct/test_selfdestruct.py +++ b/tests/homestead/selfdestruct/test_selfdestruct.py @@ -22,15 +22,17 @@ from execution_testing.forks.helpers import Fork -@pytest.mark.parametrize("same_tx_selfdestruct", [False, True]) @pytest.mark.with_all_precompiles +@pytest.mark.parametrize("same_tx_selfdestruct", [False, True]) +@pytest.mark.parametrize("warm_beneficiary", [False, True]) @pytest.mark.valid_from("Homestead") -def test_selfdestruct_to_precompile( +def test_selfdestruct_to_precompile_and_oog_at_minus_1( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, precompile: Address, same_tx_selfdestruct: bool, + warm_beneficiary: bool, ) -> None: """ Test successful SELFDESTRUCT to precompile with exact gas. @@ -48,7 +50,11 @@ def test_selfdestruct_to_precompile( push_cost = gas_costs.G_VERY_LOW selfdestruct_cost = gas_costs.G_SELF_DESTRUCT new_account_cost = gas_costs.G_NEW_ACCOUNT - exact_gas = push_cost + selfdestruct_cost + new_account_cost + if warm_beneficiary: + warming_cost = 0 + else: + warming_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + exact_gas = push_cost + selfdestruct_cost + new_account_cost + warming_cost if same_tx_selfdestruct: # Deploy and selfdestruct in same transaction From d807e7acc7514acf2d613c70c3f9e97a6c4ef41a Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 5 Jan 2026 22:17:19 -0700 Subject: [PATCH 65/71] feat(test): Extend selfdestruct tests to all Amsterdam gas boundaries --- .../plugins/forks/tests/test_markers.py | 2 +- .../src/execution_testing/forks/__init__.py | 2 + .../execution_testing/forks/forks/forks.py | 2 +- .../forks/amsterdam/vm/eoa_delegation.py | 1 - .../amsterdam/vm/instructions/environment.py | 8 +- .../forks/amsterdam/vm/instructions/system.py | 4 +- .../test_block_access_lists_opcodes.py | 248 ---- .../test_cases.md | 10 +- .../eip2930_access_list/test_tx_type.py | 4 +- tests/byzantium/eip196_ec_add_mul/test_gas.py | 4 +- tests/byzantium/eip197_ec_pairing/test_gas.py | 4 +- .../create/test_create_deposit_oog.py | 4 +- tests/frontier/create/test_create_one_byte.py | 4 +- .../create/test_create_suicide_during_init.py | 4 +- .../create/test_create_suicide_store.py | 4 +- tests/frontier/opcodes/test_all_opcodes.py | 4 +- tests/frontier/opcodes/test_blockhash.py | 6 +- .../test_call_and_callcode_gas_calculation.py | 9 +- tests/frontier/opcodes/test_calldatacopy.py | 4 +- tests/frontier/opcodes/test_calldataload.py | 6 +- tests/frontier/opcodes/test_calldatasize.py | 6 +- tests/frontier/opcodes/test_dup.py | 7 +- tests/frontier/opcodes/test_push.py | 6 +- tests/frontier/opcodes/test_swap.py | 6 +- tests/frontier/precompiles/test_ecrecover.py | 4 +- tests/frontier/precompiles/test_ripemd.py | 4 +- tests/homestead/selfdestruct/__init__.py | 1 - .../selfdestruct/test_selfdestruct.py | 200 ---- .../eip1559_fee_market_change/test_tx_type.py | 4 +- .../test_eip150_selfdestruct.py | 1007 ++++++++++++++++- 30 files changed, 1013 insertions(+), 566 deletions(-) delete mode 100644 tests/homestead/selfdestruct/__init__.py delete mode 100644 tests/homestead/selfdestruct/test_selfdestruct.py 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 b740872cc3..fbbc1b3123 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 @@ -26,7 +26,7 @@ def test_case(state_test): valid_until='"Cancun"', ), [], - {"passed": 11, "failed": 0, "skipped": 1, "errors": 0}, + {"passed": 10, "failed": 0, "skipped": 1, "errors": 0}, id="valid_until", ), pytest.param( diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 4a18c389b3..fb5e9b4c76 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -25,6 +25,7 @@ Paris, Prague, Shanghai, + SpuriousDragon, TangerineWhistle, ) from .forks.transition import ( @@ -99,6 +100,7 @@ "GrayGlacier", "Homestead", "TangerineWhistle", + "SpuriousDragon", "InvalidForkError", "Istanbul", "London", diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 5cd9884e96..ac76c0b87b 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -935,7 +935,7 @@ class DAOFork(Homestead, ignore=True): pass -class TangerineWhistle(DAOFork): +class TangerineWhistle(DAOFork, ignore=True): """TangerineWhistle fork (EIP-150).""" pass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 4509c5917f..e56fb0cccd 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -196,7 +196,6 @@ 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): diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 3d23b8f136..79fd56cc3c 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -84,13 +84,13 @@ def balance(evm: Evm) -> None: check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) charge_gas(evm, gas_cost) # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. state = evm.message.block_env.state balance = get_account(state, address).balance + track_address(evm.state_changes, address) push(evm.stack, balance) @@ -354,12 +354,12 @@ def extcodesize(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) charge_gas(evm, access_gas_cost) # OPERATION 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) @@ -400,13 +400,13 @@ def extcodecopy(evm: Evm) -> None: check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) charge_gas(evm, total_gas_cost) # OPERATION evm.memory += b"\x00" * extend_memory.expand_by 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) @@ -494,12 +494,12 @@ def extcodehash(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address(evm.state_changes, address) charge_gas(evm, access_gas_cost) # OPERATION 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/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 02604f68f2..9b54fab312 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -120,7 +120,6 @@ def generic_create( evm.accessed_addresses.add(contract_address) track_address(evm.state_changes, contract_address) - if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): @@ -640,7 +639,8 @@ def selfdestruct(evm: Evm) -> None: state = evm.message.block_env.state if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address(evm.state_changes, beneficiary) + + track_address(evm.state_changes, beneficiary) if ( not is_account_alive(state, beneficiary) 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 80d0c504a1..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 @@ -1729,254 +1729,6 @@ def test_bal_extcodecopy_and_oog( ) -@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(1_000_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, - ) - - 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=expected_recipient_balance, - ) - ] - ), - self_destructed_account: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(block_access_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( - block_access_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 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("oog_before_state_access", [True, False]) -def test_bal_self_destruct_oog( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - fork: Fork, - oog_before_state_access: bool, -) -> None: - """ - Test SELFDESTRUCT BAL behavior at gas boundaries. - - SELFDESTRUCT has two gas checkpoints: - 1. static checks: G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS - OOG here = no state access, beneficiary NOT in BAL - 2. state access: same as static checks, plus G_NEW_ACCOUNT for new account - OOG here = enough gas to access state but not enough for new account, - beneficiary IS in BAL - """ - alice = pre.fund_eoa() - # always use new account so we incur extra G_NEW_ACCOUNT cost - # there is no other gas boundary to test between cold access - # and new account - beneficiary = pre.empty_account() - - # selfdestruct_contract: PUSH20 SELFDESTRUCT - selfdestruct_code = Op.SELFDESTRUCT(beneficiary) - selfdestruct_contract = pre.deploy_contract( - code=selfdestruct_code, balance=1000 - ) - - # Gas needed inside the CALL for SELFDESTRUCT: - # - PUSH20: G_VERY_LOW = 3 - # - SELFDESTRUCT: G_SELF_DESTRUCT - # - G_COLD_ACCOUNT_ACCESS (beneficiary cold access) - gas_costs = fork.gas_costs() - exact_static_gas = ( - gas_costs.G_VERY_LOW - + gas_costs.G_SELF_DESTRUCT - + gas_costs.G_COLD_ACCOUNT_ACCESS - ) - - # subtract one from the exact gas to trigger OOG before state access - oog_gas = ( - exact_static_gas - 1 if oog_before_state_access else exact_static_gas - ) - - # caller_contract: CALL with oog_gas - caller_code = Op.CALL(gas=oog_gas, address=selfdestruct_contract) - caller_contract = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller_contract, - gas_limit=100_000, - ) - - account_expectations: Dict[Address, BalAccountExpectation | None] = { - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], - ), - caller_contract: BalAccountExpectation.empty(), - selfdestruct_contract: BalAccountExpectation.empty(), - # beneficiary only in BAL if we passed check_gas (state accessed) - beneficiary: None - if oog_before_state_access - else BalAccountExpectation.empty(), - } - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations=account_expectations - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - caller_contract: Account(code=caller_code), - # selfdestruct_contract still exists - SELFDESTRUCT failed - selfdestruct_contract: Account( - balance=1000, code=selfdestruct_code - ), - }, - ) - - def test_bal_storage_write_read_same_frame( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 0d2fc153d1..5bd0cf31ac 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -5,7 +5,11 @@ | `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_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_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 | @@ -27,7 +31,7 @@ | `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` | 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 | @@ -103,8 +107,6 @@ | `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_selfdestruct_to_precompile_and_oog` | Ensure BAL captures SELFDESTRUCT to precompile at different gas boundaries | Victim executes `SELFDESTRUCT(precompile)`. Parameterized by all precompiles and three scenarios: (1) Success, (2) OOG before state access, (3) OOG after state access. | Success: victim and precompile have `balance_changes`. OOG before state access: precompile **NOT** in BAL. OOG after state access: precompile in BAL with empty changes. | ✅ Completed | -| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ 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 | diff --git a/tests/berlin/eip2930_access_list/test_tx_type.py b/tests/berlin/eip2930_access_list/test_tx_type.py index c9e22e55d9..e96d8fefcd 100644 --- a/tests/berlin/eip2930_access_list/test_tx_type.py +++ b/tests/berlin/eip2930_access_list/test_tx_type.py @@ -13,7 +13,7 @@ TransactionException, ) from execution_testing import Opcodes as Op -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon from .spec import ref_spec_2930 @@ -62,7 +62,7 @@ def test_eip2930_tx_validity( sender=sender, gas_limit=100_000, access_list=[], - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, 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 1bce8fc06d..2a7d2035b8 100644 --- a/tests/byzantium/eip196_ec_add_mul/test_gas.py +++ b/tests/byzantium/eip196_ec_add_mul/test_gas.py @@ -8,7 +8,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -56,7 +56,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) 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 738edfa1c7..33dcd51fa0 100644 --- a/tests/byzantium/eip197_ec_pairing/test_gas.py +++ b/tests/byzantium/eip197_ec_pairing/test_gas.py @@ -8,7 +8,7 @@ Transaction, ) from execution_testing.base_types.base_types import Address -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -50,7 +50,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = {account: Account(storage={0: 1 if enough_gas else 0})} diff --git a/tests/frontier/create/test_create_deposit_oog.py b/tests/frontier/create/test_create_deposit_oog.py index 5e0fee4d78..e69b1ca159 100644 --- a/tests/frontier/create/test_create_deposit_oog.py +++ b/tests/frontier/create/test_create_deposit_oog.py @@ -14,7 +14,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium, Frontier, Homestead +from execution_testing.forks import Frontier, Homestead, SpuriousDragon SLOT_CREATE_RESULT = 1 SLOT_CREATE_RESULT_PRE = 0xDEADBEEF @@ -65,7 +65,7 @@ def test_create_deposit_oog( gas_limit=tx_gas_limit, to=code, sender=sender, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = { diff --git a/tests/frontier/create/test_create_one_byte.py b/tests/frontier/create/test_create_one_byte.py index ece0763403..819ff697fa 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, SpuriousDragon @pytest.mark.ported_from( @@ -100,7 +100,7 @@ def test_create_one_byte( data=b"", nonce=0, sender=sender, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = { diff --git a/tests/frontier/create/test_create_suicide_during_init.py b/tests/frontier/create/test_create_suicide_during_init.py index f8a1d3d43a..65b8deeace 100644 --- a/tests/frontier/create/test_create_suicide_during_init.py +++ b/tests/frontier/create/test_create_suicide_during_init.py @@ -14,7 +14,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon class Operation(Enum): @@ -93,7 +93,7 @@ def test_create_suicide_during_transaction_create( data=contract_initcode, value=tx_value, sender=sender, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = { diff --git a/tests/frontier/create/test_create_suicide_store.py b/tests/frontier/create/test_create_suicide_store.py index 717d307843..56f35dbb05 100644 --- a/tests/frontier/create/test_create_suicide_store.py +++ b/tests/frontier/create/test_create_suicide_store.py @@ -19,7 +19,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon class Operation(IntEnum): @@ -147,7 +147,7 @@ def test_create_suicide_store( to=create_contract, data=suicide_initcode, sender=sender, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = { diff --git a/tests/frontier/opcodes/test_all_opcodes.py b/tests/frontier/opcodes/test_all_opcodes.py index 8cc62b9ec3..e19dff20ea 100644 --- a/tests/frontier/opcodes/test_all_opcodes.py +++ b/tests/frontier/opcodes/test_all_opcodes.py @@ -21,7 +21,7 @@ UndefinedOpcodes, gas_test, ) -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon REFERENCE_SPEC_GIT_PATH = "N/A" REFERENCE_SPEC_VERSION = "N/A" @@ -183,7 +183,7 @@ def test_stack_overflow( gas_limit=100_000, to=contract, sender=pre.fund_eoa(), - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) 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..34e1e91ee1 100644 --- a/tests/frontier/opcodes/test_blockhash.py +++ b/tests/frontier/opcodes/test_blockhash.py @@ -9,7 +9,7 @@ Op, Transaction, ) -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon from execution_testing.forks.helpers import Fork @@ -60,7 +60,7 @@ def test_genesis_hash_available( sender=sender, to=contract, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) ] if not setup_blocks_empty @@ -76,7 +76,7 @@ def test_genesis_hash_available( sender=sender, to=contract, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) ] ) 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 ae3dd53df2..ee8dfc9350 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -51,7 +51,12 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Berlin, Byzantium, Homestead +from execution_testing.forks.forks.forks import ( + Berlin, + Byzantium, + Homestead, + SpuriousDragon, +) from execution_testing.forks.helpers import Fork @@ -196,7 +201,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 >= SpuriousDragon, ) diff --git a/tests/frontier/opcodes/test_calldatacopy.py b/tests/frontier/opcodes/test_calldatacopy.py index 822246aab5..d366715306 100644 --- a/tests/frontier/opcodes/test_calldatacopy.py +++ b/tests/frontier/opcodes/test_calldatacopy.py @@ -10,7 +10,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon @pytest.mark.ported_from( @@ -193,7 +193,7 @@ def test_calldatacopy( data=tx_data, gas_limit=100_000, gas_price=0x0A, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, 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 f6728e6a45..517f2dbd99 100644 --- a/tests/frontier/opcodes/test_calldataload.py +++ b/tests/frontier/opcodes/test_calldataload.py @@ -10,7 +10,7 @@ Transaction, ) from execution_testing import Macros as Om -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon @pytest.mark.ported_from( @@ -92,7 +92,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, sender=pre.fund_eoa(), to=to, ) @@ -101,7 +101,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, sender=pre.fund_eoa(), to=contract_address, ) diff --git a/tests/frontier/opcodes/test_calldatasize.py b/tests/frontier/opcodes/test_calldatasize.py index d21e73ca0a..ba3ba824f0 100644 --- a/tests/frontier/opcodes/test_calldatasize.py +++ b/tests/frontier/opcodes/test_calldatasize.py @@ -10,7 +10,7 @@ Transaction, ) from execution_testing import Macros as Om -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon @pytest.mark.ported_from( @@ -69,7 +69,7 @@ def test_calldatasize( tx = Transaction( gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, sender=pre.fund_eoa(), to=to, ) @@ -78,7 +78,7 @@ def test_calldatasize( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, sender=pre.fund_eoa(), to=contract_address, ) diff --git a/tests/frontier/opcodes/test_dup.py b/tests/frontier/opcodes/test_dup.py index 249210c07d..5ac55ad076 100644 --- a/tests/frontier/opcodes/test_dup.py +++ b/tests/frontier/opcodes/test_dup.py @@ -5,12 +5,13 @@ Account, Alloc, Environment, + Fork, Op, StateTestFiller, Storage, Transaction, ) -from execution_testing.forks import Frontier, Homestead +from execution_testing.forks import SpuriousDragon @pytest.mark.parametrize( @@ -38,7 +39,7 @@ @pytest.mark.with_all_evm_code_types def test_dup( state_test: StateTestFiller, - fork: str, + fork: Fork, dup_opcode: Op, pre: Alloc, ) -> None: @@ -73,7 +74,7 @@ def test_dup( to=account, gas_limit=500000, gas_price=10, - protected=False if fork in [Frontier, Homestead] else True, + protected=fork >= SpuriousDragon, data="", sender=sender, ) diff --git a/tests/frontier/opcodes/test_push.py b/tests/frontier/opcodes/test_push.py index b8d6d8a44e..bc79f09641 100644 --- a/tests/frontier/opcodes/test_push.py +++ b/tests/frontier/opcodes/test_push.py @@ -18,7 +18,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks import Frontier, Homestead +from execution_testing.forks import SpuriousDragon def get_input_for_push_opcode(opcode: Op) -> bytes: @@ -77,7 +77,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 >= SpuriousDragon, ) post = {} @@ -149,7 +149,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 >= SpuriousDragon, ) post = {} diff --git a/tests/frontier/opcodes/test_swap.py b/tests/frontier/opcodes/test_swap.py index f291164c95..be5107934d 100644 --- a/tests/frontier/opcodes/test_swap.py +++ b/tests/frontier/opcodes/test_swap.py @@ -13,7 +13,7 @@ Bytecode, Environment, ) -from execution_testing.forks import Frontier, Homestead +from execution_testing.forks import SpuriousDragon from execution_testing import Op from execution_testing import ( StateTestFiller, @@ -76,7 +76,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 >= SpuriousDragon, ) # Calculate expected storage values after SWAP and storage operations @@ -146,7 +146,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 >= SpuriousDragon, ) # Define the expected post-state. diff --git a/tests/frontier/precompiles/test_ecrecover.py b/tests/frontier/precompiles/test_ecrecover.py index d248b67f4c..4d2b9a0e4b 100644 --- a/tests/frontier/precompiles/test_ecrecover.py +++ b/tests/frontier/precompiles/test_ecrecover.py @@ -8,7 +8,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Byzantium +from execution_testing.forks.forks.forks import SpuriousDragon from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -388,7 +388,7 @@ def test_precompiles( to=account, sender=pre.fund_eoa(), gas_limit=1_000_000, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = {account: Account(storage={0: output})} diff --git a/tests/frontier/precompiles/test_ripemd.py b/tests/frontier/precompiles/test_ripemd.py index dc34ce1ad8..5f382e03b6 100644 --- a/tests/frontier/precompiles/test_ripemd.py +++ b/tests/frontier/precompiles/test_ripemd.py @@ -8,7 +8,7 @@ StateTestFiller, Transaction, ) -from execution_testing.forks.forks.forks import Byzantium +from execution_testing.forks.forks.forks import SpuriousDragon from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -176,7 +176,7 @@ def test_precompiles( sender=pre.fund_eoa(), gas_limit=1_000_0000, data=msg, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, ) post = {account: Account(storage={0: output if not oog else 0})} diff --git a/tests/homestead/selfdestruct/__init__.py b/tests/homestead/selfdestruct/__init__.py deleted file mode 100644 index 8dd1611067..0000000000 --- a/tests/homestead/selfdestruct/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for SELFDESTRUCT opcode behavior in various scenarios.""" diff --git a/tests/homestead/selfdestruct/test_selfdestruct.py b/tests/homestead/selfdestruct/test_selfdestruct.py deleted file mode 100644 index 991f9b73c5..0000000000 --- a/tests/homestead/selfdestruct/test_selfdestruct.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Test the SELFDESTRUCT opcode.""" - -from typing import Dict - -import pytest -from execution_testing import ( - Account, - Address, - Alloc, - BalAccountExpectation, - BalBalanceChange, - BalNonceChange, - Block, - BlockAccessListExpectation, - BlockchainTestFiller, - Initcode, - Op, - Transaction, - compute_create_address, -) -from execution_testing.forks import Byzantium, Cancun -from execution_testing.forks.helpers import Fork - - -@pytest.mark.with_all_precompiles -@pytest.mark.parametrize("same_tx_selfdestruct", [False, True]) -@pytest.mark.parametrize("warm_beneficiary", [False, True]) -@pytest.mark.valid_from("Homestead") -def test_selfdestruct_to_precompile_and_oog_at_minus_1( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - fork: Fork, - precompile: Address, - same_tx_selfdestruct: bool, - warm_beneficiary: bool, -) -> None: - """ - Test successful SELFDESTRUCT to precompile with exact gas. - - Pre-Cancun: Contract is always destroyed. - >=Cancun (EIP-6780): Contract only destroyed if created in same - transaction. - """ - alice = pre.fund_eoa() - - victim_balance = 100 - victim_code = Op.SELFDESTRUCT(precompile) - - gas_costs = fork.gas_costs() - push_cost = gas_costs.G_VERY_LOW - selfdestruct_cost = gas_costs.G_SELF_DESTRUCT - new_account_cost = gas_costs.G_NEW_ACCOUNT - if warm_beneficiary: - warming_cost = 0 - else: - warming_cost = gas_costs.G_COLD_ACCOUNT_ACCESS - exact_gas = push_cost + selfdestruct_cost + new_account_cost + warming_cost - - if same_tx_selfdestruct: - # Deploy and selfdestruct in same transaction - # Factory creates victim via CREATE, then calls it - initcode = Initcode(deploy_code=victim_code) - initcode_bytes = bytes(initcode) - - # pre-calculate the factory and victim addresses - factory_address = next(pre._contract_address_iterator) # type: ignore - victim = compute_create_address(address=factory_address, nonce=1) - - factory_code = ( - Op.MSTORE(0, Op.PUSH32(initcode_bytes)) - + Op.CREATE( - value=victim_balance, - offset=32 - len(initcode_bytes), - size=len(initcode_bytes), - ) - + Op.POP # Discard CREATE result, we know the address - + Op.CALL(gas=exact_gas, address=victim) - ) - # actual deploy using known address - factory = pre.deploy_contract( - address=factory_address, - code=factory_code, - balance=victim_balance, - ) - caller = factory - else: - # pre-existing contract - victim = pre.deploy_contract(code=victim_code, balance=victim_balance) - caller_code = Op.CALL(gas=exact_gas, address=victim) - caller = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller, - gas_limit=200_000, - protected=fork >= Byzantium, - ) - - # BAL expectations >= Amsterdam - expected_block_access_list = None - if fork.header_bal_hash_required(): - if same_tx_selfdestruct: - # Factory does CREATE (nonce 1->2) and transfers balance to victim - # Victim is created and destroyed in same tx - no net changes - account_expectations: Dict[ - Address, BalAccountExpectation | None - ] = { - alice: BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - ), - caller: BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=2) - ], - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) - ], - code_changes=[], - storage_changes=[], - storage_reads=[], - ), - # Victim created and destroyed in same tx - empty changes - victim: BalAccountExpectation.empty(), - precompile: BalAccountExpectation( - balance_changes=[ - BalBalanceChange( - block_access_index=1, post_balance=victim_balance - ) - ], - nonce_changes=[], - code_changes=[], - storage_changes=[], - storage_reads=[], - ), - } - else: - account_expectations = { - alice: BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - ), - caller: BalAccountExpectation.empty(), - victim: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) - ], - nonce_changes=[], - code_changes=[], - storage_changes=[], - storage_reads=[], - ), - precompile: BalAccountExpectation( - balance_changes=[ - BalBalanceChange( - block_access_index=1, post_balance=victim_balance - ) - ], - nonce_changes=[], - code_changes=[], - storage_changes=[], - storage_reads=[], - ), - } - expected_block_access_list = BlockAccessListExpectation( - account_expectations=account_expectations - ) - - # post state depends on fork and same_tx_selfdestruct - contract_destroyed = fork < Cancun or same_tx_selfdestruct - # Factory nonce is 2 after CREATE, otherwise caller nonce stays at 1 - caller_nonce = 2 if same_tx_selfdestruct else 1 - if contract_destroyed: - post = { - alice: Account(nonce=1), - caller: Account(nonce=caller_nonce), - victim: Account.NONEXISTENT, - precompile: Account(balance=victim_balance), - } - else: - # >=Cancun with pre-existing contract, code preserved - post = { - alice: Account(nonce=1), - caller: Account(nonce=caller_nonce), - victim: Account(balance=0, code=victim_code), - precompile: Account(balance=victim_balance), - } - - blockchain_test( - pre=pre, - blocks=[ - Block( - txs=[tx], - expected_block_access_list=expected_block_access_list, - ) - ], - post=post, - ) 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..d760d554f4 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,7 @@ TransactionException, ) from execution_testing import Opcodes as Op -from execution_testing.forks import Byzantium +from execution_testing.forks import SpuriousDragon from .spec import ref_spec_1559 @@ -62,7 +62,7 @@ def test_eip1559_tx_validity( sender=sender, gas_limit=100_000, max_priority_fee_per_gas=1, - protected=fork >= Byzantium, + protected=fork >= SpuriousDragon, error=TransactionException.TYPE_2_TX_PRE_FORK if not valid else None, ) 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 index ec639e4b7e..dd494164b9 100644 --- a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -1,24 +1,32 @@ """ -Tests for EIP-150 SELFDESTRUCT operation gas costs in the Tangerine -Whistle fork. +Tests for EIP-150 SELFDESTRUCT operation gas costs. + +EIP-150 introduced the 5000 gas cost 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.forks import Byzantium +from execution_testing.forks import Berlin, Cancun, SpuriousDragon from execution_testing.forks.helpers import Fork from .spec import ref_spec_150 @@ -27,91 +35,970 @@ REFERENCE_SPEC_VERSION = ref_spec_150.version -@pytest.mark.pre_alloc_group( - "selfdestruct_to_precompile_oog", - reason="Modifies precompile balance, must be isolated in EngineX format", +# --- 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 = ( + gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT + ) # PUSH + SELFDESTRUCT + + # 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) + + # Pre-deploy initcode at separate address, then use EXTCODECOPY + initcode_address = pre.deploy_contract(initcode) + + factory_address = next(pre._contract_address_iterator) # type: ignore + victim = compute_create_address(address=factory_address, nonce=1) + + factory_code = ( + Op.EXTCODECOPY(initcode_address, 0, 0, initcode_len) + + Op.CREATE(value=originator_balance, offset=0, size=initcode_len) + + Op.POP + + Op.CALL(gas=inner_call_gas, address=victim) + ) + caller = pre.deploy_contract( + address=factory_address, + code=factory_code, + balance=originator_balance, + ) + 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) + + # 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 >= SpuriousDragon, + 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 + if originator_balance > 0: + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=originator_balance, + ) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, new_code=bytes(victim_code) + ) + ], + ) + else: + 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) + ) + ], + ) + 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: + # Only include balance_changes if originator_balance > 0 + if originator_balance > 0: + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + ) + else: + caller_expectation = BalAccountExpectation.empty() + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], + ), + caller: caller_expectation, + victim: victim_expectation, + beneficiary: beneficiary_expectation, + } + + return BlockAccessListExpectation( + account_expectations=account_expectations + ) + + +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 + + +@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"), + 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"], +) +@pytest.mark.valid_from("TangerineWhistle") +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"), + 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"], +) +@pytest.mark.valid_from("TangerineWhistle") +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.parametrize("oog_before_state_access", [True, False]) @pytest.mark.with_all_precompiles +@pytest.mark.pre_alloc_group("precompile_funding") +@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"], +) @pytest.mark.valid_from("TangerineWhistle") -def test_selfdestruct_to_precompile_oog( +def test_selfdestruct_to_precompile( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, + is_success: bool, precompile: Address, - oog_before_state_access: bool, + same_tx: bool, + originator_balance: int, + beneficiary_initial_balance: int, ) -> None: """ - Test SELFDESTRUCT to precompile with out-of-gas at different boundaries. + Test SELFDESTRUCT success boundary for precompile beneficiaries. - - before_state_access: Precompile not touched (>= Amsterdam). - - after_state_access: Precompile touched but no balance change - (>= Amsterdam). + Precompiles are always warm (no cold access charge). + + - exact_gas: succeeds, balance transferred, contract destroyed + - exact_gas_minus_1: OOG, operation fails """ - alice = pre.fund_eoa() + # 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.pre_alloc_group("precompile_funding") +@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"], +) +@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) - victim_balance = 100 - victim_code = Op.SELFDESTRUCT(precompile) - victim = pre.deploy_contract(code=victim_code, balance=victim_balance) + beneficiary_dead = beneficiary_initial_balance == 0 + # State access boundary: base cost only (no G_NEW_ACCOUNT) gas_costs = fork.gas_costs() - push_cost = gas_costs.G_VERY_LOW - selfdestruct_cost = gas_costs.G_SELF_DESTRUCT - # exact gas would be: - # push_cost + selfdestruct_cost + new_account_cost + G_NEW_ACCOUNT + inner_call_gas = gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT - if oog_before_state_access: - gas = push_cost + selfdestruct_cost - 1 - else: - gas = push_cost + selfdestruct_cost + 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 - caller_code = Op.CALL(gas=gas, address=victim) - caller = pre.deploy_contract(code=caller_code) + 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, + ) + + +# --- SELFDESTRUCT to self tests --- # + + +@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 (has code) + - 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 (2), not G_VERY_LOW (3) 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) + + initcode_address = pre.deploy_contract(initcode) + + factory_address = next(pre._contract_address_iterator) # type: ignore + victim = compute_create_address(address=factory_address, nonce=1) + + factory_code = ( + Op.EXTCODECOPY(initcode_address, 0, 0, initcode_len) + + Op.CREATE(value=originator_balance, offset=0, size=initcode_len) + + Op.POP + + Op.CALL(gas=inner_call_gas, address=victim) + ) + caller = pre.deploy_contract( + address=factory_address, + code=factory_code, + balance=originator_balance, + ) + 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=100_000, - protected=True if fork >= Byzantium else False, + gas_limit=500_000, + protected=fork >= SpuriousDragon, ) - # BAL expectations >= Amsterdam - expected_block_access_list = None + # Build BAL expectations + 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) - ], - ), - caller: BalAccountExpectation.empty(), - victim: BalAccountExpectation.empty(), - } - if oog_before_state_access: - # precompile not touched, not in BAL - account_expectations[precompile] = None + 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 + if originator_balance > 0: + victim_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange( + block_access_index=1, + post_balance=originator_balance, + ) + ], + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(victim_code), + ) + ], + ) + else: + 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: + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + balance_changes=[ + BalBalanceChange(block_access_index=1, post_balance=0) + ], + ) + else: + caller_expectation = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=2) + ], + ) else: - # precompile touched, in BAL with empty expectation - account_expectations[precompile] = BalAccountExpectation.empty() - expected_block_access_list = BlockAccessListExpectation( - account_expectations=account_expectations + # 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, + } ) - # OOG: victim keeps balance and code, precompile unchanged - post = { - alice: Account(nonce=1), - caller: Account(), - victim: Account(balance=victim_balance, code=victim_code), - precompile: Account.NONEXISTENT, - } + # 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_block_access_list, - ) - ], + blocks=[Block(txs=[tx], expected_block_access_list=expected_bal)], post=post, ) From 2e7c795b4f61a76c4dd71e0d12183daaf24263a9 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 8 Jan 2026 14:17:05 -0700 Subject: [PATCH 66/71] refactor(test): fork.supports_protected_txs() instead of direct comparison - This will also help with support for any forks of this repo for L2s or other projects if some fork definitions don't have any meaningful relationship to SpuriousDragon. --- .../src/execution_testing/forks/base_fork.py | 6 ++++++ .../src/execution_testing/forks/forks/forks.py | 14 +++++++++++++- tests/berlin/eip2930_access_list/test_tx_type.py | 3 +-- tests/byzantium/eip196_ec_add_mul/test_gas.py | 3 +-- tests/byzantium/eip197_ec_pairing/test_gas.py | 3 +-- tests/frontier/create/test_create_deposit_oog.py | 4 ++-- tests/frontier/create/test_create_one_byte.py | 4 ++-- .../create/test_create_suicide_during_init.py | 3 +-- tests/frontier/create/test_create_suicide_store.py | 3 +-- tests/frontier/opcodes/test_all_opcodes.py | 3 +-- tests/frontier/opcodes/test_blockhash.py | 5 ++--- .../test_call_and_callcode_gas_calculation.py | 3 +-- tests/frontier/opcodes/test_calldatacopy.py | 3 +-- tests/frontier/opcodes/test_calldataload.py | 5 ++--- tests/frontier/opcodes/test_calldatasize.py | 5 ++--- tests/frontier/opcodes/test_dup.py | 3 +-- tests/frontier/opcodes/test_push.py | 5 ++--- tests/frontier/opcodes/test_swap.py | 5 ++--- tests/frontier/precompiles/test_ecrecover.py | 3 +-- tests/frontier/precompiles/test_ripemd.py | 3 +-- .../eip1559_fee_market_change/test_tx_type.py | 3 +-- .../test_eip150_selfdestruct.py | 4 ++-- 22 files changed, 47 insertions(+), 46 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 7d4e13eb92..2e3f23f160 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -576,6 +576,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( diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index ac76c0b87b..f8c582ad12 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -568,6 +568,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 @@ -944,7 +951,12 @@ class TangerineWhistle(DAOFork, ignore=True): class SpuriousDragon(TangerineWhistle, ignore=True): """SpuriousDragon fork.""" - pass + @classmethod + def supports_protected_txs(cls) -> bool: + """ + At Genesis, supports EIP-155 protected transactions. + """ + return True class Byzantium(SpuriousDragon): diff --git a/tests/berlin/eip2930_access_list/test_tx_type.py b/tests/berlin/eip2930_access_list/test_tx_type.py index e96d8fefcd..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 SpuriousDragon 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 >= SpuriousDragon, + 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 2a7d2035b8..a459446c8d 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 SpuriousDragon from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -56,7 +55,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= SpuriousDragon, + 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 33dcd51fa0..33f598d9e2 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 SpuriousDragon from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcodes as Op @@ -50,7 +49,7 @@ def test_gas_costs( to=account, sender=pre.fund_eoa(), gas_limit=100_0000, - protected=fork >= SpuriousDragon, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: 1 if enough_gas else 0})} diff --git a/tests/frontier/create/test_create_deposit_oog.py b/tests/frontier/create/test_create_deposit_oog.py index e69b1ca159..ebea03557e 100644 --- a/tests/frontier/create/test_create_deposit_oog.py +++ b/tests/frontier/create/test_create_deposit_oog.py @@ -14,7 +14,7 @@ Transaction, compute_create_address, ) -from execution_testing.forks import Frontier, Homestead, SpuriousDragon +from execution_testing.forks import Frontier, Homestead SLOT_CREATE_RESULT = 1 SLOT_CREATE_RESULT_PRE = 0xDEADBEEF @@ -65,7 +65,7 @@ def test_create_deposit_oog( gas_limit=tx_gas_limit, to=code, sender=sender, - protected=fork >= SpuriousDragon, + protected=fork.supports_protected_txs(), ) post = { diff --git a/tests/frontier/create/test_create_one_byte.py b/tests/frontier/create/test_create_one_byte.py index 819ff697fa..1b44d25bba 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 London, SpuriousDragon +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 >= SpuriousDragon, + 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 65b8deeace..2c932a4926 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 SpuriousDragon class Operation(Enum): @@ -93,7 +92,7 @@ def test_create_suicide_during_transaction_create( data=contract_initcode, value=tx_value, sender=sender, - protected=fork >= SpuriousDragon, + 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 56f35dbb05..12c60a5d14 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 SpuriousDragon class Operation(IntEnum): @@ -147,7 +146,7 @@ def test_create_suicide_store( to=create_contract, data=suicide_initcode, sender=sender, - protected=fork >= SpuriousDragon, + 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 e19dff20ea..a5fed1f68e 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 SpuriousDragon 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 >= SpuriousDragon, + 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 34e1e91ee1..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 SpuriousDragon 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 >= SpuriousDragon, + 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 >= SpuriousDragon, + 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 ee8dfc9350..3c178fb506 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -55,7 +55,6 @@ Berlin, Byzantium, Homestead, - SpuriousDragon, ) from execution_testing.forks.helpers import Fork @@ -201,7 +200,7 @@ def caller_tx(sender: EOA, caller_address: Address, fork: Fork) -> Transaction: value=1, gas_limit=500_000, sender=sender, - protected=fork >= SpuriousDragon, + protected=fork.supports_protected_txs(), ) diff --git a/tests/frontier/opcodes/test_calldatacopy.py b/tests/frontier/opcodes/test_calldatacopy.py index d366715306..de7ea05366 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 SpuriousDragon @pytest.mark.ported_from( @@ -193,7 +192,7 @@ def test_calldatacopy( data=tx_data, gas_limit=100_000, gas_price=0x0A, - protected=fork >= SpuriousDragon, + 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 517f2dbd99..0df9f6a00f 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 SpuriousDragon @pytest.mark.ported_from( @@ -92,7 +91,7 @@ def test_calldataload( tx = Transaction( data=calldata, gas_limit=100_000, - protected=fork >= SpuriousDragon, + 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 >= SpuriousDragon, + 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 ba3ba824f0..735fa4afa9 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 SpuriousDragon @pytest.mark.ported_from( @@ -69,7 +68,7 @@ def test_calldatasize( tx = Transaction( gas_limit=100_000, - protected=fork >= SpuriousDragon, + 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 >= SpuriousDragon, + 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 5ac55ad076..1b54a7c79e 100644 --- a/tests/frontier/opcodes/test_dup.py +++ b/tests/frontier/opcodes/test_dup.py @@ -11,7 +11,6 @@ Storage, Transaction, ) -from execution_testing.forks import SpuriousDragon @pytest.mark.parametrize( @@ -74,7 +73,7 @@ def test_dup( to=account, gas_limit=500000, gas_price=10, - protected=fork >= SpuriousDragon, + 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 bc79f09641..a05ff2f774 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 SpuriousDragon 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=fork >= SpuriousDragon, + 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=fork >= SpuriousDragon, + protected=fork.supports_protected_txs(), ) post = {} diff --git a/tests/frontier/opcodes/test_swap.py b/tests/frontier/opcodes/test_swap.py index be5107934d..efc1c42b1c 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 SpuriousDragon 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=fork >= SpuriousDragon, + 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=fork >= SpuriousDragon, + 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 4d2b9a0e4b..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 SpuriousDragon 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 >= SpuriousDragon, + 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 5f382e03b6..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 SpuriousDragon 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 >= SpuriousDragon, + protected=fork.supports_protected_txs(), ) post = {account: Account(storage={0: output if not oog else 0})} 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 d760d554f4..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 SpuriousDragon 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 >= SpuriousDragon, + protected=fork.supports_protected_txs(), error=TransactionException.TYPE_2_TX_PRE_FORK if not valid else None, ) 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 index dd494164b9..850e6a1e4a 100644 --- a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -128,7 +128,7 @@ def setup_selfdestruct_test( sender=alice, to=caller, gas_limit=500_000, - protected=fork >= SpuriousDragon, + protected=fork.supports_protected_txs(), access_list=access_list, ) @@ -875,7 +875,7 @@ def test_selfdestruct_to_self( sender=alice, to=caller, gas_limit=500_000, - protected=fork >= SpuriousDragon, + protected=fork.supports_protected_txs(), ) # Build BAL expectations From 5c548032ab63819709e3d9f826d0bc7fa7e27ebb Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 8 Jan 2026 15:55:52 -0700 Subject: [PATCH 67/71] refactor: address comments from PR #1954 --- .../test_eip150_selfdestruct.py | 102 +++++++----------- 1 file changed, 38 insertions(+), 64 deletions(-) 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 index 850e6a1e4a..4ad8e50fe0 100644 --- a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -26,6 +26,9 @@ 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 @@ -91,23 +94,17 @@ def setup_selfdestruct_test( initcode = Initcode(deploy_code=victim_code) initcode_len = len(initcode) - # Pre-deploy initcode at separate address, then use EXTCODECOPY - initcode_address = pre.deploy_contract(initcode) - - factory_address = next(pre._contract_address_iterator) # type: ignore - victim = compute_create_address(address=factory_address, nonce=1) - - factory_code = ( - Op.EXTCODECOPY(initcode_address, 0, 0, initcode_len) - + Op.CREATE(value=originator_balance, offset=0, size=initcode_len) - + Op.POP - + Op.CALL(gas=inner_call_gas, address=victim) + 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( - address=factory_address, code=factory_code, balance=originator_balance, ) + victim = compute_create_address(address=caller, nonce=1) else: # Pre-existing contract victim = pre.deploy_contract( @@ -847,22 +844,17 @@ def test_selfdestruct_to_self( initcode = Initcode(deploy_code=victim_code) initcode_len = len(initcode) - initcode_address = pre.deploy_contract(initcode) - - factory_address = next(pre._contract_address_iterator) # type: ignore - victim = compute_create_address(address=factory_address, nonce=1) - - factory_code = ( - Op.EXTCODECOPY(initcode_address, 0, 0, initcode_len) - + Op.CREATE(value=originator_balance, offset=0, size=initcode_len) - + Op.POP - + Op.CALL(gas=inner_call_gas, address=victim) + 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( - address=factory_address, code=factory_code, balance=originator_balance, ) + victim = compute_create_address(address=caller, nonce=1) else: # Pre-existing contract victim = pre.deploy_contract( @@ -887,51 +879,33 @@ def test_selfdestruct_to_self( victim_expectation = BalAccountExpectation.empty() else: # OOG: CREATE succeeded but SELFDESTRUCT failed - if originator_balance > 0: - victim_expectation = BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - balance_changes=[ - BalBalanceChange( - block_access_index=1, - post_balance=originator_balance, - ) - ], - code_changes=[ - BalCodeChange( - block_access_index=1, - new_code=bytes(victim_code), - ) - ], - ) - else: - 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: - caller_expectation = BalAccountExpectation( + victim_expectation = BalAccountExpectation( nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=2) + BalNonceChange(block_access_index=1, post_nonce=1) ], - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) + code_changes=[ + BalCodeChange( + block_access_index=1, + new_code=bytes(victim_code), + ) ], ) - else: - caller_expectation = BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=2) - ], + 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 From 772ad99e5cc480ae4c6441f703a58428e90598bb Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 8 Jan 2026 17:22:58 -0700 Subject: [PATCH 68/71] feat(test): Add selfdestruct to system contracts + to self from initcode --- .../test_cases.md | 2 + .../test_eip150_selfdestruct.py | 331 ++++++++++++++---- 2 files changed, 274 insertions(+), 59 deletions(-) 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 5bd0cf31ac..29d77ea46a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -10,6 +10,8 @@ | `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_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 | 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 index 4ad8e50fe0..5996b50b3e 100644 --- a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -1,7 +1,7 @@ """ Tests for EIP-150 SELFDESTRUCT operation gas costs. -EIP-150 introduced the 5000 gas cost for SELFDESTRUCT and precise gas +EIP-150 introduced G_SELF_DESTRUCT for SELFDESTRUCT and precise gas boundaries for state access during the operation. """ @@ -50,8 +50,9 @@ def calculate_selfdestruct_gas( """Calculate exact gas needed for SELFDESTRUCT.""" gas_costs = fork.gas_costs() gas = ( + # PUSH + SELFDESTRUCT gas_costs.G_VERY_LOW + gas_costs.G_SELF_DESTRUCT - ) # PUSH + SELFDESTRUCT + ) # Cold access cost (>=Berlin only) if fork >= Berlin and not beneficiary_warm: @@ -101,8 +102,7 @@ def setup_selfdestruct_test( ), ) caller = pre.deploy_contract( - code=factory_code, - balance=originator_balance, + code=factory_code, balance=originator_balance ) victim = compute_create_address(address=caller, nonce=1) else: @@ -110,8 +110,9 @@ def setup_selfdestruct_test( 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) + 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) @@ -177,33 +178,21 @@ def build_bal_expectations( 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 = BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=1) - ], - balance_changes=[ - BalBalanceChange( - block_access_index=1, - post_balance=originator_balance, - ) - ], - code_changes=[ - BalCodeChange( - block_access_index=1, new_code=bytes(victim_code) - ) - ], - ) - else: - 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) - ) - ], + victim_expectation.balance_changes.append( + BalBalanceChange( + block_access_index=1, post_balance=originator_balance + ) ) else: if success and originator_balance > 0: @@ -217,36 +206,27 @@ def build_bal_expectations( # Caller expectation if same_tx: - # Only include balance_changes if originator_balance > 0 + caller_expectation = BalAccountExpectation( + nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=2)], + ) if originator_balance > 0: - caller_expectation = BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=2) - ], - balance_changes=[ - BalBalanceChange(block_access_index=1, post_balance=0) - ], - ) - else: - caller_expectation = BalAccountExpectation( - nonce_changes=[ - BalNonceChange(block_access_index=1, post_nonce=2) - ], + caller_expectation.balance_changes.append( + BalBalanceChange(block_access_index=1, post_balance=0) ) else: caller_expectation = BalAccountExpectation.empty() - account_expectations: Dict[Address, BalAccountExpectation | None] = { - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], - ), - caller: caller_expectation, - victim: victim_expectation, - beneficiary: beneficiary_expectation, - } - return BlockAccessListExpectation( - account_expectations=account_expectations + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(block_access_index=1, post_nonce=1) + ], + ), + caller: caller_expectation, + victim: victim_expectation, + beneficiary: beneficiary_expectation, + } ) @@ -314,6 +294,9 @@ def build_post_state( return post +# --- tests --- # + + @pytest.mark.parametrize( "is_success", [True, False], ids=["exact_gas", "exact_gas_minus_1"] ) @@ -787,7 +770,152 @@ def test_selfdestruct_to_precompile_state_access_boundary( ) -# --- SELFDESTRUCT to self tests --- # +@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( @@ -817,7 +945,7 @@ def test_selfdestruct_to_self( Key characteristics: - Beneficiary is always warm (it's the executing contract) - - Beneficiary is always alive (has code) + - 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) @@ -834,7 +962,7 @@ def test_selfdestruct_to_self( victim_code = Op.SELFDESTRUCT(Op.ADDRESS) # Gas: ADDRESS + SELFDESTRUCT (no cold access, no G_NEW_ACCOUNT) - # Note: ADDRESS opcode costs G_BASE (2), not G_VERY_LOW (3) like PUSH + # 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 @@ -976,3 +1104,88 @@ def test_selfdestruct_to_self( 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, + ) From d8a14c162d4adacfb8aa9b36f35ec56f169c8b24 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 8 Jan 2026 18:44:47 -0700 Subject: [PATCH 69/71] feat(test-commands): Allow pytest valid fork markers as params; add unit tests --- .../pytest_commands/plugins/forks/forks.py | 51 +++++ .../forks/tests/test_bad_validity_markers.py | 70 +++++++ .../plugins/forks/tests/test_markers.py | 179 ++++++++++++++++++ .../test_eip150_selfdestruct.py | 16 +- 4 files changed, 311 insertions(+), 5 deletions(-) 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 dd0a271fa4..5eca78a165 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 aefd981690..c8f3000e39 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_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/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py index 5996b50b3e..d2ba634cf4 100644 --- a/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py +++ b/tests/tangerine_whistle/eip150_operation_gas_costs/test_eip150_selfdestruct.py @@ -29,7 +29,11 @@ from execution_testing import ( Macros as Om, ) -from execution_testing.forks import Berlin, Cancun, SpuriousDragon +from execution_testing.forks import ( + Berlin, + Cancun, + SpuriousDragon, +) from execution_testing.forks.helpers import Fork from .spec import ref_spec_150 @@ -306,7 +310,9 @@ def build_post_state( @pytest.mark.parametrize( "warm", [ - pytest.param(False, id="cold"), + pytest.param( + False, id="cold", marks=pytest.mark.valid_from("TangerineWhistle") + ), pytest.param(True, id="warm", marks=pytest.mark.valid_from("Berlin")), ], ) @@ -323,7 +329,6 @@ def build_post_state( [0, 1], ids=["dead_beneficiary", "alive_beneficiary"], ) -@pytest.mark.valid_from("TangerineWhistle") def test_selfdestruct_to_account( pre: Alloc, blockchain_test: BlockchainTestFiller, @@ -429,7 +434,9 @@ def test_selfdestruct_to_account( @pytest.mark.parametrize( "warm", [ - pytest.param(False, id="cold"), + pytest.param( + False, id="cold", marks=pytest.mark.valid_from("TangerineWhistle") + ), pytest.param(True, id="warm", marks=pytest.mark.valid_from("Berlin")), ], ) @@ -446,7 +453,6 @@ def test_selfdestruct_to_account( [0, 1], ids=["dead_beneficiary", "alive_beneficiary"], ) -@pytest.mark.valid_from("TangerineWhistle") def test_selfdestruct_state_access_boundary( pre: Alloc, blockchain_test: BlockchainTestFiller, From f8f52d9a4071109017eec2245ec2df11c9cc385e Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 12 Jan 2026 08:48:06 -0700 Subject: [PATCH 70/71] refactor(spec): Remove check_gas where unnecessary for instructions --- .../forks/amsterdam/vm/instructions/environment.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 79fd56cc3c..5ee84a3d49 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -37,7 +37,6 @@ calculate_blob_gas_price, calculate_gas_extend_memory, charge_gas, - check_gas, ) from ..stack import pop, push @@ -81,9 +80,9 @@ def balance(evm: Evm) -> None: # GAS is_cold_access = address not in evm.accessed_addresses gas_cost = GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS - check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) + charge_gas(evm, gas_cost) # OPERATION @@ -351,9 +350,9 @@ def extcodesize(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) - check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) + charge_gas(evm, access_gas_cost) # OPERATION @@ -397,9 +396,9 @@ def extcodecopy(evm: Evm) -> None: ) total_gas_cost = access_gas_cost + copy_gas_cost + extend_memory.cost - check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) + charge_gas(evm, total_gas_cost) # OPERATION @@ -491,9 +490,9 @@ def extcodehash(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) - check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) + charge_gas(evm, access_gas_cost) # OPERATION From 7b64b2969533295c50152ddfcad9e9bee6261b80 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Thu, 11 Dec 2025 21:33:26 +0100 Subject: [PATCH 71/71] feat(planned-tests): Add EIP-7928 planned test cases --- tests/amsterdam/eip7928_block_level_access_lists/test_cases.md | 2 ++ 1 file changed, 2 insertions(+) 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 29d77ea46a..3c5e3099ab 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -127,3 +127,5 @@ | `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 | +| `test_bal_static_mode` | Verify correct BAL in `static` mode | Invoke code `C` in `static` mode via `STATICCALL`. The code `C` should execute (with target `T` - brackets denote if `T` should be in BAL or not): `CALL` with nonzero value (no), `CALL` with zero value (yes), `CALLCODE` with (non)zero value (yes), `CREATE[2]` with (non)zero value (no), `SELFDESTRUCT` (no). Note: in cases with `value` being sent, also ensure the case with insufficient balance and sufficient balance in `C`. | Cases denote in brackets if target `T` must be in BAL or not. | 🟡 Planned | +| `test_bal_call_just_enough_gas` | Tests if target is in BAL if there is 0 gas left for execution | Code C which invokes `CALL`/`CALLCODE`/`STATICCALL`/`DELEGATECALL`/`CREATE`/`CREATE2` with exactly enough gas to invoke the code at target `T` (there is thus 0 gas available in this newly created call frame). | `T` is always in BAL. | 🟡 Planned |