diff --git a/src/ethereum/osaka/block_access_lists/__init__.py b/src/ethereum/osaka/block_access_lists/__init__.py new file mode 100644 index 0000000000..ccd762d757 --- /dev/null +++ b/src/ethereum/osaka/block_access_lists/__init__.py @@ -0,0 +1,51 @@ +""" +Block Access Lists (EIP-7928) implementation for Ethereum Osaka fork. +""" + +from .builder import ( + BlockAccessListBuilder, + add_balance_change, + add_code_change, + add_nonce_change, + add_storage_read, + add_storage_write, + add_touched_account, + build, +) +from .tracker import ( + StateChangeTracker, + set_transaction_index, + track_address_access, + track_balance_change, + track_code_change, + track_nonce_change, + track_storage_read, + track_storage_write, +) +from .rlp_utils import ( + compute_block_access_list_hash, + rlp_encode_block_access_list, + validate_block_access_list_against_execution, +) + +__all__ = [ + "BlockAccessListBuilder", + "StateChangeTracker", + "add_balance_change", + "add_code_change", + "add_nonce_change", + "add_storage_read", + "add_storage_write", + "add_touched_account", + "build", + "compute_block_access_list_hash", + "set_transaction_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", +] \ No newline at end of file diff --git a/src/ethereum/osaka/block_access_lists/builder.py b/src/ethereum/osaka/block_access_lists/builder.py new file mode 100644 index 0000000000..12dac71d5c --- /dev/null +++ b/src/ethereum/osaka/block_access_lists/builder.py @@ -0,0 +1,356 @@ +""" +Block Access List Builder for EIP-7928 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This module implements the Block Access List builder that tracks all account +and storage accesses during block execution and constructs the final +[`BlockAccessList`]. + +The builder follows a two-phase approach: + +1. **Collection Phase**: During transaction execution, all state accesses are + recorded via the tracking functions. +2. **Build Phase**: After block execution, the accumulated data is sorted + and encoded into the final deterministic format. + +[`BlockAccessList`]: ref:ethereum.osaka.ssz_types.BlockAccessList +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Set + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U32, U64, U256, Uint + +from ..fork_types import Address +from ..rlp_types import ( + AccountChanges, + BalanceChange, + BlockAccessList, + BlockAccessIndex, + CodeChange, + NonceChange, + SlotChanges, + StorageChange, +) + + +@dataclass +class AccountData: + """ + Account data stored in the builder during block execution. + + This dataclass tracks all changes made to a single account throughout + the execution of a block, organized by the type of change and the + transaction index where it occurred. + """ + storage_changes: Dict[Bytes, List[StorageChange]] = field(default_factory=dict) + """ + Mapping from storage slot to list of changes made to that slot. + Each change includes the transaction index and new value. + """ + + storage_reads: Set[Bytes] = field(default_factory=set) + """ + Set of storage slots that were read but not modified. + """ + + balance_changes: List[BalanceChange] = field(default_factory=list) + """ + List of balance changes for this account, ordered by transaction index. + """ + + nonce_changes: List[NonceChange] = field(default_factory=list) + """ + List of nonce changes for this account, ordered by transaction index. + """ + + code_changes: List[CodeChange] = field(default_factory=list) + """ + List of code changes (contract deployments) for this account, + ordered by transaction index. + """ + + +@dataclass +class BlockAccessListBuilder: + """ + Builder for constructing [`BlockAccessList`] efficiently during transaction + execution. + + The builder accumulates all account and storage accesses during block + execution and constructs a deterministic access list. Changes are tracked + by address, field type, and transaction index to enable efficient + reconstruction of state changes. + + [`BlockAccessList`]: ref:ethereum.osaka.ssz_types.BlockAccessList + """ + accounts: Dict[Address, AccountData] = field(default_factory=dict) + """ + Mapping from account address to its tracked changes during block execution. + """ + + +def ensure_account(builder: BlockAccessListBuilder, address: Address) -> None: + """ + Ensure an account exists in the builder's tracking structure. + + Creates an empty [`AccountData`] entry for the given address if it + doesn't already exist. This function is idempotent and safe to call + multiple times for the same address. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address to ensure exists. + + [`AccountData`]: ref:ethereum.osaka.block_access_lists.builder.AccountData + """ + if address not in builder.accounts: + builder.accounts[address] = AccountData() + + +def add_storage_write( + builder: BlockAccessListBuilder, + address: Address, + slot: Bytes, + block_access_index: BlockAccessIndex, + new_value: Bytes +) -> None: + """ + Add a storage write operation to the block access list. + + Records a storage slot modification for a given address at a specific + transaction index. Multiple writes to the same slot are tracked + separately, maintaining the order and transaction index of each change. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose storage is being modified. + slot : + The storage slot being written to. + block_access_index : + The block access index for this change (0 for pre-execution, 1..n for transactions, n+1 for post-execution). + new_value : + The new value being written to the storage slot. + """ + ensure_account(builder, address) + + if slot not in builder.accounts[address].storage_changes: + builder.accounts[address].storage_changes[slot] = [] + + change = StorageChange(block_access_index=block_access_index, new_value=new_value) + builder.accounts[address].storage_changes[slot].append(change) + + +def add_storage_read( + builder: BlockAccessListBuilder, + address: Address, + slot: Bytes +) -> None: + """ + Add a storage read operation to the block access list. + + Records that a storage slot was read during execution. Storage slots + that are both read and written will only appear in the storage changes + list, not in the storage reads list, as per [EIP-7928]. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose storage is being read. + slot : + The storage slot being read. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + """ + ensure_account(builder, address) + builder.accounts[address].storage_reads.add(slot) + + +def add_balance_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + post_balance: U256 +) -> None: + """ + Add a balance change to the block access list. + + Records the post-transaction balance for an account after it has been + modified. This includes changes from transfers, gas fees, block rewards, + and any other balance-affecting operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose balance changed. + block_access_index : + The block access index for this change (0 for pre-execution, 1..n for transactions, n+1 for post-execution). + post_balance : + The account balance after the change as U256. + """ + ensure_account(builder, address) + + change = BalanceChange(block_access_index=block_access_index, post_balance=post_balance) + builder.accounts[address].balance_changes.append(change) + + +def add_nonce_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + new_nonce: U64 +) -> None: + """ + Add a nonce change to the block access list. + + Records a nonce increment for an account. This occurs when an EOA sends + a transaction or when a contract performs [`CREATE`] or [`CREATE2`] + operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose nonce changed. + block_access_index : + The block access index for this change (0 for pre-execution, 1..n for transactions, n+1 for post-execution). + new_nonce : + The new nonce value after the change. + + [`CREATE`]: ref:ethereum.osaka.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.osaka.vm.instructions.system.create2 + """ + ensure_account(builder, address) + + change = NonceChange(block_access_index=block_access_index, new_nonce=new_nonce) + builder.accounts[address].nonce_changes.append(change) + + +def add_code_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + new_code: Bytes +) -> None: + """ + Add a code change to the block access list. + + Records contract code deployment or modification. This typically occurs + during contract creation via [`CREATE`], [`CREATE2`], or [`SETCODE`] + operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address receiving new code. + block_access_index : + The block access index for this change (0 for pre-execution, 1..n for transactions, n+1 for post-execution). + new_code : + The deployed contract bytecode. + + [`CREATE`]: ref:ethereum.osaka.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.osaka.vm.instructions.system.create2 + [`SETCODE`]: ref:ethereum.osaka.vm.instructions.system.setcode + """ + ensure_account(builder, address) + + change = CodeChange(block_access_index=block_access_index, new_code=new_code) + builder.accounts[address].code_changes.append(change) + + +def add_touched_account(builder: BlockAccessListBuilder, address: Address) -> None: + """ + Add an account that was accessed but not modified. + + Records that an account was accessed during execution without any state + changes. This is used for operations like [`EXTCODEHASH`], [`BALANCE`], + [`EXTCODESIZE`], and [`EXTCODECOPY`] that read account data without + modifying it. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address that was accessed. + + [`EXTCODEHASH`]: ref:ethereum.osaka.vm.instructions.environment.extcodehash + [`BALANCE`]: ref:ethereum.osaka.vm.instructions.environment.balance + [`EXTCODESIZE`]: ref:ethereum.osaka.vm.instructions.environment.extcodesize + [`EXTCODECOPY`]: ref:ethereum.osaka.vm.instructions.environment.extcodecopy + """ + ensure_account(builder, address) + + +def build(builder: BlockAccessListBuilder) -> BlockAccessList: + """ + Build the final [`BlockAccessList`] from accumulated changes. + + Constructs a deterministic block access list by sorting all accumulated + changes. The resulting list is ordered by: + + 1. Account addresses (lexicographically) + 2. Within each account: + - Storage slots (lexicographically) + - Transaction indices (numerically) for each change type + + Parameters + ---------- + builder : + The block access list builder containing all tracked changes. + + Returns + ------- + block_access_list : + The final sorted and encoded block access list. + + [`BlockAccessList`]: ref:ethereum.osaka.ssz_types.BlockAccessList + """ + account_changes_list = [] + + for address, changes in builder.accounts.items(): + storage_changes = [] + for slot, slot_changes in changes.storage_changes.items(): + sorted_changes = tuple(sorted(slot_changes, key=lambda x: x.block_access_index)) + storage_changes.append(SlotChanges(slot=slot, changes=sorted_changes)) + + storage_reads = [] + for slot in changes.storage_reads: + if slot not in changes.storage_changes: + storage_reads.append(slot) + + balance_changes = tuple(sorted(changes.balance_changes, key=lambda x: x.block_access_index)) + nonce_changes = tuple(sorted(changes.nonce_changes, key=lambda x: x.block_access_index)) + code_changes = tuple(sorted(changes.code_changes, key=lambda x: x.block_access_index)) + + storage_changes.sort(key=lambda x: x.slot) + storage_reads.sort() + + account_change = AccountChanges( + address=address, + storage_changes=tuple(storage_changes), + storage_reads=tuple(storage_reads), + balance_changes=balance_changes, + nonce_changes=nonce_changes, + code_changes=code_changes + ) + + account_changes_list.append(account_change) + + account_changes_list.sort(key=lambda x: x.address) + + return BlockAccessList(account_changes=tuple(account_changes_list)) \ No newline at end of file diff --git a/src/ethereum/osaka/block_access_lists/rlp_utils.py b/src/ethereum/osaka/block_access_lists/rlp_utils.py new file mode 100644 index 0000000000..335e4d1c42 --- /dev/null +++ b/src/ethereum/osaka/block_access_lists/rlp_utils.py @@ -0,0 +1,396 @@ +""" +Block Access List RLP Utilities for EIP-7928 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Utilities for working with Block Access Lists using RLP encoding, +as specified in EIP-7928. + +This module provides: + +- RLP encoding functions for all Block Access List types +- Hash computation using [`keccak256`] +- Validation logic to ensure structural correctness + +The encoding follows the RLP specification used throughout Ethereum. + +[`keccak256`]: ref:ethereum.crypto.hash.keccak256 +""" + +from typing import Optional + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import Hash32, keccak256 + +from ..rlp_types import ( + BlockAccessList, + AccountChanges, + SlotChanges, + StorageChange, + BalanceChange, + NonceChange, + CodeChange, + MAX_TXS, + MAX_SLOTS, + MAX_ACCOUNTS, + MAX_CODE_SIZE, +) + + +def compute_block_access_list_hash(block_access_list: BlockAccessList) -> Hash32: + """ + Compute the hash of a Block Access List. + + The Block Access List is RLP-encoded and then hashed with keccak256. + + Parameters + ---------- + block_access_list : + The Block Access List to hash. + + Returns + ------- + hash : + The keccak256 hash of the RLP-encoded Block Access List. + """ + block_access_list_bytes = rlp_encode_block_access_list(block_access_list) + return keccak256(block_access_list_bytes) + + +def rlp_encode_storage_change(change: StorageChange) -> bytes: + """ + Encode a [`StorageChange`] as RLP. + + Encoded as: [block_access_index, new_value] + + Parameters + ---------- + change : + The storage change to encode. + + Returns + ------- + encoded : + The RLP-encoded storage change. + + [`StorageChange`]: ref:ethereum.osaka.rlp_types.StorageChange + """ + return rlp.encode([ + Uint(change.block_access_index), + change.new_value + ]) + + +def rlp_encode_balance_change(change: BalanceChange) -> bytes: + """ + Encode a [`BalanceChange`] as RLP. + + Encoded as: [block_access_index, post_balance] + + Parameters + ---------- + change : + The balance change to encode. + + Returns + ------- + encoded : + The RLP-encoded balance change. + + [`BalanceChange`]: ref:ethereum.osaka.rlp_types.BalanceChange + """ + return rlp.encode([ + Uint(change.block_access_index), + change.post_balance + ]) + + +def rlp_encode_nonce_change(change: NonceChange) -> bytes: + """ + Encode a [`NonceChange`] as RLP. + + Encoded as: [block_access_index, new_nonce] + + Parameters + ---------- + change : + The nonce change to encode. + + Returns + ------- + encoded : + The RLP-encoded nonce change. + + [`NonceChange`]: ref:ethereum.osaka.rlp_types.NonceChange + """ + return rlp.encode([ + Uint(change.block_access_index), + Uint(change.new_nonce) + ]) + + +def rlp_encode_code_change(change: CodeChange) -> bytes: + """ + Encode a [`CodeChange`] as RLP. + + Encoded as: [block_access_index, new_code] + + Parameters + ---------- + change : + The code change to encode. + + Returns + ------- + encoded : + The RLP-encoded code change. + + [`CodeChange`]: ref:ethereum.osaka.rlp_types.CodeChange + """ + return rlp.encode([ + Uint(change.block_access_index), + change.new_code + ]) + + +def rlp_encode_slot_changes(slot_changes: SlotChanges) -> bytes: + """ + Encode [`SlotChanges`] as RLP. + + Encoded as: [slot, [changes]] + + Parameters + ---------- + slot_changes : + The slot changes to encode. + + Returns + ------- + encoded : + The RLP-encoded slot changes. + + [`SlotChanges`]: ref:ethereum.osaka.rlp_types.SlotChanges + """ + # Encode each change as [block_access_index, new_value] + changes_list = [ + [Uint(change.block_access_index), change.new_value] + for change in slot_changes.changes + ] + + return rlp.encode([ + slot_changes.slot, + changes_list + ]) + + +def rlp_encode_account_changes(account: AccountChanges) -> bytes: + """ + Encode [`AccountChanges`] as RLP. + + Encoded as: [address, storage_changes, storage_reads, balance_changes, nonce_changes, code_changes] + + Parameters + ---------- + account : + The account changes to encode. + + Returns + ------- + encoded : + The RLP-encoded account changes. + + [`AccountChanges`]: ref:ethereum.osaka.rlp_types.AccountChanges + """ + # Encode storage_changes: [[slot, [[block_access_index, new_value], ...]], ...] + storage_changes_list = [ + [slot_changes.slot, [[Uint(c.block_access_index), c.new_value] for c in slot_changes.changes]] + for slot_changes in account.storage_changes + ] + + # Encode storage_reads: [slot1, slot2, ...] + storage_reads_list = list(account.storage_reads) + + # Encode balance_changes: [[block_access_index, post_balance], ...] + balance_changes_list = [ + [Uint(bc.block_access_index), bc.post_balance] + for bc in account.balance_changes + ] + + # Encode nonce_changes: [[block_access_index, new_nonce], ...] + nonce_changes_list = [ + [Uint(nc.block_access_index), Uint(nc.new_nonce)] + for nc in account.nonce_changes + ] + + # Encode code_changes: [[block_access_index, new_code], ...] + code_changes_list = [ + [Uint(cc.block_access_index), cc.new_code] + for cc in account.code_changes + ] + + return rlp.encode([ + account.address, + storage_changes_list, + storage_reads_list, + balance_changes_list, + nonce_changes_list, + code_changes_list + ]) + + +def rlp_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes: + """ + Encode a [`BlockAccessList`] to RLP bytes. + + This function produces the final RLP representation of a block's access list, + following the EIP-7928 specification. + + Parameters + ---------- + block_access_list : + The block access list to encode. + + Returns + ------- + encoded : + The complete RLP-encoded block access list. + + [`BlockAccessList`]: ref:ethereum.osaka.rlp_types.BlockAccessList + """ + # Encode as a list of AccountChanges directly (not wrapped) + account_changes_list = [] + for account in block_access_list.account_changes: + # Each account is encoded as: + # [address, storage_changes, storage_reads, balance_changes, nonce_changes, code_changes] + storage_changes_list = [ + [slot_changes.slot, [[Uint(c.block_access_index), c.new_value] for c in slot_changes.changes]] + for slot_changes in account.storage_changes + ] + + storage_reads_list = list(account.storage_reads) + + balance_changes_list = [ + [Uint(bc.block_access_index), bc.post_balance] + for bc in account.balance_changes + ] + + nonce_changes_list = [ + [Uint(nc.block_access_index), Uint(nc.new_nonce)] + for nc in account.nonce_changes + ] + + code_changes_list = [ + [Uint(cc.block_access_index), cc.new_code] + for cc in account.code_changes + ] + + account_changes_list.append([ + account.address, + storage_changes_list, + storage_reads_list, + balance_changes_list, + nonce_changes_list, + code_changes_list + ]) + + encoded = rlp.encode(account_changes_list) + return Bytes(encoded) + + +def validate_block_access_list_against_execution( + block_access_list: BlockAccessList, + block_access_list_builder: Optional['BlockAccessListBuilder'] = None +) -> bool: + """ + Validate that a Block Access List is structurally correct and optionally matches a builder's state. + + Parameters + ---------- + block_access_list : + The Block Access List to validate. + block_access_list_builder : + Optional Block Access List builder to validate against. If provided, checks that the + Block Access List hash matches what would be built from the builder's current state. + + Returns + ------- + valid : + True if the Block Access List is structurally valid and matches the builder (if provided). + """ + # 1. Validate structural constraints + + # Check that storage changes and reads don't overlap for the same slot + for account in block_access_list.account_changes: + changed_slots = {sc.slot for sc in account.storage_changes} + read_slots = set(account.storage_reads) + + # A slot should not be in both changes and reads (per EIP-7928) + if changed_slots & read_slots: + return False + + # 2. Validate ordering (addresses should be sorted lexicographically) + addresses = [account.address for account in block_access_list.account_changes] + if addresses != sorted(addresses): + return False + + # 3. Validate all data is within bounds + max_block_access_index = MAX_TXS + 1 # 0 for pre-exec, 1..MAX_TXS for txs, MAX_TXS+1 for post-exec + for account in block_access_list.account_changes: + # Validate storage slots are sorted within each account + storage_slots = [sc.slot for sc in account.storage_changes] + if storage_slots != sorted(storage_slots): + return False + + # Check storage changes + for slot_changes in account.storage_changes: + # Check changes are sorted by block_access_index + indices = [c.block_access_index for c in slot_changes.changes] + if indices != sorted(indices): + return False + + for change in slot_changes.changes: + if change.block_access_index > max_block_access_index: + return False + + # Check balance changes are sorted by block_access_index + balance_indices = [bc.block_access_index for bc in account.balance_changes] + if balance_indices != sorted(balance_indices): + return False + + for balance_change in account.balance_changes: + if balance_change.block_access_index > max_block_access_index: + return False + + # Check nonce changes are sorted by block_access_index + nonce_indices = [nc.block_access_index for nc in account.nonce_changes] + if nonce_indices != sorted(nonce_indices): + return False + + for nonce_change in account.nonce_changes: + if nonce_change.block_access_index > max_block_access_index: + return False + + # Check code changes are sorted by block_access_index + code_indices = [cc.block_access_index for cc in account.code_changes] + if code_indices != sorted(code_indices): + return False + + for code_change in account.code_changes: + if code_change.block_access_index > max_block_access_index: + return False + if len(code_change.new_code) > MAX_CODE_SIZE: + return False + + # 4. If Block Access List builder provided, validate against it by comparing hashes + if block_access_list_builder is not None: + from .builder import build + # Build a Block Access List from the builder + expected_block_access_list = build(block_access_list_builder) + + # Compare hashes + if compute_block_access_list_hash(block_access_list) != compute_block_access_list_hash(expected_block_access_list): + return False + + return True \ No newline at end of file diff --git a/src/ethereum/osaka/block_access_lists/tracker.py b/src/ethereum/osaka/block_access_lists/tracker.py new file mode 100644 index 0000000000..70f90edcf3 --- /dev/null +++ b/src/ethereum/osaka/block_access_lists/tracker.py @@ -0,0 +1,346 @@ +""" +Block Access List State Change Tracker for EIP-7928 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This module 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 Dict, Optional + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U32, U64, U256, Uint + +from ..rlp_types import BlockAccessIndex + +from ..fork_types import Address, Account +from ..state import State, get_account, get_storage +from .builder import ( + BlockAccessListBuilder, + add_balance_change, + add_code_change, + add_nonce_change, + add_storage_read, + add_storage_write, + add_touched_account, +) + + +@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.osaka.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. + """ + + current_block_access_index: int = 0 + """ + The current block access index (0 for pre-execution, 1..n for transactions, n+1 for post-execution). + """ + + +def set_transaction_index(tracker: StateChangeTracker, block_access_index: int) -> 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. + + Parameters + ---------- + tracker : + The state change tracker instance. + block_access_index : + The block access index (0 for pre-execution, 1..n for transactions, n+1 for post-execution). + """ + tracker.current_block_access_index = block_access_index + # Clear the pre-storage cache for each new transaction to ensure + # no-op writes are detected relative to the transaction start + tracker.pre_storage_cache.clear() + + +def capture_pre_state( + tracker: StateChangeTracker, + address: Address, + key: Bytes, + 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: + tracker.pre_storage_cache[cache_key] = get_storage(state, address, key) + return tracker.pre_storage_cache[cache_key] + + +def track_address_access(tracker: StateChangeTracker, address: Address) -> None: + """ + Track that an address was accessed. + + Records account access even when no state changes occur. This is + important for operations that read account data without modifying it. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address that was accessed. + """ + add_touched_account(tracker.block_access_list_builder, address) + + +def track_storage_read( + tracker: StateChangeTracker, + address: Address, + key: Bytes, + state: State +) -> None: + """ + Track a storage read operation. + + Records that a storage slot was read and captures its pre-state value. + The slot will only appear in the final access list if it wasn't also + written to during block execution. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose storage is being read. + key : + The storage slot being read. + state : + The current execution state. + """ + track_address_access(tracker, address) + + capture_pre_state(tracker, address, key, state) + + add_storage_read(tracker.block_access_list_builder, address, key) + + +def track_storage_write( + tracker: StateChangeTracker, + address: Address, + key: Bytes, + new_value: U256, + state: State +) -> None: + """ + Track a storage write operation. + + Records storage modifications, but only if the new value differs from + the pre-state value. No-op writes (where the value doesn't change) are + tracked as reads instead, as specified in [EIP-7928]. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose storage is being modified. + key : + The storage slot being written to. + new_value : + The new value to write. + state : + The current execution state. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + """ + track_address_access(tracker, address) + + pre_value = capture_pre_state(tracker, address, key, state) + + value_bytes = new_value.to_be_bytes32() + + if pre_value != new_value: + add_storage_write( + tracker.block_access_list_builder, + address, + key, + BlockAccessIndex(tracker.current_block_access_index), + value_bytes + ) + else: + add_storage_read(tracker.block_access_list_builder, address, key) + + +def track_balance_change( + tracker: StateChangeTracker, + address: Address, + new_balance: U256, + state: State +) -> None: + """ + Track a balance change for an account. + + Records the new balance after any balance-affecting operation, including + transfers, gas payments, block rewards, and withdrawals. The balance is + encoded as a 16-byte value (uint128) which is sufficient for the total + ETH supply. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose balance changed. + new_balance : + The new balance value. + state : + The current execution state. + """ + track_address_access(tracker, address) + + add_balance_change( + tracker.block_access_list_builder, + address, + BlockAccessIndex(tracker.current_block_access_index), + new_balance + ) + + +def track_nonce_change( + tracker: StateChangeTracker, + address: Address, + new_nonce: Uint, + state: State +) -> None: + """ + Track a nonce change for an account. + + Records nonce increments for both EOAs (when sending transactions) and + contracts (when performing [`CREATE`] or [`CREATE2`] operations). Deployed + contracts also have their initial nonce tracked. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose nonce changed. + new_nonce : + The new nonce value. + state : + The current execution state. + + [`CREATE`]: ref:ethereum.osaka.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.osaka.vm.instructions.system.create2 + """ + track_address_access(tracker, address) + add_nonce_change( + tracker.block_access_list_builder, + address, + BlockAccessIndex(tracker.current_block_access_index), + U64(new_nonce) + ) + + +def track_code_change( + tracker: StateChangeTracker, + address: Address, + new_code: Bytes, + state: State +) -> None: + """ + Track a code change for contract deployment. + + Records new contract code deployments via [`CREATE`], [`CREATE2`], or + [`SETCODE`] operations. This function is called when contract bytecode + is deployed to an address. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The address receiving the contract code. + new_code : + The deployed contract bytecode. + state : + The current execution state. + + [`CREATE`]: ref:ethereum.osaka.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.osaka.vm.instructions.system.create2 + [`SETCODE`]: ref:ethereum.osaka.vm.instructions.system.setcode + """ + track_address_access(tracker, address) + add_code_change( + tracker.block_access_list_builder, + address, + BlockAccessIndex(tracker.current_block_access_index), + new_code + ) + + +def finalize_transaction_changes( + tracker: StateChangeTracker, + state: State +) -> None: + """ + Finalize changes for the current transaction. + + This method is called at the end of each transaction execution. Currently + a no-op as all tracking is done incrementally during execution, but + provided for future extensibility. + + Parameters + ---------- + tracker : + The state change tracker instance. + state : + The current execution state. + """ + pass \ No newline at end of file diff --git a/src/ethereum/osaka/block_access_lists/utils.py b/src/ethereum/osaka/block_access_lists/utils.py new file mode 100644 index 0000000000..a38ebc43b1 --- /dev/null +++ b/src/ethereum/osaka/block_access_lists/utils.py @@ -0,0 +1,521 @@ +""" +Block Access List Utilities for EIP-7928 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Utilities for working with Block Access Lists, including SSZ encoding, +hashing, and validation functions. + +This module provides: + +- SSZ encoding functions for all Block Access List types +- Hash computation using [`keccak256`] +- Validation logic to ensure structural correctness + +The encoding follows the [SSZ specification] used in Ethereum consensus layer. + +[`keccak256`]: ref:ethereum.crypto.hash.keccak256 +[SSZ specification]: https://github.com/ethereum/consensus-specs/blob/dev/ssz/simple-serialize.md +""" + +from typing import Union, Optional +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import Hash32, keccak256 + +from ..ssz_types import ( + BlockAccessList, + AccountChanges, + SlotChanges, + SlotRead, + StorageChange, + BalanceChange, + NonceChange, + CodeChange, + MAX_TRANSACTIONS, + MAX_SLOTS, + MAX_ACCOUNTS, + MAX_CODE_SIZE, +) + + +def compute_block_access_list_hash(block_access_list: BlockAccessList) -> Hash32: + """ + Compute the hash of a Block Access List. + + The Block Access List is SSZ-encoded and then hashed with keccak256. + + Parameters + ---------- + block_access_list : + The Block Access List to hash. + + Returns + ------- + hash : + The keccak256 hash of the SSZ-encoded Block Access List. + """ + block_access_list_bytes = ssz_encode_block_access_list(block_access_list) + return keccak256(block_access_list_bytes) + + +def ssz_encode_uint(value: Union[int, Uint], size: int) -> bytes: + """ + Encode an unsigned integer as SSZ (little-endian). + + Parameters + ---------- + value : + The integer value to encode. + size : + The size in bytes for the encoded output. + + Returns + ------- + encoded : + The little-endian encoded bytes. + """ + if isinstance(value, Uint): + value = int(value) + return value.to_bytes(size, 'little') + + +def ssz_encode_bytes(data: bytes) -> bytes: + """ + Encode fixed-size bytes as SSZ. + + For fixed-size byte arrays, SSZ encoding is simply the bytes themselves. + + Parameters + ---------- + data : + The bytes to encode. + + Returns + ------- + encoded : + The encoded bytes (unchanged). + """ + return data + + +def ssz_encode_list(items: tuple, encode_item_fn, max_length: int = None) -> bytes: + """ + Encode a list or tuple as SSZ. + + Handles both fixed-length and variable-length lists according to the + [SSZ specification]. Variable-length lists use offset encoding when + elements have variable size. + + Parameters + ---------- + items : + The tuple of items to encode. + encode_item_fn : + Function to encode individual items. + max_length : + Maximum list length (if specified, indicates variable-length list). + + Returns + ------- + encoded : + The SSZ-encoded list. + + [SSZ specification]: https://github.com/ethereum/consensus-specs/blob/dev/ssz/simple-serialize.md + """ + result = bytearray() + + if max_length is None: + # Fixed-length list/tuple: just concatenate + for item in items: + result.extend(encode_item_fn(item)) + else: + # Variable-length lists use offset encoding + item_count = len(items) + if item_count == 0: + # Empty list is encoded as just the 4-byte offset pointing to itself + return ssz_encode_uint(4, 4) + + # Calculate if items are fixed or variable size + first_item_encoded = encode_item_fn(items[0]) if items else b'' + is_fixed_size = all(len(encode_item_fn(item)) == len(first_item_encoded) for item in items) + + if is_fixed_size: + # Fixed-size elements: concatenate directly + for item in items: + result.extend(encode_item_fn(item)) + else: + # Variable-size elements: use offset encoding + # Reserve space for offsets + offset_start = 4 * item_count + data_section = bytearray() + + for item in items: + # Write offset + result.extend(ssz_encode_uint(offset_start + len(data_section), 4)) + # Encode item data + item_data = encode_item_fn(item) + data_section.extend(item_data) + + result.extend(data_section) + + return bytes(result) + + +def ssz_encode_storage_change(change: StorageChange) -> bytes: + """ + Encode a [`StorageChange`] as SSZ. + + Parameters + ---------- + change : + The storage change to encode. + + Returns + ------- + encoded : + The SSZ-encoded storage change. + + [`StorageChange`]: ref:ethereum.osaka.ssz_types.StorageChange + """ + return ( + ssz_encode_uint(change.tx_index, 2) # TxIndex as uint16 + + ssz_encode_bytes(change.new_value) # StorageValue as Bytes32 + ) + + +def ssz_encode_balance_change(change: BalanceChange) -> bytes: + """ + Encode a [`BalanceChange`] as SSZ. + + Parameters + ---------- + change : + The balance change to encode. + + Returns + ------- + encoded : + The SSZ-encoded balance change. + + [`BalanceChange`]: ref:ethereum.osaka.ssz_types.BalanceChange + """ + return ( + ssz_encode_uint(change.tx_index, 2) # TxIndex as uint16 + + ssz_encode_uint(change.post_balance, 32) # Balance as uint256 + ) + + +def ssz_encode_nonce_change(change: NonceChange) -> bytes: + """ + Encode a [`NonceChange`] as SSZ. + + Parameters + ---------- + change : + The nonce change to encode. + + Returns + ------- + encoded : + The SSZ-encoded nonce change. + + [`NonceChange`]: ref:ethereum.osaka.ssz_types.NonceChange + """ + return ( + ssz_encode_uint(change.tx_index, 2) # TxIndex as uint16 + + ssz_encode_uint(change.new_nonce, 8) # Nonce as uint64 + ) + + +def ssz_encode_code_change(change: CodeChange) -> bytes: + """ + Encode a [`CodeChange`] as SSZ. + + Code changes use variable-length encoding since contract bytecode + can vary in size up to [`MAX_CODE_SIZE`]. + + Parameters + ---------- + change : + The code change to encode. + + Returns + ------- + encoded : + The SSZ-encoded code change. + + [`CodeChange`]: ref:ethereum.osaka.ssz_types.CodeChange + [`MAX_CODE_SIZE`]: ref:ethereum.osaka.ssz_types.MAX_CODE_SIZE + """ + result = bytearray() + result.extend(ssz_encode_uint(change.tx_index, 2)) # TxIndex as uint16 + # Code is variable length, so we encode length first for variable-size containers + code_len = len(change.new_code) + # In SSZ, variable-length byte arrays are prefixed with their length + result.extend(ssz_encode_uint(code_len, 4)) + result.extend(change.new_code) + return bytes(result) + + +def ssz_encode_slot_changes(slot_changes: SlotChanges) -> bytes: + """ + Encode [`SlotChanges`] as SSZ. + + Encodes a storage slot and all changes made to it during block execution. + + Parameters + ---------- + slot_changes : + The slot changes to encode. + + Returns + ------- + encoded : + The SSZ-encoded slot changes. + + [`SlotChanges`]: ref:ethereum.osaka.ssz_types.SlotChanges + """ + result = bytearray() + result.extend(ssz_encode_bytes(slot_changes.slot)) # StorageKey as Bytes32 + # Encode the list of changes + changes_encoded = ssz_encode_list( + slot_changes.changes, + ssz_encode_storage_change, + MAX_TRANSACTIONS # max length for changes + ) + result.extend(changes_encoded) + return bytes(result) + + +def ssz_encode_slot_read(slot_read: SlotRead) -> bytes: + """ + Encode a [`SlotRead`] as SSZ. + + For read-only slots, only the slot key is encoded. + + Parameters + ---------- + slot_read : + The slot read to encode. + + Returns + ------- + encoded : + The SSZ-encoded slot read. + + [`SlotRead`]: ref:ethereum.osaka.ssz_types.SlotRead + """ + return ssz_encode_bytes(slot_read.slot) # StorageKey as Bytes32 + + +def ssz_encode_account_changes(account: AccountChanges) -> bytes: + """ + Encode [`AccountChanges`] as SSZ. + + Encodes all changes for a single account using variable-size struct + encoding with offsets for the variable-length fields. + + Parameters + ---------- + account : + The account changes to encode. + + Returns + ------- + encoded : + The SSZ-encoded account changes. + + [`AccountChanges`]: ref:ethereum.osaka.ssz_types.AccountChanges + """ + # For variable-size struct, we use offset encoding + result = bytearray() + offsets = [] + data_section = bytearray() + + # Fixed-size fields first + result.extend(ssz_encode_bytes(account.address)) # Address as Bytes20 + + # Variable-size fields use offsets + # Calculate base offset (after all fixed fields and offset values) + base_offset = 20 + (5 * 4) # address + 5 offset fields + + # Encode storage_changes + storage_changes_data = ssz_encode_list( + account.storage_changes, + ssz_encode_slot_changes, + MAX_SLOTS + ) + offsets.append(base_offset + len(data_section)) + data_section.extend(storage_changes_data) + + # Encode storage_reads + storage_reads_data = ssz_encode_list( + account.storage_reads, + ssz_encode_slot_read, + MAX_SLOTS + ) + offsets.append(base_offset + len(data_section)) + data_section.extend(storage_reads_data) + + # Encode balance_changes + balance_changes_data = ssz_encode_list( + account.balance_changes, + ssz_encode_balance_change, + MAX_TRANSACTIONS + ) + offsets.append(base_offset + len(data_section)) + data_section.extend(balance_changes_data) + + # Encode nonce_changes + nonce_changes_data = ssz_encode_list( + account.nonce_changes, + ssz_encode_nonce_change, + MAX_TRANSACTIONS + ) + offsets.append(base_offset + len(data_section)) + data_section.extend(nonce_changes_data) + + # Encode code_changes + code_changes_data = ssz_encode_list( + account.code_changes, + ssz_encode_code_change, + MAX_TRANSACTIONS + ) + offsets.append(base_offset + len(data_section)) + data_section.extend(code_changes_data) + + # Write offsets + for offset in offsets: + result.extend(ssz_encode_uint(offset, 4)) + + # Write data section + result.extend(data_section) + + return bytes(result) + + +def ssz_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes: + """ + Encode a [`BlockAccessList`] to SSZ bytes. + + This is the top-level encoding function that produces the final SSZ + representation of a block's access list, following the [SSZ specification] + for Ethereum. + + Parameters + ---------- + block_access_list : + The block access list to encode. + + Returns + ------- + encoded : + The complete SSZ-encoded block access list. + + [`BlockAccessList`]: ref:ethereum.osaka.ssz_types.BlockAccessList + [SSZ specification]: https://github.com/ethereum/consensus-specs/blob/dev/ssz/simple-serialize.md + """ + encoded = ssz_encode_list( + block_access_list.account_changes, + ssz_encode_account_changes, + MAX_ACCOUNTS + ) + return Bytes(encoded) + + +def validate_block_access_list_against_execution( + block_access_list: BlockAccessList, + block_access_list_builder: Optional['BlockAccessListBuilder'] = None +) -> bool: + """ + Validate that a Block Access List is structurally correct and optionally matches a builder's state. + + Parameters + ---------- + block_access_list : + The Block Access List to validate. + block_access_list_builder : + Optional Block Access List builder to validate against. If provided, checks that the + Block Access List hash matches what would be built from the builder's current state. + + Returns + ------- + valid : + True if the Block Access List is structurally valid and matches the builder (if provided). + """ + # 1. Validate structural constraints + + # Check that storage changes and reads don't overlap for the same slot + for account in block_access_list.account_changes: + changed_slots = {sc.slot for sc in account.storage_changes} + read_slots = {sr.slot for sr in account.storage_reads} + + # A slot should not be in both changes and reads (per EIP-7928) + if changed_slots & read_slots: + return False + + # 2. Validate ordering (addresses should be sorted lexicographically) + addresses = [account.address for account in block_access_list.account_changes] + if addresses != sorted(addresses): + return False + + # 3. Validate all data is within bounds + max_tx_index = MAX_TRANSACTIONS - 1 + for account in block_access_list.account_changes: + # Validate storage slots are sorted within each account + storage_slots = [sc.slot for sc in account.storage_changes] + if storage_slots != sorted(storage_slots): + return False + + # Check storage changes + for slot_changes in account.storage_changes: + # Check changes are sorted by tx_index + tx_indices = [c.tx_index for c in slot_changes.changes] + if tx_indices != sorted(tx_indices): + return False + + for change in slot_changes.changes: + if change.tx_index > max_tx_index: + return False + + # Check balance changes are sorted by tx_index + balance_tx_indices = [bc.tx_index for bc in account.balance_changes] + if balance_tx_indices != sorted(balance_tx_indices): + return False + + for balance_change in account.balance_changes: + if balance_change.tx_index > max_tx_index: + return False + + # Check nonce changes are sorted by tx_index + nonce_tx_indices = [nc.tx_index for nc in account.nonce_changes] + if nonce_tx_indices != sorted(nonce_tx_indices): + return False + + for nonce_change in account.nonce_changes: + if nonce_change.tx_index > max_tx_index: + return False + + # Check code changes are sorted by tx_index + code_tx_indices = [cc.tx_index for cc in account.code_changes] + if code_tx_indices != sorted(code_tx_indices): + return False + + for code_change in account.code_changes: + if code_change.tx_index > max_tx_index: + return False + if len(code_change.new_code) > MAX_CODE_SIZE: + return False + + # 4. If Block Access List builder provided, validate against it by comparing hashes + if block_access_list_builder is not None: + from .builder import build + # Build a Block Access List from the builder + expected_block_access_list = build(block_access_list_builder) + + # Compare hashes - much simpler! + if compute_block_access_list_hash(block_access_list) != compute_block_access_list_hash(expected_block_access_list): + return False + + return True \ No newline at end of file diff --git a/src/ethereum/osaka/blocks.py b/src/ethereum/osaka/blocks.py index fd906d8f71..0ebd90b2ae 100644 --- a/src/ethereum/osaka/blocks.py +++ b/src/ethereum/osaka/blocks.py @@ -18,6 +18,7 @@ from ..crypto.hash import Hash32 from .fork_types import Address, Bloom, Root +from .rlp_types import BlockAccessList from .transactions import ( AccessListTransaction, BlobTransaction, @@ -240,6 +241,14 @@ class Header: [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 """ + bal_hash: Hash32 + """ + Hash of the Block Access List containing all accounts and storage + locations accessed during block execution. Introduced in [EIP-7928]. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + """ + @slotted_freezable @dataclass @@ -293,6 +302,14 @@ class Block: A tuple of withdrawals processed in this block. """ + block_access_list: BlockAccessList + """ + Block Access List containing all accounts and storage locations accessed + during block execution. Introduced in [EIP-7928]. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + """ + @slotted_freezable @dataclass diff --git a/src/ethereum/osaka/fork.py b/src/ethereum/osaka/fork.py index 22c07e0ae8..1dcb786d80 100644 --- a/src/ethereum/osaka/fork.py +++ b/src/ethereum/osaka/fork.py @@ -30,6 +30,7 @@ ) from . import vm +from .block_access_lists import StateChangeTracker, compute_block_access_list_hash, build, set_transaction_index, track_balance_change from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom from .exceptions import ( @@ -243,6 +244,10 @@ def state_transition(chain: BlockChain, block: Block) -> None: block_logs_bloom = logs_bloom(block_output.block_logs) withdrawals_root = root(block_output.withdrawals_trie) requests_hash = compute_requests_hash(block_output.requests) + + # Build and validate Block Access List + computed_block_access_list = build(block_output.block_access_list_builder) + computed_block_access_list_hash = compute_block_access_list_hash(computed_block_access_list) if block_output.block_gas_used != block.header.gas_used: raise InvalidBlock( @@ -262,6 +267,10 @@ def state_transition(chain: BlockChain, block: Block) -> None: raise InvalidBlock if requests_hash != block.header.requests_hash: raise InvalidBlock + if computed_bal_hash != block.header.bal_hash: + raise InvalidBlock + if computed_block_access_list != block.block_access_list: + raise InvalidBlock chain.blocks.append(block) if len(chain.blocks) > 255: @@ -581,6 +590,7 @@ def process_system_transaction( target_address: Address, system_contract_code: Bytes, data: Bytes, + change_tracker: Optional[StateChangeTracker] = None, ) -> MessageCallOutput: """ Process a system transaction with the given code. @@ -637,6 +647,7 @@ def process_system_transaction( accessed_storage_keys=set(), disable_precompiles=False, parent_evm=None, + change_tracker=change_tracker, ) system_tx_output = process_message_call(system_tx_message) @@ -648,6 +659,7 @@ def process_checked_system_transaction( block_env: vm.BlockEnvironment, target_address: Address, data: Bytes, + change_tracker: Optional[StateChangeTracker] = None, ) -> MessageCallOutput: """ Process a system transaction and raise an error if the contract does not @@ -680,6 +692,7 @@ def process_checked_system_transaction( target_address, system_contract_code, data, + change_tracker, ) if system_tx_output.error: @@ -695,6 +708,7 @@ def process_unchecked_system_transaction( block_env: vm.BlockEnvironment, target_address: Address, data: Bytes, + change_tracker: Optional[StateChangeTracker] = None, ) -> MessageCallOutput: """ Process a system transaction without checking if the contract contains code @@ -720,6 +734,7 @@ def process_unchecked_system_transaction( target_address, system_contract_code, data, + change_tracker, ) @@ -753,27 +768,43 @@ def apply_body( The block output for the current block. """ block_output = vm.BlockOutput() + + # Initialize Block Access List state change tracker + change_tracker = StateChangeTracker(block_output.block_access_list_builder) + + # Set system transaction index for pre-execution system contracts + # EIP-7928: System contracts use bal_index 0 + set_transaction_index(change_tracker, 0) process_unchecked_system_transaction( block_env=block_env, target_address=BEACON_ROOTS_ADDRESS, data=block_env.parent_beacon_block_root, + change_tracker=change_tracker, ) process_unchecked_system_transaction( block_env=block_env, target_address=HISTORY_STORAGE_ADDRESS, data=block_env.block_hashes[-1], # The parent hash + change_tracker=change_tracker, ) + # EIP-7928: Transactions use bal_index 1 to len(transactions) for i, tx in enumerate(map(decode_transaction, transactions)): - process_transaction(block_env, block_output, tx, Uint(i)) + set_transaction_index(change_tracker, i + 1) + process_transaction(block_env, block_output, tx, Uint(i), change_tracker) - process_withdrawals(block_env, block_output, withdrawals) + # EIP-7928: Post-execution uses bal_index len(transactions) + 1 + post_execution_index = len(transactions) + 1 + set_transaction_index(change_tracker, post_execution_index) + process_withdrawals(block_env, block_output, withdrawals, change_tracker) + process_general_purpose_requests( block_env=block_env, block_output=block_output, + change_tracker=change_tracker, ) return block_output @@ -782,6 +813,7 @@ def apply_body( def process_general_purpose_requests( block_env: vm.BlockEnvironment, block_output: vm.BlockOutput, + change_tracker: StateChangeTracker, ) -> None: """ Process all the requests in the block. @@ -803,6 +835,7 @@ def process_general_purpose_requests( block_env=block_env, target_address=WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, data=b"", + change_tracker=change_tracker, ) if len(system_withdrawal_tx_output.return_data) > 0: @@ -814,6 +847,7 @@ def process_general_purpose_requests( block_env=block_env, target_address=CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, data=b"", + change_tracker=change_tracker, ) if len(system_consolidation_tx_output.return_data) > 0: @@ -828,6 +862,7 @@ def process_transaction( block_output: vm.BlockOutput, tx: Transaction, index: Uint, + change_tracker: StateChangeTracker, ) -> None: """ Execute a transaction against the provided environment. @@ -881,13 +916,13 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender) + increment_nonce(block_env.state, sender, change_tracker) 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), change_tracker ) access_list_addresses = set() @@ -926,6 +961,7 @@ def process_transaction( ) message = prepare_message(block_env, tx_env, tx) + message.change_tracker = change_tracker tx_output = process_message_call(message) @@ -954,7 +990,7 @@ 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, change_tracker) # transfer miner fees coinbase_balance_after_mining_fee = get_account( @@ -965,6 +1001,7 @@ def process_transaction( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, + change_tracker ) elif account_exists_and_is_empty(block_env.state, block_env.coinbase): destroy_account(block_env.state, block_env.coinbase) @@ -995,6 +1032,7 @@ def process_withdrawals( block_env: vm.BlockEnvironment, block_output: vm.BlockOutput, withdrawals: Tuple[Withdrawal, ...], + change_tracker: StateChangeTracker, ) -> None: """ Increase the balance of the withdrawing account. @@ -1011,6 +1049,10 @@ def increase_recipient_balance(recipient: Account) -> None: ) modify_state(block_env.state, wd.address, increase_recipient_balance) + + # Track balance change for BAL (withdrawals are tracked as system contract changes) + new_balance = get_account(block_env.state, wd.address).balance + track_balance_change(change_tracker, wd.address, U256(new_balance), block_env.state) if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) diff --git a/src/ethereum/osaka/rlp_types.py b/src/ethereum/osaka/rlp_types.py new file mode 100644 index 0000000000..c87577ce76 --- /dev/null +++ b/src/ethereum/osaka/rlp_types.py @@ -0,0 +1,114 @@ +""" +RLP Types for EIP-7928 Block-Level Access Lists +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This module defines the RLP data structures for Block-Level Access Lists +as specified in EIP-7928. These structures enable efficient encoding and +decoding of all accounts and storage locations accessed during block execution. + +The encoding follows the pattern: address -> field -> block_access_index -> change +""" + +from dataclasses import dataclass +from typing import List, Tuple + +from ethereum_types.bytes import Bytes, Bytes20, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint + +# Type aliases for clarity (matching EIP-7928 specification) +Address = Bytes20 +StorageKey = Bytes32 +StorageValue = Bytes32 +CodeData = Bytes +BlockAccessIndex = Uint # uint16 in the spec, but using Uint for compatibility +Balance = U256 # Post-transaction balance in wei +Nonce = U64 + +# Constants chosen to support a 630m block gas limit +MAX_TXS = 30_000 +MAX_SLOTS = 300_000 +MAX_ACCOUNTS = 300_000 +MAX_CODE_SIZE = 24_576 +MAX_CODE_CHANGES = 1 + + +@slotted_freezable +@dataclass +class StorageChange: + """ + Storage change: [block_access_index, new_value] + RLP encoded as a list + """ + block_access_index: BlockAccessIndex + new_value: StorageValue + + +@slotted_freezable +@dataclass +class BalanceChange: + """ + Balance change: [block_access_index, post_balance] + RLP encoded as a list + """ + block_access_index: BlockAccessIndex + post_balance: Balance + + +@slotted_freezable +@dataclass +class NonceChange: + """ + Nonce change: [block_access_index, new_nonce] + RLP encoded as a list + """ + block_access_index: BlockAccessIndex + new_nonce: Nonce + + +@slotted_freezable +@dataclass +class CodeChange: + """ + Code change: [block_access_index, new_code] + RLP encoded as a list + """ + block_access_index: BlockAccessIndex + new_code: CodeData + + +@slotted_freezable +@dataclass +class SlotChanges: + """ + All changes to a single storage slot: [slot, [changes]] + RLP encoded as a list + """ + slot: StorageKey + changes: Tuple[StorageChange, ...] + + +@slotted_freezable +@dataclass +class AccountChanges: + """ + All changes for a single account, grouped by field type. + RLP encoded as: [address, storage_changes, storage_reads, balance_changes, nonce_changes, code_changes] + """ + address: Address + storage_changes: Tuple[SlotChanges, ...] # slot -> [block_access_index -> new_value] + storage_reads: Tuple[StorageKey, ...] # read-only storage keys + balance_changes: Tuple[BalanceChange, ...] # [block_access_index -> post_balance] + nonce_changes: Tuple[NonceChange, ...] # [block_access_index -> new_nonce] + code_changes: Tuple[CodeChange, ...] # [block_access_index -> new_code] + + +@slotted_freezable +@dataclass +class BlockAccessList: + """ + Block-Level Access List for EIP-7928. + Contains all addresses accessed during block execution. + RLP encoded as a list of AccountChanges + """ + account_changes: Tuple[AccountChanges, ...] \ No newline at end of file diff --git a/src/ethereum/osaka/ssz_types.py b/src/ethereum/osaka/ssz_types.py new file mode 100644 index 0000000000..4e68ac1818 --- /dev/null +++ b/src/ethereum/osaka/ssz_types.py @@ -0,0 +1,97 @@ +""" +SSZ Types for EIP-7928 Block-Level Access Lists +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This module defines the SSZ data structures for Block-Level Access Lists +as specified in EIP-7928. These structures enable efficient encoding and +decoding of all accounts and storage locations accessed during block execution. +""" + +from dataclasses import dataclass +from typing import List, Tuple + +from ethereum_types.bytes import Bytes, Bytes20, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U256, Uint + +# Type aliases for clarity +Address = Bytes20 +StorageKey = Bytes32 +StorageValue = Bytes32 +TxIndex = Uint +Balance = Bytes # uint128 - Post-transaction balance in wei (16 bytes, sufficient for total ETH supply) +Nonce = Uint + +# Constants chosen to support a 630m block gas limit +MAX_TRANSACTIONS = 30_000 +MAX_SLOTS = 300_000 +MAX_ACCOUNTS = 300_000 +MAX_CODE_SIZE = 24_576 +MAX_CODE_CHANGES = 1 + + +@slotted_freezable +@dataclass +class StorageChange: + """Single storage write: tx_index -> new_value""" + tx_index: TxIndex + new_value: StorageValue + + +@slotted_freezable +@dataclass +class BalanceChange: + """Single balance change: tx_index -> post_balance""" + tx_index: TxIndex + post_balance: Balance + + +@slotted_freezable +@dataclass +class NonceChange: + """Single nonce change: tx_index -> new_nonce""" + tx_index: TxIndex + new_nonce: Nonce + + +@slotted_freezable +@dataclass +class CodeChange: + """Single code change: tx_index -> new_code""" + tx_index: TxIndex + new_code: Bytes + + +@slotted_freezable +@dataclass +class SlotChanges: + """All changes to a single storage slot""" + slot: StorageKey + changes: Tuple[StorageChange, ...] + + + + +@slotted_freezable +@dataclass +class AccountChanges: + """ + All changes for a single account, grouped by field type. + This eliminates address redundancy across different change types. + """ + address: Address + storage_changes: Tuple[SlotChanges, ...] + storage_reads: Tuple[StorageKey, ...] + balance_changes: Tuple[BalanceChange, ...] + nonce_changes: Tuple[NonceChange, ...] + code_changes: Tuple[CodeChange, ...] + + +@slotted_freezable +@dataclass +class BlockAccessList: + """ + Block-Level Access List for EIP-7928. + Contains all addresses accessed during block execution. + """ + account_changes: Tuple[AccountChanges, ...] \ No newline at end of file diff --git a/src/ethereum/osaka/state.py b/src/ethereum/osaka/state.py index b067ed2286..2e46f1d913 100644 --- a/src/ethereum/osaka/state.py +++ b/src/ethereum/osaka/state.py @@ -27,6 +27,11 @@ from .fork_types import EMPTY_ACCOUNT, Account, Address, Root from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set +# Forward declaration for type hints +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .block_access_lists import StateChangeTracker + @dataclass class State: @@ -488,6 +493,7 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, + change_tracker: "StateChangeTracker", ) -> None: """ Move funds between accounts. @@ -503,9 +509,22 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - - -def set_account_balance(state: State, address: Address, amount: U256) -> None: + + if change_tracker is not None: + from .block_access_lists.tracker import track_balance_change + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance + + track_balance_change(change_tracker, sender_address, U256(sender_new_balance), state) + track_balance_change(change_tracker, recipient_address, U256(recipient_new_balance), state) + + +def set_account_balance( + state: State, + address: Address, + amount: U256, + change_tracker: "StateChangeTracker", +) -> None: """ Sets the balance of an account. @@ -519,15 +538,22 @@ def set_account_balance(state: State, address: Address, amount: U256) -> None: amount: The amount that needs to set in balance. + + change_tracker: + Change tracker to record balance changes. """ def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) + + if change_tracker is not None: + from .block_access_lists.tracker import track_balance_change + track_balance_change(change_tracker, address, amount, state) -def increment_nonce(state: State, address: Address) -> None: +def increment_nonce(state: State, address: Address, change_tracker: "StateChangeTracker") -> None: """ Increments the nonce of an account. @@ -538,15 +564,33 @@ def increment_nonce(state: State, address: Address) -> None: address: Address of the account whose nonce needs to be incremented. + + change_tracker: + Change tracker for EIP-7928. """ def increase_nonce(sender: Account) -> None: sender.nonce += Uint(1) modify_state(state, address, increase_nonce) + + # Track nonce change for Block Access List (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + from .block_access_lists.tracker import track_nonce_change + account = get_account(state, address) + track_nonce_change(change_tracker, address, account.nonce, state) -def set_code(state: State, address: Address, code: Bytes) -> None: +def set_code( + state: State, + address: Address, + code: Bytes, + change_tracker: "StateChangeTracker", +) -> None: """ Sets Account code. @@ -560,12 +604,18 @@ def set_code(state: State, address: Address, code: Bytes) -> None: code: The bytecode that needs to be set. + + change_tracker: + Change tracker for EIP-7928. """ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) + + from .block_access_lists.tracker import track_code_change + track_code_change(change_tracker, address, code, state) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/osaka/vm/__init__.py b/src/ethereum/osaka/vm/__init__.py index df75f66a6e..6069cdcd5d 100644 --- a/src/ethereum/osaka/vm/__init__.py +++ b/src/ethereum/osaka/vm/__init__.py @@ -22,12 +22,17 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException +from ..block_access_lists import BlockAccessListBuilder from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage from ..transactions import LegacyTransaction from ..trie import Trie +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from ..block_access_lists import StateChangeTracker + __all__ = ("Environment", "Evm", "Message") @@ -90,6 +95,7 @@ class BlockOutput: ) blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) + block_access_list_builder: BlockAccessListBuilder = field(default_factory=BlockAccessListBuilder) @dataclass @@ -134,6 +140,7 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] + change_tracker: Optional["StateChangeTracker"] = None @dataclass diff --git a/src/ethereum/osaka/vm/eoa_delegation.py b/src/ethereum/osaka/vm/eoa_delegation.py index 1fe2e1e7bd..ecf64b524f 100644 --- a/src/ethereum/osaka/vm/eoa_delegation.py +++ b/src/ethereum/osaka/vm/eoa_delegation.py @@ -195,9 +195,9 @@ def set_delegation(message: Message) -> U256: code_to_set = b"" else: code_to_set = EOA_DELEGATION_MARKER + auth.address - set_code(state, authority, code_to_set) + set_code(state, authority, code_to_set, message.change_tracker) - increment_nonce(state, authority) + increment_nonce(state, authority, message.change_tracker) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/osaka/vm/instructions/environment.py b/src/ethereum/osaka/vm/instructions/environment.py index 226b3d3bb3..6d144a0087 100644 --- a/src/ethereum/osaka/vm/instructions/environment.py +++ b/src/ethereum/osaka/vm/instructions/environment.py @@ -86,6 +86,10 @@ def balance(evm: Evm) -> None: # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. balance = get_account(evm.message.block_env.state, address).balance + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_address_access + track_address_access(evm.message.change_tracker, address) push(evm.stack, balance) @@ -352,6 +356,10 @@ def extcodesize(evm: Evm) -> None: # OPERATION code = get_account(evm.message.block_env.state, address).code + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_address_access + track_address_access(evm.message.change_tracker, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -394,6 +402,10 @@ def extcodecopy(evm: Evm) -> None: # OPERATION evm.memory += b"\x00" * extend_memory.expand_by code = get_account(evm.message.block_env.state, address).code + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_address_access + track_address_access(evm.message.change_tracker, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -480,6 +492,10 @@ def extcodehash(evm: Evm) -> None: # OPERATION account = get_account(evm.message.block_env.state, address) + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_address_access + track_address_access(evm.message.change_tracker, address) if account == EMPTY_ACCOUNT: codehash = U256(0) diff --git a/src/ethereum/osaka/vm/instructions/storage.py b/src/ethereum/osaka/vm/instructions/storage.py index 65a0d5a9b6..38ba054356 100644 --- a/src/ethereum/osaka/vm/instructions/storage.py +++ b/src/ethereum/osaka/vm/instructions/storage.py @@ -59,6 +59,15 @@ def sload(evm: Evm) -> None: value = get_storage( evm.message.block_env.state, evm.message.current_target, key ) + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_storage_read + track_storage_read( + evm.message.change_tracker, + evm.message.current_target, + key, + evm.message.block_env.state + ) push(evm.stack, value) @@ -127,6 +136,13 @@ def sstore(evm: Evm) -> None: if evm.message.is_static: raise WriteInStaticContext set_storage(state, evm.message.current_target, key, new_value) + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_storage_write + track_storage_write( + evm.message.change_tracker, + evm.message.current_target, key, new_value, state + ) # PROGRAM COUNTER evm.pc += Uint(1) diff --git a/src/ethereum/osaka/vm/instructions/system.py b/src/ethereum/osaka/vm/instructions/system.py index d7308821bd..562c0b59be 100644 --- a/src/ethereum/osaka/vm/instructions/system.py +++ b/src/ethereum/osaka/vm/instructions/system.py @@ -108,12 +108,12 @@ def generic_create( evm.message.block_env.state, contract_address ) or account_has_storage(evm.message.block_env.state, contract_address): increment_nonce( - evm.message.block_env.state, evm.message.current_target + evm.message.block_env.state, evm.message.current_target, evm.message.change_tracker ) push(evm.stack, U256(0)) return - increment_nonce(evm.message.block_env.state, evm.message.current_target) + increment_nonce(evm.message.block_env.state, evm.message.current_target, evm.message.change_tracker) child_message = Message( block_env=evm.message.block_env, @@ -133,7 +133,13 @@ def generic_create( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=False, parent_evm=evm, + change_tracker=evm.message.change_tracker, ) + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_address_access + track_address_access(evm.message.change_tracker, contract_address) + child_evm = process_create_message(child_message) if child_evm.error: @@ -323,7 +329,13 @@ def generic_call( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=disable_precompiles, parent_evm=evm, + change_tracker=evm.message.change_tracker, ) + + if evm.message.change_tracker: + from ...block_access_lists.tracker import track_address_access + track_address_access(evm.message.change_tracker, to) + child_evm = process_message(child_message) if child_evm.error: @@ -554,6 +566,7 @@ def selfdestruct(evm: Evm) -> None: originator, beneficiary, originator_balance, + evm.message.change_tracker ) # register account for deletion only if it was created @@ -561,7 +574,7 @@ def selfdestruct(evm: Evm) -> None: if originator in evm.message.block_env.state.created_accounts: # If beneficiary is the same as originator, then # the ether is burnt. - set_account_balance(evm.message.block_env.state, originator, U256(0)) + set_account_balance(evm.message.block_env.state, originator, U256(0), evm.message.change_tracker) evm.accounts_to_delete.add(originator) # HALT the execution diff --git a/src/ethereum/osaka/vm/interpreter.py b/src/ethereum/osaka/vm/interpreter.py index 18225616d6..9998355b5c 100644 --- a/src/ethereum/osaka/vm/interpreter.py +++ b/src/ethereum/osaka/vm/interpreter.py @@ -192,7 +192,7 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce(state, message.current_target) + increment_nonce(state, message.current_target, message.change_tracker) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -210,7 +210,7 @@ def process_create_message(message: Message) -> Evm: evm.output = b"" evm.error = error else: - set_code(state, message.current_target, contract_code) + set_code(state, message.current_target, contract_code, message.change_tracker) commit_transaction(state, transient_storage) else: rollback_transaction(state, transient_storage) @@ -241,7 +241,8 @@ def process_message(message: Message) -> Evm: if message.should_transfer_value and message.value != 0: move_ether( - state, message.caller, message.current_target, message.value + state, message.caller, message.current_target, message.value, + message.change_tracker ) evm = execute_code(message) diff --git a/tests/osaka/conftest.py b/tests/osaka/conftest.py new file mode 100644 index 0000000000..ed5fd82cf9 --- /dev/null +++ b/tests/osaka/conftest.py @@ -0,0 +1,11 @@ +""" +Minimal conftest for osaka BAL tests. +""" + +import pytest + + +def pytest_configure(config): + """Configure custom markers.""" + config.addinivalue_line("markers", "bal: mark test as BAL-related") + config.addinivalue_line("markers", "integration: mark test as integration test") \ No newline at end of file diff --git a/tests/osaka/test_bal_implementation.py b/tests/osaka/test_bal_implementation.py new file mode 100644 index 0000000000..fdbd56d205 --- /dev/null +++ b/tests/osaka/test_bal_implementation.py @@ -0,0 +1,556 @@ +""" +Comprehensive tests for Block Access List (BAL) implementation in EIP-7928. + +This module tests the complete BAL implementation including: +- Core functionality (tracking, building, validation) +- State modifications and nonce tracking +- Integration with VM instructions +- Edge cases and error handling +""" + +import ast +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from ethereum_types.bytes import Bytes, Bytes20, Bytes32 +from ethereum_types.numeric import U32, U64, U256, Uint + +from ethereum.osaka.block_access_lists import ( + BlockAccessListBuilder, + StateChangeTracker, + add_storage_write, + add_storage_read, + add_balance_change, + add_nonce_change, + add_code_change, + add_touched_account, + build, +) +from ethereum.osaka.rlp_types import ( + AccountChanges, + BalanceChange, + BlockAccessList, + BlockAccessIndex, + CodeChange, + NonceChange, + SlotChanges, + StorageChange, + MAX_CODE_CHANGES, +) + + +class TestBALCore: + """Test core BAL functionality.""" + + def test_bal_builder_initialization(self): + """Test BAL builder initializes correctly.""" + builder = BlockAccessListBuilder() + assert builder.accounts == {} + + def test_bal_builder_add_storage_write(self): + """Test adding storage writes to BAL builder.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + value = Bytes32(b'\x03' * 32) + + add_storage_write(builder, address, slot, BlockAccessIndex(0), value) + + assert address in builder.accounts + assert slot in builder.accounts[address].storage_changes + assert len(builder.accounts[address].storage_changes[slot]) == 1 + + change = builder.accounts[address].storage_changes[slot][0] + assert change.block_access_index == 0 + assert change.new_value == value + + def test_bal_builder_add_storage_read(self): + """Test adding storage reads to BAL builder.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + + add_storage_read(builder, address, slot) + + assert address in builder.accounts + assert slot in builder.accounts[address].storage_reads + + def test_bal_builder_add_balance_change(self): + """Test adding balance changes to BAL builder.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + balance = Bytes(b'\x00' * 16) # uint128 + + add_balance_change(builder, address, BlockAccessIndex(0), balance) + + assert address in builder.accounts + assert len(builder.accounts[address].balance_changes) == 1 + + change = builder.accounts[address].balance_changes[0] + assert change.block_access_index == 0 + assert change.post_balance == balance + + def test_bal_builder_add_nonce_change(self): + """Test adding nonce changes to BAL builder.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + nonce = 42 + + add_nonce_change(builder, address, BlockAccessIndex(0), U64(nonce)) + + assert address in builder.accounts + assert len(builder.accounts[address].nonce_changes) == 1 + + change = builder.accounts[address].nonce_changes[0] + assert change.block_access_index == 0 + assert change.new_nonce == U64(42) + + def test_bal_builder_add_code_change(self): + """Test adding code changes to BAL builder.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + code = Bytes(b'\x60\x80\x60\x40') + + add_code_change(builder, address, BlockAccessIndex(0), code) + + assert address in builder.accounts + assert len(builder.accounts[address].code_changes) == 1 + + change = builder.accounts[address].code_changes[0] + assert change.block_access_index == 0 + assert change.new_code == code + + def test_bal_builder_touched_account(self): + """Test adding touched accounts without changes.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + + add_touched_account(builder, address) + + assert address in builder.accounts + assert builder.accounts[address].storage_changes == {} + assert builder.accounts[address].storage_reads == set() + assert builder.accounts[address].balance_changes == [] + assert builder.accounts[address].nonce_changes == [] + assert builder.accounts[address].code_changes == [] + + def test_bal_builder_build_complete(self): + """Test building a complete BlockAccessList.""" + builder = BlockAccessListBuilder() + + # Add various changes + address1 = Bytes20(b'\x01' * 20) + address2 = Bytes20(b'\x02' * 20) + slot1 = Bytes32(b'\x03' * 32) + slot2 = Bytes32(b'\x04' * 32) + + # Address 1: storage write and read + add_storage_write(builder, address1, slot1, BlockAccessIndex(1), Bytes32(b'\x05' * 32)) + add_storage_read(builder, address1, slot2) + add_balance_change(builder, address1, BlockAccessIndex(1), Bytes(b'\x00' * 16)) + + # Address 2: only touched + add_touched_account(builder, address2) + + # Build BAL + block_access_list = build(builder) + + assert isinstance(block_access_list, BlockAccessList) + assert len(block_access_list.account_changes) == 2 + + # Verify sorting by address + assert block_access_list.account_changes[0].address == address1 + assert block_access_list.account_changes[1].address == address2 + + # Verify address1 changes + acc1 = block_access_list.account_changes[0] + assert len(acc1.storage_changes) == 1 + assert len(acc1.storage_reads) == 1 + assert acc1.storage_reads[0] == slot2 # Direct StorageKey + assert len(acc1.balance_changes) == 1 + + # Verify address2 is empty + acc2 = block_access_list.account_changes[1] + assert len(acc2.storage_changes) == 0 + assert len(acc2.storage_reads) == 0 + assert len(acc2.balance_changes) == 0 + + +class TestBALTracker: + """Test BAL state change tracker functionality.""" + + def test_tracker_initialization(self): + """Test tracker initializes with BAL builder.""" + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + assert tracker.block_access_list_builder is builder + assert tracker.pre_storage_cache == {} + assert tracker.current_block_access_index == 0 + + def test_tracker_set_transaction_index(self): + """Test setting block access index.""" + from ethereum.osaka.block_access_lists import set_transaction_index + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + + set_transaction_index(tracker, 5) + assert tracker.current_block_access_index == 5 + # Pre-storage cache should persist across transactions + assert tracker.pre_storage_cache == {} + + @patch('ethereum.osaka.block_access_lists.tracker.get_storage') + def test_tracker_capture_pre_state(self, mock_get_storage): + """Test capturing pre-state values.""" + from ethereum.osaka.block_access_lists.tracker import capture_pre_state + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + + mock_state = MagicMock() + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + expected_value = U256(42) + + mock_get_storage.return_value = expected_value + + # First call should fetch from state + value = capture_pre_state(tracker, address, slot, mock_state) + assert value == expected_value + mock_get_storage.assert_called_once_with(mock_state, address, slot) + + # Second call should use cache + mock_get_storage.reset_mock() + value2 = capture_pre_state(tracker, address, slot, mock_state) + assert value2 == expected_value + mock_get_storage.assert_not_called() + + @patch('ethereum.osaka.block_access_lists.tracker.capture_pre_state') + def test_tracker_storage_write_actual_change(self, mock_capture): + """Test tracking storage write with actual change.""" + from ethereum.osaka.block_access_lists.tracker import track_storage_write + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + tracker.current_block_access_index = 1 + + mock_state = MagicMock() + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + old_value = U256(42) + new_value = U256(100) + + mock_capture.return_value = old_value + + track_storage_write(tracker, address, slot, new_value, mock_state) + + # Should add storage write since value changed + assert address in builder.accounts + assert slot in builder.accounts[address].storage_changes + assert len(builder.accounts[address].storage_changes[slot]) == 1 + + change = builder.accounts[address].storage_changes[slot][0] + assert change.block_access_index == 1 + assert change.new_value == new_value.to_be_bytes32() + + @patch('ethereum.osaka.block_access_lists.tracker.capture_pre_state') + def test_tracker_storage_write_no_change(self, mock_capture): + """Test tracking storage write with no actual change.""" + from ethereum.osaka.block_access_lists.tracker import track_storage_write + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + tracker.current_block_access_index = 1 + + mock_state = MagicMock() + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + same_value = U256(42) + + mock_capture.return_value = same_value + + track_storage_write(tracker, address, slot, same_value, mock_state) + + # Should add storage read since value didn't change + assert address in builder.accounts + assert slot in builder.accounts[address].storage_reads + assert slot not in builder.accounts[address].storage_changes + + def test_tracker_balance_change(self): + """Test tracking balance changes.""" + from ethereum.osaka.block_access_lists.tracker import track_balance_change + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + tracker.current_block_access_index = 2 + + mock_state = MagicMock() + address = Bytes20(b'\x01' * 20) + new_balance = U256(1000) + + track_balance_change(tracker, address, new_balance, mock_state) + + assert address in builder.accounts + assert len(builder.accounts[address].balance_changes) == 1 + + change = builder.accounts[address].balance_changes[0] + assert change.block_access_index == 2 + # Balance is stored as U256 per EIP-7928 + assert change.post_balance == new_balance + + def test_tracker_nonce_change(self): + """Test tracking nonce changes.""" + from ethereum.osaka.block_access_lists.tracker import track_nonce_change + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + tracker.current_block_access_index = 3 + + mock_state = MagicMock() + address = Bytes20(b'\x01' * 20) + new_nonce = U64(10) + + track_nonce_change(tracker, address, new_nonce, mock_state) + + assert address in builder.accounts + assert len(builder.accounts[address].nonce_changes) == 1 + + change = builder.accounts[address].nonce_changes[0] + assert change.block_access_index == 3 + assert change.new_nonce == new_nonce + + def test_tracker_code_change(self): + """Test tracking code changes.""" + from ethereum.osaka.block_access_lists.tracker import track_code_change + builder = BlockAccessListBuilder() + tracker = StateChangeTracker(builder) + tracker.current_block_access_index = 1 + + mock_state = MagicMock() + address = Bytes20(b'\x01' * 20) + new_code = Bytes(b'\x60\x80\x60\x40') + + track_code_change(tracker, address, new_code, mock_state) + + assert address in builder.accounts + assert len(builder.accounts[address].code_changes) == 1 + + change = builder.accounts[address].code_changes[0] + assert change.block_access_index == 1 + assert change.new_code == new_code + + +class TestBALIntegration: + """Test BAL integration with block execution.""" + + def test_system_contract_indices(self): + """Test that system contracts use block_access_index 0.""" + builder = BlockAccessListBuilder() + + # Simulate pre-execution system contract changes + beacon_roots_addr = Bytes20(b'\x00' * 19 + b'\x02') + history_addr = Bytes20(b'\x00' * 19 + b'\x35') + + # These should use index 0 + add_storage_write(builder, beacon_roots_addr, Bytes32(b'\x00' * 32), BlockAccessIndex(0), Bytes32(b'\x01' * 32)) + add_storage_write(builder, history_addr, Bytes32(b'\x00' * 32), BlockAccessIndex(0), Bytes32(b'\x02' * 32)) + + block_access_list = build(builder) + + for account in block_access_list.account_changes: + if account.address in [beacon_roots_addr, history_addr]: + for slot_changes in account.storage_changes: + for change in slot_changes.changes: + assert change.block_access_index == 0 + + def test_transaction_indices(self): + """Test that transactions use indices 1 to len(transactions).""" + builder = BlockAccessListBuilder() + + # Simulate 3 transactions + for tx_num in range(1, 4): + address = Bytes20(tx_num.to_bytes(20, 'big')) + # Transactions should use indices 1, 2, 3 + add_balance_change(builder, address, BlockAccessIndex(tx_num), Bytes(b'\x00' * 16)) + + block_access_list = build(builder) + + assert len(block_access_list.account_changes) == 3 + for i, account in enumerate(block_access_list.account_changes): + assert len(account.balance_changes) == 1 + assert account.balance_changes[0].block_access_index == i + 1 + + def test_post_execution_index(self): + """Test that post-execution changes use index len(transactions) + 1.""" + builder = BlockAccessListBuilder() + num_transactions = 5 + + # Simulate withdrawal (post-execution) + withdrawal_addr = Bytes20(b'\xff' * 20) + post_exec_index = num_transactions + 1 + + add_balance_change(builder, withdrawal_addr, BlockAccessIndex(post_exec_index), Bytes(b'\x00' * 16)) + + block_access_list = build(builder) + + for account in block_access_list.account_changes: + if account.address == withdrawal_addr: + assert len(account.balance_changes) == 1 + assert account.balance_changes[0].block_access_index == post_exec_index + + def test_mixed_indices_ordering(self): + """Test that mixed indices are properly ordered in the BAL.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + + # Add changes with different indices (out of order) + add_balance_change(builder, address, BlockAccessIndex(3), Bytes(b'\x03' * 16)) + add_balance_change(builder, address, BlockAccessIndex(1), Bytes(b'\x01' * 16)) + add_balance_change(builder, address, BlockAccessIndex(2), Bytes(b'\x02' * 16)) + add_balance_change(builder, address, BlockAccessIndex(0), Bytes(b'\x00' * 16)) + + block_access_list = build(builder) + + assert len(block_access_list.account_changes) == 1 + account = block_access_list.account_changes[0] + assert len(account.balance_changes) == 4 + + # Should be sorted by block_access_index + for i in range(4): + assert account.balance_changes[i].block_access_index == i + assert account.balance_changes[i].post_balance == bytes([i]) * 16 + + +class TestRLPEncoding: + """Test RLP encoding of BAL structures.""" + + def test_rlp_encoding_import(self): + """Test that RLP encoding utilities can be imported.""" + from ethereum.osaka.block_access_lists import rlp_encode_block_access_list, compute_block_access_list_hash + assert rlp_encode_block_access_list is not None + assert compute_block_access_list_hash is not None + + def test_rlp_encode_simple_bal(self): + """Test RLP encoding of a simple BAL.""" + from ethereum.osaka.block_access_lists import rlp_encode_block_access_list + + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + + add_balance_change(builder, address, BlockAccessIndex(1), Bytes(b'\x00' * 16)) + + block_access_list = build(builder) + encoded = rlp_encode_block_access_list(block_access_list) + + # Should produce valid RLP bytes + assert isinstance(encoded, (bytes, Bytes)) + assert len(encoded) > 0 + + def test_bal_hash_computation(self): + """Test BAL hash computation using RLP.""" + from ethereum.osaka.block_access_lists import compute_block_access_list_hash + + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + + add_storage_write(builder, address, Bytes32(b'\x02' * 32), BlockAccessIndex(1), Bytes32(b'\x03' * 32)) + + block_access_list = build(builder) + hash_val = compute_block_access_list_hash(block_access_list) + + # Should produce a 32-byte hash + assert len(hash_val) == 32 + + # Same BAL should produce same hash + hash_val2 = compute_block_access_list_hash(block_access_list) + assert hash_val == hash_val2 + + def test_rlp_encode_complex_bal(self): + """Test RLP encoding of a complex BAL with multiple change types.""" + from ethereum.osaka.block_access_lists import rlp_encode_block_access_list + + builder = BlockAccessListBuilder() + + # Add various types of changes + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + + # Pre-execution (index 0) + add_storage_write(builder, address, slot, BlockAccessIndex(0), Bytes32(b'\x03' * 32)) + + # Transaction (index 1) + add_balance_change(builder, address, BlockAccessIndex(1), Bytes(b'\x00' * 16)) + add_nonce_change(builder, address, BlockAccessIndex(1), U64(1)) + + # Post-execution (index 2) + add_code_change(builder, address, BlockAccessIndex(2), Bytes(b'\x60\x80')) + + block_access_list = build(builder) + encoded = rlp_encode_block_access_list(block_access_list) + + # Should produce valid RLP bytes + assert isinstance(encoded, (bytes, Bytes)) + assert len(encoded) > 0 + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_bal(self): + """Test building an empty BAL.""" + builder = BlockAccessListBuilder() + block_access_list = build(builder) + + assert isinstance(block_access_list, BlockAccessList) + assert len(block_access_list.account_changes) == 0 + + def test_multiple_changes_same_slot(self): + """Test multiple changes to the same storage slot.""" + builder = BlockAccessListBuilder() + address = Bytes20(b'\x01' * 20) + slot = Bytes32(b'\x02' * 32) + + # Multiple writes to same slot at different indices + add_storage_write(builder, address, slot, BlockAccessIndex(0), Bytes32(b'\x00' * 32)) + add_storage_write(builder, address, slot, BlockAccessIndex(1), Bytes32(b'\x01' * 32)) + add_storage_write(builder, address, slot, BlockAccessIndex(2), Bytes32(b'\x02' * 32)) + + block_access_list = build(builder) + + assert len(block_access_list.account_changes) == 1 + account = block_access_list.account_changes[0] + assert len(account.storage_changes) == 1 + + slot_changes = account.storage_changes[0] + assert slot_changes.slot == slot + assert len(slot_changes.changes) == 3 + + # Changes should be sorted by index + for i in range(3): + assert slot_changes.changes[i].block_access_index == i + + def test_max_code_changes_constant(self): + """Test that MAX_CODE_CHANGES constant is available.""" + assert MAX_CODE_CHANGES == 1 + + def test_address_sorting(self): + """Test that addresses are sorted lexicographically in BAL.""" + builder = BlockAccessListBuilder() + + # Add addresses in reverse order + addresses = [ + Bytes20(b'\xff' * 20), + Bytes20(b'\xaa' * 20), + Bytes20(b'\x11' * 20), + Bytes20(b'\x00' * 20), + ] + + for addr in addresses: + add_touched_account(builder, addr) + + block_access_list = build(builder) + + # Should be sorted lexicographically + sorted_addresses = sorted(addresses) + for i, account in enumerate(block_access_list.account_changes): + assert account.address == sorted_addresses[i] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file