This EIP introduces Block-Level Access Lists (BALs) that record all accounts and storage locations accessed during block execution, along with their post-execution values. BALs enable parallel disk reads, parallel transaction validation, and executionless state updates.
Motivation
Transaction execution cannot be parallelized without knowing in advance which addresses and storage slots will be accessed. While EIP-2930 introduced optional transaction access lists, they are not enforced.
This proposal enforces access lists at the block level, enabling:
Parallel disk reads and transaction execution
State reconstruction without executing transactions
Reduced execution time to parallel IO + parallel EVM
Specification
Block Structure Modification
We introduce a new field to the block header:
classHeader:# Existing fields
...bal_hash:Hash32
The block body includes a BlockAccessList containing all account accesses and state changes.
SSZ Data Structures
BALs use SSZ encoding following the pattern: address -> field -> tx_index -> change.
# Type aliases using SSZ types
Address=Bytes20# 20-byte Ethereum address
StorageKey=Bytes32# 32-byte storage slot key
StorageValue=Bytes32# 32-byte storage value
CodeData=List[byte,MAX_CODE_SIZE]# Variable-length contract bytecode
TxIndex=uint16# Transaction index within block (max 65,535)
Balance=uint128# Post-transaction balance in wei (16 bytes, sufficient for total ETH supply)
Nonce=uint64# Account nonce
# Constants; chosen to support a 630m block gas limit
MAX_TXS=30_000MAX_SLOTS=300_000MAX_ACCOUNTS=300_000MAX_CODE_SIZE=24_576# Maximum contract bytecode size in bytes
# Core change structures (no redundant keys)
classStorageChange(Container):"""Single storage write: tx_index -> new_value"""tx_index:TxIndexnew_value:StorageValueclassBalanceChange(Container):"""Single balance change: tx_index -> post_balance"""tx_index:TxIndexpost_balance:BalanceclassNonceChange(Container):"""Single nonce change: tx_index -> new_nonce"""tx_index:TxIndexnew_nonce:NonceclassCodeChange(Container):"""Single code change: tx_index -> new_code"""tx_index:TxIndexnew_code:CodeData# Slot-level mapping (eliminates slot key redundancy)
classSlotChanges(Container):"""All changes to a single storage slot"""slot:StorageKeychanges:List[StorageChange,MAX_TXS]# Only changes, no redundant slot
# Account-level mapping (groups all account changes)
classAccountChanges(Container):"""
All changes for a single account, grouped by field type.
This eliminates address redundancy across different change types.
"""address:Address# Storage changes (slot -> [tx_index -> new_value])
storage_changes:List[SlotChanges,MAX_SLOTS]# Read-only storage keys
storage_reads:List[StorageKey,MAX_SLOTS]# Balance changes ([tx_index -> post_balance])
balance_changes:List[BalanceChange,MAX_TXS]# Nonce changes ([tx_index -> new_nonce])
nonce_changes:List[NonceChange,MAX_TXS]# Code changes ([tx_index -> new_code]) - typically 0 or 1
code_changes:List[CodeChange,MAX_TXS]# Block-level structure (simple list of account changes)
classBlockAccessList(Container):"""
Block Access List
"""account_changes:List[AccountChanges,MAX_ACCOUNTS]
The BlockAccessList contains all addresses accessed during block execution:
Addresses with state changes (storage, balance, nonce, or code)
Addresses accessed without state changes (e.g., STATICCALL targets, BALANCE opcode targets)
Addresses with no state changes MUST still be included with empty change lists for parallel IO.
Ordering requirements:
Addresses: lexicographic (bytewise)
Storage keys: lexicographic within each account
Transaction indices: ascending within each change list
Storage writes include:
Value changes (different post-value than pre-value)
Zeroed slots (pre-value exists, post-value is zero)
Storage reads:
Slots accessed via SLOAD but not written
Slots written with unchanged values (SSTORE with same value)
Slots in pre-state but not written
Slots both read and written (with changed values) appear only in storage_changes.
Balance changes record post-transaction balances (uint128) for:
Transaction senders (gas + value)
Recipients
Coinbase (rewards + fees)
SELFDESTRUCT beneficiaries
Zero-value transfers: NOT recorded in balance_changes but addresses MUST be included with empty AccountChanges.
Code changes track post-transaction runtime bytecode for deployed/modified contracts.
Nonce changes record post-transaction nonces for contracts that performed successful CREATE/CREATE2.
Excluded (statically inferrable):
EOA nonce increments
New contracts (always nonce 1)
Failed CREATE/CREATE2
EIP-7702 delegations
Important Implementation Details
Edge Cases
SELFDESTRUCT: Beneficiary recorded as balance change
Accessed but unchanged: Include with empty changes (EXTCODEHASH, EXTCODESIZE, BALANCE targets)
Zero-value transfers: Include address, omit from balance_changes
Failed transactions: Excluded from BAL
Gas refunds: Final balance recorded
Block rewards: Included in coinbase balance
STATICCALL/Read-only opcodes: Include targets with empty changes
Concrete Example
Example block:
Alice (0xaaaa…) sends 1 ETH to Bob (0xbbbb…), checks balance of 0x2222…
Charlie (0xcccc…) calls factory (0xffff…) deploying contract at 0xdddd…
Resulting BAL:
BlockAccessList(account_changes=[AccountChanges(address=0xaaaa...,# Alice
storage_changes=[],storage_reads=[],balance_changes=[BalanceChange(tx_index=0,post_balance=0x00000000000000029a241a)],# 50 ETH remaining
nonce_changes=[],# EOA nonce changes are not recorded
code_changes=[]),AccountChanges(address=0xbbbb...,# Bob
storage_changes=[],storage_reads=[],balance_changes=[BalanceChange(tx_index=0,post_balance=0x0000000000000003b9aca00)],# 11 ETH
nonce_changes=[],code_changes=[]),AccountChanges(address=0xcccc...,# Charlie (transaction sender)
storage_changes=[],storage_reads=[],balance_changes=[BalanceChange(tx_index=1,post_balance=0x0000000000000001bc16d67)],# After gas
nonce_changes=[],# EOA nonce changes are not recorded
code_changes=[]),AccountChanges(address=0xdddd...,# Deployed contract
storage_changes=[],storage_reads=[],balance_changes=[],nonce_changes=[],# New contracts start with nonce 1 (not recorded)
code_changes=[CodeChange(tx_index=1,new_code=b'\x60\x80\x60\x40...')]),AccountChanges(address=0xeeee...,# Coinbase
storage_changes=[],storage_reads=[],balance_changes=[BalanceChange(tx_index=0,post_balance=0x000000000000000005f5e1),# After tx 0
BalanceChange(tx_index=1,post_balance=0x00000000000000000bebc2)# After tx 1 + reward
],nonce_changes=[],code_changes=[]),AccountChanges(address=0xffff...,# Factory contract that performed CREATE
storage_changes=[SlotChanges(slot=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01',changes=[StorageChange(tx_index=1,new_value=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd')])],storage_reads=[],balance_changes=[],nonce_changes=[NonceChange(tx_index=1,new_nonce=5)],# After CREATE
code_changes=[]),AccountChanges(address=0x1111...,# Contract accessed via STATICCALL or BALANCE opcode
storage_changes=[],storage_reads=b'\x00...\x05',# Read slot 5
balance_changes=[],nonce_changes=[],code_changes=[]),AccountChanges(address=0x2222...,# Address whose balance was checked via BALANCE opcode
storage_changes=[],storage_reads=[],balance_changes=[],# Just checked via BALANCE opcode
nonce_changes=[],code_changes=[])])
SSZ-encoded and compressed: ~400-500 bytes.
State Transition Function
Modify the state transition function to validate the block-level access lists:
defstate_transition(block):account_data={}# address -> {storage_writes, storage_reads, balance_changes, nonce_changes, code_changes}
balance_touched=set()fortx_index,txinenumerate(block.transactions):# Get pre and post states for this transaction
pre_state,post_state=execute_transaction_with_tracing(tx)# Process all touched addresses
# All touched addresses must be included (even without changes)
all_addresses=set(pre_state.keys())|set(post_state.keys())foraddrinall_addresses:ifaddrnotinaccount_data:account_data[addr]={'storage_writes':{},# slot -> [(tx_index, new_value)]
'storage_reads':set(),# set of slots
'balance_changes':[],# [(tx_index, post_balance)]
'nonce_changes':[],# [(tx_index, new_nonce)]
'code_changes':[]# [(tx_index, new_code)]
}pre_info=pre_state.get(addr,{})post_info=post_state.get(addr,{})# Process storage changes
pre_storage=pre_info.get('storage',{})post_storage=post_info.get('storage',{})all_slots=set(pre_storage.keys())|set(post_storage.keys())forslotinall_slots:pre_val=pre_storage.get(slot)post_val=post_storage.get(slot)ifpost_valisnotNone:# Check if value actually changed
ifpre_val!=post_val:# Changed write - include in storage_writes
ifslotnotinaccount_data[addr]['storage_writes']:account_data[addr]['storage_writes'][slot]=[]account_data[addr]['storage_writes'][slot].append((tx_index,post_val))else:# Unchanged write - include as read
account_data[addr]['storage_reads'].add(slot)elifpre_valisnotNoneandslotnotinpost_storage:# Zeroed slot
ifslotnotinaccount_data[addr]['storage_writes']:account_data[addr]['storage_writes'][slot]=[]account_data[addr]['storage_writes'][slot].append((tx_index,'0x'+'00'*32))elifpre_valisnotNone:# Read-only
account_data[addr]['storage_reads'].add(slot)# Balance changes (only non-zero)
pre_balance=int(pre_info.get('balance','0x0'),16)post_balance=int(post_info.get('balance','0x0'),16)ifpre_balance!=post_balance:balance_touched.add(addr)account_data[addr]['balance_changes'].append((tx_index,post_balance))# Code changes
pre_code=pre_info.get('code','')post_code=post_info.get('code','')ifpost_codeandpost_code!=pre_codeandpost_codenotin('','0x'):account_data[addr]['code_changes'].append((tx_index,bytes.fromhex(post_code[2:])))# Nonce changes (contracts with CREATE/CREATE2)
ifpre_info.get('code')andpre_info['code']notin('','0x','0x0'):pre_nonce=int(pre_info.get('nonce','0x0'),16)post_nonce=int(post_info.get('nonce','0x0'),16)ifpost_nonce>pre_nonce:account_data[addr]['nonce_changes'].append((tx_index,post_nonce))# Coinbase balance (block rewards)
coinbase_addr=block.coinbasecoinbase_balance=get_balance(coinbase_addr)ifcoinbase_addrinbalance_touchedorcoinbase_balance>0:ifcoinbase_addrnotinaccount_data:account_data[coinbase_addr]={'storage_writes':{},'storage_reads':set(),'balance_changes':[],'nonce_changes':[],'code_changes':[]}ifnotaccount_data[coinbase_addr]['balance_changes']or \
account_data[coinbase_addr]['balance_changes'][-1][0]<len(block.transactions)-1:account_data[coinbase_addr]['balance_changes'].append((len(block.transactions)-1,coinbase_balance))# Build the BAL from collected data
computed_bal=build_block_access_list(account_data)# Validate block data
assertblock.bal_hash==compute_bal_hash(computed_bal)defbuild_block_access_list(account_data):account_changes_list=[]foraddr,datainaccount_data.items():storage_changes=[SlotChanges(slot=slot,changes=[StorageChange(tx_index=idx,new_value=val)foridx,valinsorted(changes)])forslot,changesindata['storage_writes'].items()]# Include pure reads and unchanged writes (excluded from storage_writes)
storage_reads=[slotforslotindata['storage_reads']ifslotnotindata['storage_writes']]account_changes_list.append(AccountChanges(address=addr,storage_changes=sorted(storage_changes,key=lambdax:x.slot),storage_reads=sorted(storage_reads,key=lambdax:x.slot),balance_changes=[BalanceChange(tx_index=idx,post_balance=bal)foridx,balinsorted(data['balance_changes'])],nonce_changes=[NonceChange(tx_index=idx,new_nonce=nonce)foridx,nonceinsorted(data['nonce_changes'])],code_changes=[CodeChange(tx_index=idx,new_code=code)foridx,codeinsorted(data['code_changes'])]))returnBlockAccessList(account_changes=sorted(account_changes_list,key=lambdax:x.address))
The BAL MUST be complete and accurate. Missing or spurious entries invalidate the block.
Clients MUST validate by comparing execution-gathered accesses (per EIP-2929) with the BAL.
Clients MAY invalidate immediately if any transaction exceeds declared state.
Rationale
BAL Design Choice
This design variant was chosen for several key reasons:
Size vs parallelization: BALs include all accessed addresses (even unchanged) for complete parallel execution. Omitting read values reduces size while maintaining parallelization benefits.
Storage values for writes: Post-execution values enable state reconstruction during sync without individual proofs against state root.
Balance and nonce tracking: Essential for parallel execution. Nonce tracking handles CREATE/CREATE2 edge cases where contracts increase nonce without being transaction senders.
Overhead analysis: Historical data shows ~40 KiB average BAL size with ~9.6 KiB for balance diffs - reasonable for performance gains.