Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-7928: Block-Level Access Lists

Enforced block access lists with storage locations and state diffs

Authors Toni Wahrstätter (@nerolation), Dankrad Feist (@dankrad), Francesco D`Amato (@fradamt), Jochem Brouwer (@jochem-brouwer), Ignacio Hagopian (@jsign)
Created 2025-03-31
Discussion Link https://ethereum-magicians.org/t/eip-7928-block-level-access-lists/23337

Abstract

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:

class Header:
    # 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_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
MAX_CODE_SIZE = 24_576  # Maximum contract bytecode size in bytes

# 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]) - typically 0 or 1
    code_changes: List[CodeChange, MAX_TXS]

# Block-level structure (simple list of account changes)
class BlockAccessList(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:

  1. Alice (0xaaaa…) sends 1 ETH to Bob (0xbbbb…), checks balance of 0x2222…
  2. 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:

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.

Rationale

BAL Design Choice

This design variant was chosen for several key reasons:

  1. Size vs parallelization: BALs include all accessed addresses (even unchanged) for complete parallel execution. Omitting read values reduces size while maintaining parallelization benefits.

  2. Storage values for writes: Post-execution values enable state reconstruction during sync without individual proofs against state root.

  3. Balance and nonce tracking: Essential for parallel execution. Nonce tracking handles CREATE/CREATE2 edge cases where contracts increase nonce without being transaction senders.

  4. Overhead analysis: Historical data shows ~40 KiB average BAL size with ~9.6 KiB for balance diffs - reasonable for performance gains.

  5. Transaction independence: 60-80% of transactions access disjoint storage slots, enabling effective parallelization.

  6. SSZ encoding: Enables efficient merkle proofs for light clients. SSZ BAL embedded as opaque bytes in RLP block for compatibility.

Block Size Considerations

Block size impact (historical analysis):

  • Average: ~40 KiB (compressed)
  • Balance diffs: ~9.6 KiB
  • Worst-case (36m gas): ~0.93 MiB
  • Worst-case balance diffs: ~0.12 MiB

Smaller than current worst-case calldata blocks.

An empirical analysis has been done here.

Asynchronous Validation

BAL verification occurs alongside parallel IO and EVM operations without delaying block processing.

Backwards Compatibility

This proposal requires changes to the block structure that are not backwards compatible and require a hard fork.

Security Considerations

Validation Overhead

Validating access lists and balance diffs adds validation overhead but is essential to prevent acceptance of invalid blocks.

Block Size

Increased block size impacts propagation but overhead (~40 KiB average) is reasonable for performance gains.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Toni Wahrstätter (@nerolation), Dankrad Feist (@dankrad), Francesco D`Amato (@fradamt), Jochem Brouwer (@jochem-brouwer), Ignacio Hagopian (@jsign), "EIP-7928: Block-Level Access Lists [DRAFT]," Ethereum Improvement Proposals, no. 7928, March 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7928.