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..ae05445b66 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: @@ -275,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 @@ -374,11 +378,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 +449,77 @@ 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, 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( + 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 + # Filtering happens at transaction level in eoa_delegation.py + for ( + address, + block_access_index, + ), new_code in state_changes.code_changes.items(): + add_code_change(builder, address, block_access_index, new_code) + + return _build_from_builder(builder) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_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 b67ad90aa5..8f9a9d81f5 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,17 @@ set_account_balance, state_root, ) +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, + normalize_balance_changes_for_transaction, + track_address, + track_balance_change, +) from .transactions import ( AccessListTransaction, BlobTransaction, @@ -777,9 +782,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, @@ -796,9 +801,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 + increment_block_access_index(block_env.block_state_changes) process_withdrawals(block_env, block_output, withdrawals) @@ -806,8 +812,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 @@ -888,9 +895,18 @@ 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 + 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 + track_address(tx_state_changes, block_env.coinbase) + capture_pre_balance( + tx_state_changes, block_env.coinbase, coinbase_pre_balance + ) trie_set( block_output.transactions_trie, @@ -921,13 +937,16 @@ 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, 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.state, + sender, + U256(sender_balance_after_gas_fee), + tx_state_changes, ) access_list_addresses = set() @@ -965,6 +984,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) @@ -993,18 +1014,22 @@ 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, + 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, coinbase_balance_after_mining_fee, + tx_state_changes, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( @@ -1012,17 +1037,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 @@ -1041,6 +1055,36 @@ 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, + ) + + commit_transaction_frame(tx_state_changes) + + # EIP-7928: Handle 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) + ), + ) + def process_withdrawals( block_env: vm.BlockEnvironment, @@ -1050,6 +1094,13 @@ 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 + 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) @@ -1061,25 +1112,27 @@ 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) 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) + track_balance_change( + block_env.block_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, + ) + 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 b47cd2d377..c1d331942a 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -21,15 +21,17 @@ from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify -from ethereum_types.numeric import U256, Uint +from ethereum_types.numeric import U64, U256, Uint -from .block_access_lists.tracker import ( - prepare_balance_tracking, +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 .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: @@ -515,17 +517,18 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - block_env: "BlockEnvironment" = None, + state_changes: StateChanges, ) -> 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) + 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) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -538,24 +541,22 @@ 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( + 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, - block_env: "BlockEnvironment" = None, + state_changes: StateChanges, ) -> None: """ Sets the balance of an account. @@ -571,28 +572,26 @@ def set_account_balance( amount: The amount that needs to set in balance. - block_env: - Optional block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ - # 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) + 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) - - # 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(state_changes, address, amount) def increment_nonce( - state: State, address: Address, block_env: "BlockEnvironment" = None + state: State, + address: Address, + state_changes: "StateChanges", ) -> None: """ Increments the nonce of an account. @@ -605,8 +604,8 @@ def increment_nonce( address: Address of the account whose nonce needs to be incremented. - block_env: - Optional block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ @@ -615,24 +614,15 @@ 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) + account = get_account(state, address) + track_nonce_change(state_changes, address, U64(account.nonce)) def set_code( state: State, address: Address, code: Bytes, - block_env: "BlockEnvironment" = None, + state_changes: StateChanges, ) -> None: """ Sets Account code. @@ -648,8 +638,55 @@ def set_code( code: The bytecode that needs to be set. - block_env: - Optional block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). + + """ + + def write_code(sender: Account) -> None: + sender.code = code + + 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: + """ + 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). """ @@ -658,14 +695,11 @@ 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 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 new file mode 100644 index 0000000000..7b98396318 --- /dev/null +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -0,0 +1,605 @@ +""" +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, passed explicitly to operations. + +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: Block → Transaction → Call frames. + Each frame holds a reference to its parent for upward traversal. + """ + + parent: Optional["StateChanges"] = None + _block_access_index: BlockAccessIndex = BlockAccessIndex(0) + + touched_addresses: Set[Address] = field(default_factory=set) + storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) + storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256] = ( + field(default_factory=dict) + ) + + balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256] = field( + default_factory=dict + ) + nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( + default_factory=set + ) + code_changes: Dict[Tuple[Address, BlockAccessIndex], Bytes] = field( + default_factory=dict + ) + + # Pre-state captures 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 + ) + pre_code: Dict[Address, Bytes] = field(default_factory=dict) + + +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: + capture_pre_code(parent_frame, 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: + 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 + address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} + for addr, idx, nonce in child_frame.nonce_changes: + if ( + addr not in address_final_nonces + or nonce > address_final_nonces[addr][1] + ): + address_final_nonces[addr] = (idx, nonce) + + # 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 + + +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 - 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(): + 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 + + +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. + + """ + 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)) + + +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) + ] diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index c10df4897b..26b7e99e45 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, merge_on_failure, merge_on_success 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,8 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.accessed_addresses.update(child_evm.accessed_addresses) evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) + merge_on_success(child_evm.state_changes) + def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ @@ -200,3 +203,5 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left + + merge_on_failure(child_evm.state_changes) diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 649027cb43..ec95fd1a47 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,9 +12,15 @@ 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 ..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 from . import Evm, Message @@ -115,11 +121,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 ---------- @@ -130,77 +140,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 - - delegated_code = get_account(state, delegated_address).code + delegation_gas_cost = GAS_COLD_ACCOUNT_ACCESS - return ( - True, - address, - delegated_address, - delegated_code, - additional_gas_cost, - ) + return True, address, delegated_address, delegation_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_access(evm.message.block_env, 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_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 - ) + # Track the address for BAL + track_address(evm.state_changes, delegated_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: @@ -239,7 +236,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) + track_address(message.block_env.block_state_changes, authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -255,9 +252,23 @@ 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) - increment_nonce(state, authority) + state_changes = ( + message.transaction_state_changes + 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 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: 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..3d23b8f136 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,9 +17,10 @@ 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 ...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) - track_address_access(evm.message.block_env, 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) - track_address_access(evm.message.block_env, 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) - track_address_access(evm.message.block_env, 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) - track_address_access(evm.message.block_env, 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 65a6a38455..8edff23534 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, @@ -24,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 ( @@ -63,7 +64,7 @@ def sload(evm: Evm) -> None: 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, evm.message.current_target, key, ) @@ -95,6 +96,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 @@ -119,26 +124,22 @@ def sstore(evm: Evm) -> None: else: gas_cost += GAS_WARM_ACCESS - 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 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 ) - track_storage_write( - evm.message.block_env, + track_storage_read( + evm.state_changes, evm.message.current_target, key, - new_value, ) + check_gas(evm, gas_cost) + + if is_cold_access: + 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: @@ -163,6 +164,9 @@ 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 + ) # 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..3513e09a58 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, @@ -27,14 +27,15 @@ 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, to_address_masked, ) from ...vm.eoa_delegation import ( - apply_delegation_tracking, - check_delegation, + calculate_delegation_cost, + read_delegation_target, ) from .. import ( Evm, @@ -114,22 +115,16 @@ def generic_create( evm.accessed_addresses.add(contract_address) - track_address_access(evm.message.block_env, 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, - ) + increment_nonce(state, evm.message.current_target, evm.state_changes) push(evm.stack, U256(0)) return - increment_nonce( - state, - evm.message.current_target, - ) + increment_nonce(state, evm.message.current_target, evm.state_changes) child_message = Message( block_env=evm.message.block_env, @@ -325,6 +320,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, @@ -343,6 +340,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) @@ -396,14 +394,33 @@ 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) + + # 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,16 +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) - - 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 @@ -498,14 +505,33 @@ 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) + + # 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 @@ -520,16 +546,6 @@ def callcode(evm: Evm) -> None: 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 @@ -537,6 +553,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: + capture_pre_balance( + evm.state_changes, evm.message.current_target, sender_balance + ) + if sender_balance < value: push(evm.stack, U256(0)) evm.return_data = b"" @@ -599,7 +624,7 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address_access(evm.message.block_env, beneficiary) + track_address(evm.state_changes, beneficiary) charge_gas(evm, gas_cost) @@ -613,17 +638,14 @@ def selfdestruct(evm: Evm) -> None: originator, beneficiary, originator_balance, + evm.state_changes, ) # 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.message.block_env.state, originator, U256(0), evm.state_changes ) evm.accounts_to_delete.add(originator) @@ -666,13 +688,34 @@ def delegatecall(evm: Evm) -> None: 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 @@ -682,16 +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) - - 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 @@ -748,14 +781,34 @@ 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) + # 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,16 +822,6 @@ def staticcall(evm: Evm) -> None: 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 5b33e48dd0..a63b745624 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,13 @@ rollback_transaction, set_code, ) +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 @@ -72,6 +73,61 @@ 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: """ @@ -140,9 +196,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, + track_address( + message.block_env.block_state_changes, delegated_address ) evm = process_message(message) @@ -205,7 +260,10 @@ 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) + 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) if not evm.error: contract_code = evm.output @@ -219,14 +277,19 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) + 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) + set_code( + state, message.current_target, contract_code, create_frame + ) commit_transaction(state, transient_storage) + merge_on_success(create_frame) else: rollback_transaction(state, transient_storage) + merge_on_failure(create_frame) return evm @@ -250,16 +313,12 @@ 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) + parent_frame = get_parent_frame(message) + state_changes = get_message_state_frame(message) + + track_address(state_changes, message.current_target) if message.should_transfer_value and message.value != 0: move_ether( @@ -267,30 +326,22 @@ 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) + if state_changes != parent_frame: + merge_on_failure(evm.state_changes) 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) + if state_changes != parent_frame: + merge_on_success(evm.state_changes) return evm -def execute_code(message: Message) -> Evm: +def execute_code(message: Message, state_changes: StateChanges) -> Evm: """ Executes bytecode present in the `message`. @@ -298,6 +349,8 @@ def execute_code(message: Message) -> Evm: ---------- message : Transaction specific items. + state_changes : + The state changes frame to use for tracking. Returns ------- @@ -325,6 +378,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 2bb604f935..47df63b7ff 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -136,11 +136,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 7f5287496e..9d229496fe 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -9,7 +9,7 @@ from typing import Any, Final, TextIO, 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 ethereum import trace from ethereum.exceptions import EthereumException, InvalidBlock @@ -252,10 +252,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.is_after_fork("prague"): self.fork.process_unchecked_system_transaction( block_env=block_env, @@ -291,21 +287,13 @@ 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"): - assert block_env.state.change_tracker is not None - num_transactions = ulen( - [ - tx_idx - for tx_idx in self.txs.successfully_parsed - if tx_idx is not None - ] + from ethereum.forks.amsterdam.state_tracker import ( + increment_block_access_index, ) - # 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 - ) + increment_block_access_index(block_env.block_state_changes) if not self.fork.is_after_fork("paris"): if self.options.state_reward is None: @@ -324,8 +312,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.state.change_tracker.block_access_list_builder + block_env.block_state_changes ) def run_blockchain_test(self) -> 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 9f1214fe4c..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 @@ -1925,3 +1925,284 @@ 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), + }, + ) + + +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}), + }, + ) + + +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""), + }, + ) + + +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_block_access_lists_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index 5ff9e7b135..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 @@ -608,3 +608,223 @@ 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), + }, + ) + + +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_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 17799d3655..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 @@ -32,6 +32,7 @@ Fork, Op, Transaction, + compute_create_address, ) from .spec import ref_spec_7928 @@ -754,3 +755,217 @@ 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, + }, + ) + + +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 + }, + ) + + +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 351395479d..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,11 @@ | `test_bal_7702_invalid_nonce_authorization` | Ensure BAL handles failed authorization due to wrong nonce | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect nonce, causing silent authorization failure | BAL **MUST** include Alice with empty changes (account access), Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_invalid_chain_id_authorization` | Ensure BAL handles failed authorization due to wrong chain id | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect chain id, causing authorization failure before account access | BAL **MUST** include Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include Alice (authorization fails before loading account) or `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_delegated_via_call_opcode` | Ensure BAL captures delegation target when a contract uses *CALL opcodes to call a delegated account | Pre-deployed contract `Alice` delegated to `Oracle`. `Caller` contract uses CALL/CALLCODE/DELEGATECALL/STATICCALL to call `Alice`. Bob sends transaction to `Caller`. | BAL **MUST** include Bob: `nonce_changes`. `Caller`: empty changes (account access). `Alice`: empty changes (account access - delegated account being called). `Oracle`: empty changes (delegation target access). | ✅ Completed | +| `test_bal_7702_null_address_delegation` | Ensure BAL does not record spurious code changes for net-zero code operations | Alice sends transaction with authorization delegating to NULL_ADDRESS (0x0), which sets code to `b""` on an account that already has `b""` code. Transaction sends 10 wei to Bob. | BAL **MUST** include Alice with `nonce_changes` (tx nonce + auth nonce increment) but **MUST NOT** include `code_changes` (setting `b"" -> b""` is net-zero and filtered out). Bob: `balance_changes` (receives 10 wei). This ensures net-zero code change is not recorded. | ✅ Completed | +| `test_bal_7702_double_auth_reset` | Ensure BAL captures net code change when double auth resets delegation | `Relayer` sends transaction with two authorizations for Alice: (1) First auth sets delegation to `CONTRACT_A` at nonce=0, (2) Second auth resets delegation to empty (address 0) at nonce=1. Transaction sends 10 wei to Bob. Per EIP-7702, only the last authorization takes effect. | BAL **MUST** include Alice with `nonce_changes` (both auths increment nonce to 2) but **MUST NOT** include `code_changes` (net change is empty → empty). Bob: `balance_changes` (receives 10 wei). Relayer: `nonce_changes`. `CONTRACT_A` **MUST NOT** be in BAL (never accessed). This is a regression test for the bug where BAL showed first auth's code despite final state being empty. | ✅ Completed | +| `test_bal_7702_double_auth_swap` | Ensure BAL captures final code when double auth swaps delegation targets | `Relayer` sends transaction with two authorizations for Alice: (1) First auth sets delegation to `CONTRACT_A` at nonce=0, (2) Second auth changes delegation to `CONTRACT_B` at nonce=1. Transaction sends 10 wei to Bob. Per EIP-7702, only the last authorization takes effect. | BAL **MUST** include Alice with `nonce_changes` (both auths increment nonce to 2) and `code_changes` (final code is delegation designation for `CONTRACT_B`, not `CONTRACT_A`). Bob: `balance_changes` (receives 10 wei). Relayer: `nonce_changes`. Neither `CONTRACT_A` nor `CONTRACT_B` appear in BAL during delegation setup (never accessed). This ensures BAL shows final state, not intermediate changes. | ✅ Completed | | `test_bal_sstore_and_oog` | Ensure BAL handles OOG during SSTORE execution at various gas boundaries (EIP-2200 stipend and implicit SLOAD) | Alice calls contract that attempts `SSTORE` to cold slot `0x01`. Parameterized: (1) OOG at EIP-2200 stipend check (2300 gas after PUSH opcodes) - fails before implicit SLOAD, (2) OOG at stipend + 1 (2301 gas) - passes stipend check but fails after implicit SLOAD, (3) OOG at exact gas - 1, (4) Successful SSTORE with exact gas. | For case (1): BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes` (fails before implicit SLOAD). For cases (2) and (3): BAL **MUST** include slot `0x01` in `storage_reads` (implicit SLOAD occurred) but **MUST NOT** include in `storage_changes` (write didn't complete). For case (4): BAL **MUST** include slot `0x01` in `storage_changes` only (successful write; read is filtered by builder). | ✅ Completed | +| `test_bal_sstore_static_context` | Ensure BAL does not capture spurious storage access when SSTORE fails in static context | Alice calls contract with `STATICCALL` which attempts `SSTORE` to slot `0x01`. SSTORE must fail before any storage access occurs. | BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes`. Static context check happens before storage access, preventing spurious reads. Alice has `nonce_changes` and `balance_changes` (gas cost). Target contract included with empty changes. | ✅ Completed | | `test_bal_sload_and_oog` | Ensure BAL handles OOG during SLOAD execution correctly | Alice calls contract that attempts `SLOAD` from cold slot `0x01`. Parameterized: (1) OOG at SLOAD opcode (insufficient gas), (2) Successful SLOAD execution. | For OOG case: BAL **MUST NOT** contain slot `0x01` in `storage_reads` since storage wasn't accessed. For success case: BAL **MUST** contain slot `0x01` in `storage_reads`. | ✅ Completed | | `test_bal_balance_and_oog` | Ensure BAL handles OOG during BALANCE opcode execution correctly | Alice calls contract that attempts `BALANCE` opcode on cold target account. Parameterized: (1) OOG at BALANCE opcode (insufficient gas), (2) Successful BALANCE execution. | For OOG case: BAL **MUST NOT** include target account (wasn't accessed). For success case: BAL **MUST** include target account in `account_changes`. | ✅ Completed | | `test_bal_extcodesize_and_oog` | Ensure BAL handles OOG during EXTCODESIZE opcode execution correctly | Alice calls contract that attempts `EXTCODESIZE` opcode on cold target contract. Parameterized: (1) OOG at EXTCODESIZE opcode (insufficient gas), (2) Successful EXTCODESIZE execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | @@ -48,6 +52,12 @@ | `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_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 | | `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 | @@ -79,3 +89,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 | 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( 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, )