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
...
block_access_list_hash: Hash32
The block body includes a BlockAccessList
containing all account accesses and state changes.
BALs use RLP encoding following the pattern: address -> field -> block_access_index -> change
.
# Type aliases for RLP encoding
Address = bytes # 20-byte Ethereum address
StorageKey = bytes # 32-byte storage slot key
StorageValue = bytes # 32-byte storage value
CodeData = bytes # Variable-length contract bytecode
BlockAccessIndex = uint16 # Block access index (0 for pre-execution, 1..n for transactions, n+1 for post-execution)
Balance = uint256 # Post-transaction balance in wei
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 (RLP encoded as lists)
# StorageChange: [block_access_index, new_value]
StorageChange = [BlockAccessIndex, StorageValue]
# BalanceChange: [block_access_index, post_balance]
BalanceChange = [BlockAccessIndex, Balance]
# NonceChange: [block_access_index, new_nonce]
NonceChange = [BlockAccessIndex, Nonce]
# CodeChange: [block_access_index, new_code]
CodeChange = [BlockAccessIndex, CodeData]
# SlotChanges: [slot, [changes]]
# All changes to a single storage slot
SlotChanges = [StorageKey, List[StorageChange]]
# AccountChanges: [address, storage_changes, storage_reads, balance_changes, nonce_changes, code_changes]
# All changes for a single account, grouped by field type
AccountChanges = [
Address, # address
List[SlotChanges], # storage_changes (slot -> [block_access_index -> new_value])
List[StorageKey], # storage_reads (read-only storage keys)
List[BalanceChange], # balance_changes ([block_access_index -> post_balance])
List[NonceChange], # nonce_changes ([block_access_index -> new_nonce])
List[CodeChange] # code_changes ([block_access_index -> new_code])
]
# BlockAccessList: List of AccountChanges
BlockAccessList = List[AccountChanges]
BlockAccessList
is the set of all addresses accessed during block execution.
It MUST include:
STATICCALL
targets, BALANCE
opcode targets).Addresses with no state changes MUST still be present with empty change lists.
Entries from an EIP-2930 access list MUST NOT be included automatically. Only addresses and storage slots that are actually touched or changed during execution are recorded.
The following ordering rules MUST apply:
BlockAccessIndex
values MUST be assigned as follows:
0
for pre‑execution system contract calls.1 … n
for transactions (in block order).n + 1
for post‑execution system contract calls.Writes include:
Any value change (post‑value ≠ pre‑value).
Zeroing a slot (pre‑value exists, post‑value is zero).
Reads include:
Slots accessed via SLOAD
that are not written.
SSTORE
that re‑stores the same value).balance_changes
)Record post‑transaction balances (uint128
) for:
value > 0
).Zero‑value transfers: MUST NOT be recorded in balance_changes
, but the corresponding addresses MUST still be included with empty AccountChanges
.
Track post‑transaction runtime bytecode for deployed or modified contracts, and delegation indicators as defined in EIP-7702.
Record post‑transaction nonces for:
CREATE
or CREATE2
.EXTCODEHASH
, EXTCODESIZE
, BALANCE
, STATICCALL
, etc.).balance_changes
.block_access_index = 0
.block_access_index = len(transactions) + 1
.The execution layer computes:
block_access_list_hash = keccak256(rlp.encode(block_access_list))
and provides both block_access_list
and block_access_list_hash
in the ExecutionPayload
to the consensus layer, which stores them without modification.
The state transition function must validate that the provided BAL matches the actual state accesses:
def validate_block(block):
# 1. Verify provided BAL matches header hash
import rlp
provided_bal_hash = keccak256(rlp.encode(block.block_access_list))
assert provided_bal_hash == block.header.block_access_list_hash
# 2. Execute block and collect actual accesses
actual_bal = execute_and_collect_accesses(block)
# 3. Verify actual execution matches provided BAL
actual_bal_hash = keccak256(rlp.encode(actual_bal))
assert actual_bal_hash == block.header.block_access_list_hash
def execute_and_collect_accesses(block):
"""Execute block and collect all state accesses into BAL format"""
accesses = {}
# Pre-execution system contracts (block_access_index = 0)
track_system_contracts_pre(block, accesses, block_access_index=0)
# Execute transactions (block_access_index = 1..n)
for i, tx in enumerate(block.transactions):
execute_transaction(tx)
track_state_changes(tx, accesses, block_access_index=i+1)
# Withdrawals and post-execution (block_access_index = len(txs) + 1)
post_index = len(block.transactions) + 1
for withdrawal in block.withdrawals:
apply_withdrawal(withdrawal)
track_balance_change(withdrawal.address, accesses, post_index)
track_system_contracts_post(block, accesses, post_index)
# Convert to BAL format and sort
return build_bal(accesses)
def track_state_changes(tx, accesses, block_access_index):
"""Track all state changes from a transaction"""
for addr in get_touched_addresses(tx):
if addr not in accesses:
accesses[addr] = {
'storage_writes': {}, # slot -> [(index, value)]
'storage_reads': set(),
'balance_changes': [],
'nonce_changes': [],
'code_changes': []
}
# Track storage changes
for slot, value in get_storage_writes(addr).items():
if slot not in accesses[addr]['storage_writes']:
accesses[addr]['storage_writes'][slot] = []
accesses[addr]['storage_writes'][slot].append((block_access_index, value))
# Track reads (slots accessed but not written)
for slot in get_storage_reads(addr):
if slot not in accesses[addr]['storage_writes']:
accesses[addr]['storage_reads'].add(slot)
# Track balance, nonce, code changes
if balance_changed(addr):
accesses[addr]['balance_changes'].append((block_access_index, get_balance(addr)))
if nonce_changed(addr):
accesses[addr]['nonce_changes'].append((block_access_index, get_nonce(addr)))
if code_changed(addr):
accesses[addr]['code_changes'].append((block_access_index, get_code(addr)))
def build_bal(accesses):
"""Convert collected accesses to BAL format"""
bal = []
for addr in sorted(accesses.keys()): # Sort addresses lexicographically
data = accesses[addr]
# Format storage changes: [slot, [[index, value], ...]]
storage_changes = [[slot, sorted(changes)]
for slot, changes in sorted(data['storage_writes'].items())]
# Account entry: [address, storage_changes, reads, balance_changes, nonce_changes, code_changes]
bal.append([
addr,
storage_changes,
sorted(list(data['storage_reads'])),
sorted(data['balance_changes']),
sorted(data['nonce_changes']),
sorted(data['code_changes'])
])
return bal
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.
Example block:
Pre-execution:
Transactions:
Post-execution:
Note: Pre-execution system contract uses block_access_index = 0 Post-execution withdrawal uses block_access_index = 3 (len(transactions) + 1)
Resulting BAL (RLP structure):
[
# Addresses are sorted lexicographically
[ # AccountChanges for 0x0000F90827F1C53a10cb7A02335B175320002935 (Block hash contract)
0x0000F90827F1C53a10cb7A02335B175320002935,
[ # storage_changes
[b'\x00...\x0f\xa0', [[0, b'...']]] # slot, [[block_access_index, parent_hash]]
],
[], # storage_reads
[], # balance_changes
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0x2222... (Address checked by Alice)
0x2222...,
[], # storage_changes
[], # storage_reads
[], # balance_changes (no change, just checked)
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xaaaa... (Alice - sender tx 0)
0xaaaa...,
[], # storage_changes
[], # storage_reads
[[1, 0x...29a241a]], # balance_changes: [[block_access_index, post_balance]]
[[1, 10]], # nonce_changes: [[block_access_index, new_nonce]]
[] # code_changes
],
[ # AccountChanges for 0xabcd... (Eve - withdrawal recipient)
0xabcd...,
[], # storage_changes
[], # storage_reads
[[3, 0x...5f5e100]], # balance_changes: 100 ETH withdrawal
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xbbbb... (Bob - recipient tx 0)
0xbbbb...,
[], # storage_changes
[], # storage_reads
[[1, 0x...b9aca00]], # balance_changes: +1 ETH
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xcccc... (Charlie - sender tx 1)
0xcccc...,
[], # storage_changes
[], # storage_reads
[[2, 0x...bc16d67]], # balance_changes: after gas
[[2, 5]], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xdddd... (Deployed contract)
0xdddd...,
[], # storage_changes
[], # storage_reads
[], # balance_changes
[[2, 1]], # nonce_changes: new contract nonce
[[2, b'\x60\x80\x60\x40...']] # code_changes: deployed bytecode
],
[ # AccountChanges for 0xeeee... (Coinbase)
0xeeee...,
[], # storage_changes
[], # storage_reads
[[1, 0x...05f5e1], [2, 0x...0bebc2]], # balance_changes: after tx fees
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xffff... (Factory contract)
0xffff...,
[ # storage_changes
[b'\x00...\x01', [[2, b'\x00...\xdd\xdd...']]] # slot 1, deployed address
],
[], # storage_reads
[], # balance_changes
[[2, 5]], # nonce_changes: after CREATE
[] # code_changes
]
]
RLP-encoded and compressed: ~400-500 bytes.
This design variant was chosen for several key reasons:
Size vs parallelization: BALs include all accessed addresses (even unchanged) for complete parallel IO and execution.
Storage values for writes: Post-execution values enable state reconstruction during sync without individual proofs against state root.
Overhead analysis: Historical data shows ~45 KiB average BAL size.
Transaction independence: 60-80% of transactions access disjoint storage slots, enabling effective parallelization. The remaining 20-40% can be parallelized by having post-transaction state diffs.
RLP encoding: Native Ethereum encoding format, maintains compatibility with existing infrastructure.
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.