EIP-7928 - Block-Level Access Lists

Created 2025-03-31
Status Draft
Category Core
Type Standards Track
Authors

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, parallel state root computation 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:

Specification

Block Structure Modification

We introduce a new field to the block header, block_access_list_hash, which contains the Keccak-256 hash of the RLP-encoded block access list. When no state changes are present, this field is the hash of an empty RLP list 0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347, i.e. keccak256(rlp.encode([])).

class Header:
    # Existing fields
    ...

    block_access_list_hash: Hash32 = keccak256(rlp.encode(block_access_list))

The BlockAccessList is not included in the block body. The EL stores BALs separately and transmits them as a field in the ExecutionPayload via the engine API. The BAL is RLP-encoded as a list of AccountChanges. When no state changes are present, this field is the empty RLP list 0xc0, i.e. rlp.encode([]).

RLP Data Structures

BALs use RLP encoding following the pattern: address -> field -> block_access_index -> change.

# Type aliases for RLP encoding
Address = bytes20  # 20-byte Ethereum address
StorageKey = uint256  # Storage slot key
StorageValue = uint256  # Storage value
Bytecode = 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

# 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, Bytecode]

# 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]

Scope and Inclusion

BlockAccessList is the set of all addresses accessed during block execution.

It MUST include:

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.

Gas Validation Before State Access

State-accessing opcodes perform gas validation in two phases:

Pre-state validation MUST pass before any state access occurs. If pre-state validation fails, the target resource (address or storage slot) is never accessed and MUST NOT be included in the BAL.

Once pre-state validation passes, the target is accessed and included in the BAL. Post-state costs are then calculated; their order is implementation-defined since the target has already been accessed.

The following table specifies pre-state validation costs in addition to the base opcode cost (gas constants as defined in EIP-2929):

Instruction Pre-state Validation
BALANCE access_cost
SELFBALANCE None (accesses current contract, always warm)
EXTCODESIZE access_cost
EXTCODEHASH access_cost
EXTCODECOPY access_cost + memory_expansion
CALL access_cost + memory_expansion + GAS_CALL_VALUE (if value > 0)
CALLCODE access_cost + memory_expansion + GAS_CALL_VALUE (if value > 0)
DELEGATECALL access_cost + memory_expansion
STATICCALL access_cost + memory_expansion
CREATE memory_expansion + INITCODE_WORD_COST + GAS_CREATE
CREATE2 memory_expansion + INITCODE_WORD_COST + GAS_KECCAK256_WORD + GAS_CREATE
SLOAD access_cost
SSTORE More than GAS_CALL_STIPEND available
SELFDESTRUCT GAS_SELF_DESTRUCT + access_cost

Where:

Post-state costs (e.g., GAS_NEW_ACCOUNT for calls to empty accounts, GAS_SELF_DESTRUCT_NEW_ACCOUNT if beneficiary does not exist) do not affect BAL inclusion since the target has already been accessed.

EIP-7702 Delegation

When a call target has an EIP-7702 delegation, the target is accessed to resolve the delegation. If a delegation exists, the delegated address requires its own access_cost check before being accessed. If this check fails, the delegated address MUST NOT appear in the BAL, though the original call target is included (having been accessed to resolve the delegation).

Note: Delegated accounts cannot be empty, so GAS_NEW_ACCOUNT never applies when resolving delegations.

SSTORE

SSTORE performs an implicit read of the current storage value for gas calculation. The GAS_CALL_STIPEND check prevents this state access when operating within the call stipend. If SSTORE fails this check, the storage slot MUST NOT appear in storage_reads or storage_changes.

Ordering and Determinism

The following ordering rules MUST apply:

BlockAccessIndex Assignment

BlockAccessIndex values MUST be assigned as follows:

Recording Semantics by Change Type

Storage

Note: Implementations MUST check the pre-transaction value to correctly distinguish between actual writes and no-op writes.

Balance (balance_changes)

Record post‑transaction balances (uint256) for:

For unaltered account balances:

If an account’s balance changes during a transaction, but its post-transaction balance is equal to its pre-transaction balance, then the change MUST NOT be recorded in balance_changes. The sender and recipient address MUST be included in AccountChanges.

The following special cases require addresses to be included with empty changes if no other state changes occur:

Zero-value block reward recipients MUST NOT trigger a balance change in the block access list and MUST NOT cause the recipient address to be included as a read (e.g. without changes). Zero-value block reward recipients MUST only be included with a balance change in blocks where the reward is greater than zero.

Code

Track post‑transaction runtime bytecode for deployed or modified contracts, and delegation indicators for successful delegations as defined in EIP-7702.

Nonce

Record post‑transaction nonces for:

Edge Cases (Normative)

Engine API

The Engine API is extended with new structures and methods to support block-level access lists:

ExecutionPayloadV4 extends ExecutionPayloadV3 with:

engine_newPayloadV5 validates execution payloads:

engine_getPayloadV6 builds execution payloads:

Block processing flow:

When processing a block:

  1. The EL receives the BAL in the ExecutionPayload
  2. The EL computes block_access_list_hash = keccak256(blockAccessList) and includes it in the block header
  3. The EL executes the block and generates the actual BAL
  4. If the generated BAL doesn't match the provided BAL, the block is invalid (the hash in the header would be wrong)

The execution layer provides the RLP-encoded blockAccessList to the consensus layer via the Engine API. The consensus layer then computes the SSZ hash_tree_root for storage in the ExecutionPayload.

Retrieval methods for historical BALs:

The blockAccessList field contains the RLP-encoded BAL or null for pre-Amsterdam blocks or when data has been pruned.

The EL MUST retain BALs for at least the duration of the weak subjectivity period (=3533 epochs) to support synchronization with re-execution after being offline for less than the WSP.

State Transition Function

The state transition function must validate that the provided BAL matches the actual state accesses:

def validate_block(execution_payload, block_header):
    # 1. Compute hash from received BAL and set in header
    block_header.block_access_list_hash = keccak(execution_payload.blockAccessList)

    # 2. Execute block and collect actual accesses
    actual_bal = execute_and_collect_accesses(execution_payload)

    # 3. Verify actual execution matches provided BAL
    # If this fails, the block is invalid (the hash in the header would be wrong)
    assert rlp.encode(actual_bal) == execution_payload.blockAccessList

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. Spurious entries MAY be detected by validating BAL indices, which MUST never be higher than len(transactions) + 1.

Clients MAY invalidate immediately if any transaction exceeds declared state.

Clients MUST store BALs separately from blocks and make them available via the engine API.

Concrete Example

Example block:

Pre-execution:

Transactions:

  1. Alice (0xaaaa...) sends 1 ETH to Bob (0xbbbb...), checks balance of 0x2222...
  2. Charlie (0xcccc...) calls factory (0xffff...) deploying contract at 0xdddd...

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.

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 IO and execution.

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

  3. Overhead analysis: Historical data shows ~70 KiB average BAL size.

  4. 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.

  5. RLP encoding: Native Ethereum encoding format, maintains compatibility with existing infrastructure.

BAL Size Considerations (60m block gas limit)

Average BAL size: ~72.4 KiB (compressed)

Smaller than current worst-case calldata blocks.

An empirical analysis has been done here. An updated analysis for a 60 million block gas limit can be found 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 and engine API 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 (~70 KiB average) is reasonable for performance gains.

Copyright

Copyright and related rights waived via CC0.