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.
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 IO + parallel EVM
We introduce a new field to the block header:
class Header:
# Existing fields
...
bal_hash: Hash32
The block body includes a BlockAccessList
containing all account accesses and state changes.
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_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
MAX_CODE_SIZE = 24_576 # Maximum contract bytecode size in bytes
MAX_CODE_CHANGES = 1
# Core change structures (no redundant keys)
class StorageChange(Container):
"""Single storage write: tx_index -> new_value"""
tx_index: TxIndex
new_value: StorageValue
class BalanceChange(Container):
"""Single balance change: tx_index -> post_balance"""
tx_index: TxIndex
post_balance: Balance
class NonceChange(Container):
"""Single nonce change: tx_index -> new_nonce"""
tx_index: TxIndex
new_nonce: Nonce
class CodeChange(Container):
"""Single code change: tx_index -> new_code"""
tx_index: TxIndex
new_code: CodeData
# Slot-level mapping (eliminates slot key redundancy)
class SlotChanges(Container):
"""All changes to a single storage slot"""
slot: StorageKey
changes: List[StorageChange, MAX_TXS] # Only changes, no redundant slot
# Account-level mapping (groups all account changes)
class AccountChanges(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])
code_changes: List[CodeChange, MAX_CODE_CHANGES]
# Block-level structure
class BlockAccessList(Container):
"""
Block Access List
"""
account_changes: List[AccountChanges, MAX_ACCOUNTS]
The BlockAccessList
contains all addresses accessed during block execution:
Addresses with no state changes MUST still be included with empty change lists for parallel IO.
Ordering requirements:
Storage writes include:
Storage reads:
SLOAD
but not writtenSSTORE
with same value)Slots both read and written (with changed values) appear only in storage_changes
.
Balance changes record post-transaction balances (uint128`) for:
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 a successful CREATE
or CREATE2
operation.
EXTCODEHASH
, EXTCODESIZE
, BALANCE
targets)Example block:
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.
Modify the state transition function to validate the block-level access lists:
def state_transition(block):
account_data = {} # address -> {storage_writes, storage_reads, balance_changes, nonce_changes, code_changes}
balance_touched = set()
for tx_index, tx in enumerate(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())
for addr in all_addresses:
if addr not in account_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())
for slot in all_slots:
pre_val = pre_storage.get(slot)
post_val = post_storage.get(slot)
if post_val is not None:
# Check if value actually changed
if pre_val != post_val:
# Changed write - include in storage_writes
if slot not in account_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)
elif pre_val is not None and slot not in post_storage:
# Zeroed slot
if slot not in account_data[addr]['storage_writes']:
account_data[addr]['storage_writes'][slot] = []
account_data[addr]['storage_writes'][slot].append((tx_index, '0x' + '00' * 32))
elif pre_val is not None:
# 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)
if pre_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', '')
if post_code and post_code != pre_code and post_code not in ('', '0x'):
account_data[addr]['code_changes'].append((tx_index, bytes.fromhex(post_code[2:])))
# Nonce changes (contracts with CREATE/CREATE2)
if pre_info.get('code') and pre_info['code'] not in ('', '0x', '0x0'):
pre_nonce = int(pre_info.get('nonce', '0x0'), 16)
post_nonce = int(post_info.get('nonce', '0x0'), 16)
if post_nonce > pre_nonce:
account_data[addr]['nonce_changes'].append((tx_index, post_nonce))
# Coinbase balance (block rewards)
coinbase_addr = block.coinbase
coinbase_balance = get_balance(coinbase_addr)
if coinbase_addr in balance_touched or coinbase_balance > 0:
if coinbase_addr not in account_data:
account_data[coinbase_addr] = {
'storage_writes': {}, 'storage_reads': set(),
'balance_changes': [], 'nonce_changes': [], 'code_changes': []
}
if not account_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
assert block.bal_hash == compute_bal_hash(computed_bal)
def build_block_access_list(account_data):
account_changes_list = []
for addr, data in account_data.items():
storage_changes = [
SlotChanges(slot=slot, changes=[StorageChange(tx_index=idx, new_value=val)
for idx, val in sorted(changes)])
for slot, changes in data['storage_writes'].items()
]
# Include pure reads and unchanged writes (excluded from storage_writes)
storage_reads = [slot for slot in data['storage_reads']
if slot not in data['storage_writes']]
account_changes_list.append(AccountChanges(
address=addr,
storage_changes=sorted(storage_changes, key=lambda x: x.slot),
storage_reads=sorted(storage_reads, key=lambda x: x.slot),
balance_changes=[BalanceChange(tx_index=idx, post_balance=bal)
for idx, bal in sorted(data['balance_changes'])],
nonce_changes=[NonceChange(tx_index=idx, new_nonce=nonce)
for idx, nonce in sorted(data['nonce_changes'])],
code_changes=[CodeChange(tx_index=idx, new_code=code)
for idx, code in sorted(data['code_changes'])]
))
return BlockAccessList(account_changes=sorted(account_changes_list, key=lambda x: 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.
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.
Overhead analysis: Historical data shows ~40 KiB average BAL size with ~4.5 KiB for balance diffs - reasonable for performance gains.
Transaction independence: 60-80% of transactions access disjoint storage slots, enabling effective parallelization.
SSZ encoding: Enables efficient Merkle proofs for light clients. SSZ BAL embedded as opaque bytes in RLP block for compatibility.
Block size impact (historical analysis):
Smaller than current worst-case calldata blocks.
An empirical analysis has been done here.
BAL verification occurs alongside parallel IO and EVM operations without delaying block processing.
This proposal requires changes to the block structure that are not backwards compatible and require a hard fork.
Validating access lists and balance diffs adds validation overhead but is essential to prevent acceptance of invalid blocks.
Increased block size impacts propagation but overhead (~40 KiB average) is reasonable for performance gains.
Copyright and related rights waived via CC0.