This specification was annotated with AI assistance.

Annotated specification for EIP-7928: Block-Level Access Lists.

Section 01: RLP Types

Source Files

  • specs/execution-specs/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py (121 lines)

Overview

This file defines the canonical RLP data structures for Block-Level Access Lists. Every BAL transmitted over the network or validated during block processing uses these exact types. The file is 121 lines—small but foundational.


Module Docstring (lines 1-8)

"""
Defines the RLP data structures for Block-Level Access Lists
as specified in EIP-7928. These structures enable efficient encoding and
decoding of all accounts and storage locations accessed during block execution.

The encoding follows the pattern:
address -> field -> block_access_index -> change.
"""
Key insight: The "address → field → block_access_index → change" pattern describes the nesting hierarchy:
  • Top level: list of accounts (by address)
  • Per account: grouped by field type (storage, balance, nonce, code)
  • Per field: ordered by block_access_index
  • Leaf: the actual change value
This hierarchical grouping enables:
  • Efficient compression (addresses deduplicated)
  • Parallel prefetching (all slots for an address together)
  • Clear change attribution (which tx caused what)

Imports (lines 9-15)

from dataclasses import dataclass
from typing import List, Tuple

from ethereum_types.bytes import Bytes, Bytes20
from ethereum_types.frozen import slotted_freezable
from ethereum_types.numeric import U64, U256, Uint
slotted_freezable: A decorator that makes dataclasses both:
  • Slotted: Uses __slots__ for memory efficiency (no __dict__)
  • Freezable: Instances are immutable after creation
This matters because BAL structures are created once during block execution and then serialized. Immutability prevents accidental modification; slots reduce memory overhead when processing blocks with thousands of accounts.

Type Aliases (lines 17-23)

# Type aliases for clarity (matching EIP-7928 specification)
Address = Bytes20
StorageKey = U256
StorageValue = U256
CodeData = Bytes
BlockAccessIndex = Uint  # uint16 in the spec, but using Uint for compatibility
Balance = U256  # Post-transaction balance in wei
Nonce = U64

Why these specific types?

AliasUnderlyingReason
AddressBytes20Ethereum addresses are exactly 20 bytes
StorageKeyU256Storage slots are 256-bit keys
StorageValueU256Storage values are 256-bit
CodeDataBytesContract bytecode is variable-length
BlockAccessIndexUintSee below
BalanceU256Wei balances can exceed 2^64
NonceU64EIP-2681 limits nonces to 64 bits

BlockAccessIndex: uint16 vs Uint

The comment is important:

BlockAccessIndex = Uint  # uint16 in the spec, but using Uint for compatibility

EIP-7928 specifies uint16 (max 65,535), but EELS uses Uint (arbitrary precision) for Python compatibility. The constraint is enforced elsewhere via MAX_TXS:
# Constants chosen to support a 630m block gas limit
MAX_TXS = 30_000

At 630M gas with ~21,000 gas per simple transfer, you get ~30,000 transactions max. The uint16 limit (65,535) provides headroom.

BlockAccessIndex semantics:
  • 0 = pre-execution phase (system contracts before tx 1)
  • 1 to n = transaction indices (1-indexed, not 0-indexed!)
  • n+1 = post-execution phase (withdrawals, post-block system calls)

Constants (lines 25-30)

# 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
MAX_CODE_CHANGES = 1

MAX_TXS = 30,000

Derived from: 630M gas ÷ 21,000 gas/transfer ≈ 30,000.

This caps BlockAccessIndex values. Any BAL with an index > MAX_TXS + 1 is invalid.

MAX_CODE_SIZE = 24,576

From EIP-170. Contract bytecode cannot exceed 24 KiB. This bounds the size of CodeData in CodeChange.

MAX_CODE_CHANGES = 1

Critical constraint: An account can have at most ONE code change per transaction. This is because:
  • CREATE/CREATE2 deploys code once
  • SELFDESTRUCT (in same tx) clears it once
  • EIP-7702 sets delegation once
Multiple code changes per tx would indicate a bug or invalid BAL.

Commented-out constants

# MAX_SLOTS = 300_000
# MAX_ACCOUNTS = 300_000

These were considered but not enforced. In practice, gas limits bound these implicitly.


StorageChange (lines 32-42)

@slotted_freezable
@dataclass
class StorageChange:
    """
    Storage change: [block_access_index, new_value].
    RLP encoded as a list.
    """

    block_access_index: BlockAccessIndex
    new_value: StorageValue
RLP encoding: [block_access_index, new_value] as a 2-element list. Example:
# TX 3 sets storage to 0x42
change = StorageChange(block_access_index=Uint(3), new_value=U256(0x42))
# RLP: [0x03, 0x42] → 0xc20342
Why new_value, not delta?

Recording the post-value (not the change amount) enables state reconstruction without knowing the pre-state. This is essential for executionless sync.


BalanceChange (lines 44-54)

@slotted_freezable
@dataclass
class BalanceChange:
    """
    Balance change: [block_access_index, post_balance].
    RLP encoded as a list.
    """

    block_access_index: BlockAccessIndex
    post_balance: Balance
Field name post_balance: Explicitly "post" to emphasize this is the balance after the transaction, not a delta. Multiple entries per account: Unlike code changes, an account can have multiple balance changes (one per tx that affects it). COINBASE typically has many entries—one for each tx that pays fees.

NonceChange (lines 56-66)

@slotted_freezable
@dataclass
class NonceChange:
    """
    Nonce change: [block_access_index, new_nonce].
    RLP encoded as a list.
    """

    block_access_index: BlockAccessIndex
    new_nonce: Nonce
Nonce semantics: Nonces only increment, never decrement. new_nonce is always greater than the previous value. When recorded:
  • Transaction sender: nonce increments
  • Contract using CREATE/CREATE2: nonce increments
  • Newly deployed contract: nonce set to 1 (EIP-161)

CodeChange (lines 68-78)

@slotted_freezable
@dataclass
class CodeChange:
    """
    Code change: [block_access_index, new_code].
    RLP encoded as a list.
    """

    block_access_index: BlockAccessIndex
    new_code: CodeData
new_code contents:
  • Contract deployment: the deployed bytecode (after initcode runs)
  • EIP-7702 delegation: 0xef0100 + 20-byte target address (23 bytes total)
  • SELFDESTRUCT (same-tx): empty bytes b''
Size bound: len(new_code) <= MAX_CODE_SIZE (24,576 bytes)

SlotChanges (lines 80-90)

@slotted_freezable
@dataclass
class SlotChanges:
    """
    All changes to a single storage slot: [slot, [changes]].
    RLP encoded as a list.
    """

    slot: StorageKey
    changes: Tuple[StorageChange, ...]
Grouping by slot: A single slot may be modified by multiple transactions in a block. SlotChanges groups all modifications to one slot. Example:
# Slot 0x01 modified by TX 2 (→0x100) and TX 5 (→0x200)
slot_changes = SlotChanges(
    slot=U256(0x01),
    changes=(
        StorageChange(Uint(2), U256(0x100)),
        StorageChange(Uint(5), U256(0x200)),
    )
)
# RLP: [0x01, [[0x02, 0x820100], [0x05, 0x820200]]]
Ordering: Changes within a slot are ordered by block_access_index (ascending). This is enforced during BAL construction (see Section 07).

AccountChanges (lines 92-115)

@slotted_freezable
@dataclass
class AccountChanges:
    """
    All changes for a single account, grouped by field type.
    RLP encoded as: [address, storage_changes, storage_reads,
    balance_changes, nonce_changes, code_changes].
    """

    address: Address

    # slot -> [block_access_index -> new_value]
    storage_changes: Tuple[SlotChanges, ...]

    # read-only storage keys
    storage_reads: Tuple[StorageKey, ...]

    # [block_access_index -> post_balance]
    balance_changes: Tuple[BalanceChange, ...]

    # [block_access_index -> new_nonce]
    nonce_changes: Tuple[NonceChange, ...]

    # [block_access_index -> new_code]
    code_changes: Tuple[CodeChange, ...]
RLP field order (fixed, positional):
  • address (20 bytes)
  • storage_changes (list of SlotChanges)
  • storage_reads (list of StorageKey)
  • balance_changes (list of BalanceChange)
  • nonce_changes (list of NonceChange)
  • code_changes (list of CodeChange)
Why storage_reads is separate from storage_changes:

Storage reads don't need post-values (there's nothing to reconstruct). Separating them:

  • Reduces BAL size (~1/3 smaller per read vs write)
  • Makes intent clear (read vs write)
  • Enables different handling in clients (reads for prefetch, writes for state update)
Empty lists are valid: An account touched but unchanged has all-empty lists. The address still appears in the BAL to indicate it was accessed.


BlockAccessList (line 118-121)

BlockAccessList = List[AccountChanges]
The top-level type: A BAL is simply a list of AccountChanges. Ordering: Accounts are sorted lexicographically by address. This is NOT enforced here (just a type alias) but is enforced during construction (see Section 07). Hash computation:
block_access_list_hash = keccak256(rlp.encode(block_access_list))

Cross-References

  • Section 02 (RLP Utilities): Encoding/decoding functions for these types
  • Section 06-07 (Builder): How these structures are constructed during execution
  • Section 03-05 (State Tracker): The recording layer that feeds the builder
  • EIP-7928: Canonical specification text

Gotchas

1. BlockAccessIndex is 1-indexed for transactions

# Transaction 1 has block_access_index = 1, NOT 0
# Index 0 is reserved for pre-execution
# Index n+1 is post-execution

2. Tuples, not Lists

All collection fields use Tuple[..., ...] (immutable) not List[...]. This ensures BAL structures can't be modified after creation.

3. storage_reads contains ONLY reads

A slot that was both read AND written goes in storage_changes, not storage_reads. The "read" is implicit in the write.

4. Empty code means cleared, not absent

CodeChange(block_access_index=Uint(3), new_code=b'')  # Code was CLEARED
# vs
code_changes=()  # No code change occurred

5. U256(0) RLP encoding

Zero encodes as empty RLP string 0x80, not 0x00:

rlp.encode(U256(0)) == b'\x80'  # Correct
rlp.encode(U256(0)) != b'\x00'  # Wrong

Section 02: RLP Utilities

Source

specs/execution-specs/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py (117 lines)

Imports (lines 16-24)

from typing import cast

from ethereum_rlp import Extended, rlp
from ethereum_types.bytes import Bytes
from ethereum_types.numeric import Uint

from ethereum.crypto.hash import Hash32, keccak256

from .rlp_types import BlockAccessList
Extended is the union type for RLP-encodable values. cast is needed because Python's type system can't verify nested list structures match Extended.

compute_block_access_list_hash (lines 27-47)

def compute_block_access_list_hash(
    block_access_list: BlockAccessList,
) -> Hash32:
    block_access_list_bytes = rlp_encode_block_access_list(block_access_list)
    return keccak256(block_access_list_bytes)
This is the consensus-critical function. The returned hash goes into the block header as block_access_list_hash. Validation path:
  • Builder produces BAL during execution
  • compute_block_access_list_hash(bal) → hash in header
  • Validator re-executes, builds BAL, computes hash
  • Hash mismatch → block invalid

rlp_encode_block_access_list (lines 50-113)

def rlp_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes:
    account_changes_list = []
    for account in block_access_list:
        storage_changes_list = [
            [
                slot_changes.slot,
                [
                    [Uint(c.block_access_index), c.new_value]
                    for c in slot_changes.changes
                ],
            ]
            for slot_changes in account.storage_changes
        ]

        storage_reads_list = list(account.storage_reads)

        balance_changes_list = [
            [Uint(bc.block_access_index), Uint(bc.post_balance)]
            for bc in account.balance_changes
        ]

        nonce_changes_list = [
            [Uint(nc.block_access_index), Uint(nc.new_nonce)]
            for nc in account.nonce_changes
        ]

        code_changes_list = [
            [Uint(cc.block_access_index), cc.new_code]
            for cc in account.code_changes
        ]

        account_changes_list.append(
            [
                account.address,
                storage_changes_list,
                storage_reads_list,
                balance_changes_list,
                nonce_changes_list,
                code_changes_list,
            ]
        )

    encoded = rlp.encode(cast(Extended, account_changes_list))
    return Bytes(encoded)

Encoding Structure

BlockAccessList = [
  AccountChanges_0,
  AccountChanges_1,
  ...
]

AccountChanges = [
  address,                    # Bytes20
  storage_changes,            # [[slot, [[idx, val], ...]], ...]
  storage_reads,              # [slot, ...]
  balance_changes,            # [[idx, balance], ...]
  nonce_changes,              # [[idx, nonce], ...]
  code_changes                # [[idx, code], ...]
]

Uint() Wrapping

Notice explicit Uint() conversions:

[Uint(c.block_access_index), c.new_value]
[Uint(bc.block_access_index), Uint(bc.post_balance)]

This ensures RLP integer encoding (minimal big-endian, no leading zeros). Without wrapping, Python ints might encode incorrectly.

No Validation

This function does NOT validate:

  • Ordering (addresses sorted, slots sorted, indices ascending)
  • Bounds (MAX_TXS, MAX_CODE_SIZE)
  • Semantic correctness (valid indices)
Validation happens in builder.py during construction. This function assumes input is already valid.


What's Missing

The file is minimal—only encoding, no decoding. This is intentional:

  • Block producers encode BALs they build
  • Validators don't decode received BALs—they rebuild from execution and compare hashes
  • Sync nodes using executionless sync would need decoding, but that's not in EELS yet
If decoding is added later, it would mirror the encoding structure with validation checks.

RLP Output Example

For a simple transfer (Alice → Bob, TX 1):

bal = [
    AccountChanges(
        address=ALICE,
        storage_changes=(),
        storage_reads=(),
        balance_changes=(BalanceChange(1, 4_000_000_000_000_000_000),),
        nonce_changes=(NonceChange(1, 43),),
        code_changes=(),
    ),
    AccountChanges(
        address=BOB,
        storage_changes=(),
        storage_reads=(),
        balance_changes=(BalanceChange(1, 2_000_000_000_000_000_000),),
        nonce_changes=(),
        code_changes=(),
    ),
]

# Encodes to (simplified):
# [
#   [alice_addr, [], [], [[1, 4e18]], [[1, 43]], []],
#   [bob_addr, [], [], [[1, 2e18]], [], []]
# ]

Cross-References

  • Section 01: Type definitions being encoded
  • Section 07: Where ordering/validation happens before encoding
  • Section 08: blocks.py calls compute_block_access_list_hash

Section 03: State Tracker - Data Structures

Source

specs/execution-specs/src/ethereum/forks/amsterdam/state_tracker.py (lines 1-110)

Module Docstring (lines 1-11)

"""
EIP-7928 Block Access Lists: Hierarchical State Change Tracking.

Frame hierarchy mirrors EVM execution: Block -> Transaction -> Call frames.
Each frame tracks state accesses and merges to parent on completion.

On success, changes merge upward with net-zero filtering (pre-state vs final).
On failure, only reads merge (writes discarded). Pre-state captures use
first-write-wins semantics and are stored at the transaction frame level.
"""

Three key concepts:

  • Frame hierarchy: Block → Transaction → Call (nested arbitrarily deep)
  • Merge semantics: Success = merge all; Failure = merge reads only
  • First-write-wins: Pre-values captured on first access, not overwritten

StateChanges Dataclass (lines 24-56)

@dataclass
class StateChanges:
    parent: Optional["StateChanges"] = None
    block_access_index: BlockAccessIndex = BlockAccessIndex(0)

    touched_addresses: Set[Address] = field(default_factory=set)
    storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set)
    storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256] = (
        field(default_factory=dict)
    )

    balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256] = field(
        default_factory=dict
    )
    nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field(
        default_factory=set
    )
    code_changes: Dict[Tuple[Address, BlockAccessIndex], Bytes] = field(
        default_factory=dict
    )

    # Pre-state captures (transaction-scoped)
    pre_balances: Dict[Address, U256] = field(default_factory=dict)
    pre_storage: Dict[Tuple[Address, Bytes32], U256] = field(default_factory=dict)
    pre_code: Dict[Address, Bytes] = field(default_factory=dict)

Field Breakdown

FieldKey TypeValue TypePurpose
parent-StateChanges?Links frame hierarchy
block_access_index-UintCurrent tx index (0=pre, 1..n=tx, n+1=post)
touched_addresses-Set[Address]All accessed addresses
storage_reads(addr, slot)-Slots read but not written
storage_writes(addr, slot, idx)U256Slot writes with tx attribution
balance_changes(addr, idx)U256Balance after tx
nonce_changes(addr, idx, nonce)-Nonce increments (set, not dict)
code_changes(addr, idx)BytesCode deployments/delegations
pre_balancesaddrU256Pre-tx balance (for net-zero filter)
pre_storage(addr, slot)U256Pre-tx storage (for net-zero filter)
pre_codeaddrBytesPre-tx code (for net-zero filter)

Why nonce_changes is a Set, not Dict

nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]]

Nonces only increment. Multiple nonce changes per address/tx can occur (CREATE inside a tx), but only the final nonce matters. Using a Set with the nonce value as part of the tuple allows deduplication during merge (keep highest).

Why storage_writes includes BlockAccessIndex in key

storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256]

A single slot can be written by multiple transactions. The index in the key allows tracking each write separately, which is needed for the BAL's SlotChanges.changes list.


get_block_frame (lines 59-74)

def get_block_frame(state_changes: StateChanges) -> StateChanges:
    block_frame = state_changes
    while block_frame.parent is not None:
        block_frame = block_frame.parent
    return block_frame

Walks to root. Block frame has parent = None.


increment_block_access_index (lines 77-88)

def increment_block_access_index(root_frame: StateChanges) -> None:
    root_frame.block_access_index = BlockAccessIndex(
        root_frame.block_access_index + Uint(1)
    )

Called between transactions. Index 0 → 1 before tx 1, index 1 → 2 before tx 2, etc.


get_transaction_frame (lines 91-108)

def get_transaction_frame(state_changes: StateChanges) -> StateChanges:
    tx_frame = state_changes
    while tx_frame.parent is not None and tx_frame.parent.parent is not None:
        tx_frame = tx_frame.parent
    return tx_frame

Walks up until parent.parent is None. The tx frame is the direct child of the block frame.

Frame depth identification:
  • parent is None → block frame
  • parent.parent is None → transaction frame
  • parent.parent.parent is None → first call frame
  • etc.

capture_pre_balance (lines 111-130)

def capture_pre_balance(
    tx_frame: StateChanges, address: Address, balance: U256
) -> None:
    assert tx_frame.parent is None or tx_frame.parent.parent is None
    if address not in tx_frame.pre_balances:
        tx_frame.pre_balances[address] = balance
First-write-wins: Only captures if not already present. The pre-value is the state at transaction start, not at current call frame start. Assertion: Must be called on tx frame (or block frame for system calls).

capture_pre_storage (lines 133-156)

def capture_pre_storage(
    tx_frame: StateChanges, address: Address, key: Bytes32, value: U256
) -> None:
    assert tx_frame.parent is None or tx_frame.parent.parent is None
    slot = (address, key)
    if slot not in tx_frame.pre_storage:
        tx_frame.pre_storage[slot] = value

Same pattern. Pre-storage is used for net-zero filtering:

  • If storage_writes[(addr, key, idx)] == pre_storage[(addr, key)], the write is converted to a read.

capture_pre_code (lines 159-178)

def capture_pre_code(
    tx_frame: StateChanges, address: Address, code: Bytes
) -> None:
    assert tx_frame.parent is None or tx_frame.parent.parent is None
    if address not in tx_frame.pre_code:
        tx_frame.pre_code[address] = code

Same pattern. Used for EIP-7702 delegation changes that might restore original code.


Frame Hierarchy Diagram

┌──────────────────────────────────────────────────────────┐
│ Block Frame (parent=None)                                │
│   block_access_index: managed here                       │
│   touched_addresses: accumulated from all txs            │
│   storage_reads/writes: final BAL data                   │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │ TX Frame (parent=block, parent.parent=None)        │  │
│  │   pre_balances, pre_storage, pre_code: captured    │  │
│  │   block_access_index: inherited from block         │  │
│  │                                                    │  │
│  │  ┌──────────────────────────────────────────────┐  │  │
│  │  │ Call Frame 1 (parent=tx)                     │  │  │
│  │  │   No pre-captures (delegated to tx frame)    │  │  │
│  │  │                                              │  │  │
│  │  │  ┌────────────────────────────────────────┐  │  │  │
│  │  │  │ Call Frame 2 (parent=call1)            │  │  │  │
│  │  │  │   Arbitrary nesting depth              │  │  │  │
│  │  │  └────────────────────────────────────────┘  │  │  │
│  │  └──────────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

Cross-References

  • Section 04: Recording functions (track_)
  • Section 05: Frame management (merge_, commit_, filter_)
  • Section 06-07: Builder consumes the block frame after all txs complete

Section 04: State Tracker - Recording Logic

Source

specs/execution-specs/src/ethereum/forks/amsterdam/state_tracker.py (lines 180-330)

track_address (lines 188-201)

def track_address(state_changes: StateChanges, address: Address) -> None:
    state_changes.touched_addresses.add(address)

Called for every address access regardless of type. Even if no state changes, the address appears in the BAL.

Call sites:
  • Transaction sender/recipient
  • BALANCE, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH targets
  • CALL, DELEGATECALL, STATICCALL, CALLCODE targets
  • CREATE, CREATE2 deployments
  • Precompile calls
  • System contract calls

track_storage_read (lines 204-220)

def track_storage_read(
    state_changes: StateChanges, address: Address, key: Bytes32
) -> None:
    state_changes.storage_reads.add((address, key))

Called on SLOAD. The slot goes into storage_reads initially. If the same slot is later written in the same tx, the final placement (reads vs writes) is determined by net-zero filtering at commit time.


track_storage_write (lines 223-243)

def track_storage_write(
    state_changes: StateChanges,
    address: Address,
    key: Bytes32,
    value: U256,
) -> None:
    idx = state_changes.block_access_index
    state_changes.storage_writes[(address, key, idx)] = value

Called on SSTORE. Note:

  • Key includes block_access_index to track per-tx writes
  • Value is the new value, not delta
  • Same (addr, key, idx) overwrites previous (latest value wins within a tx)
Gas validation already passed: If track_storage_write is called, the SSTORE stipend check (>2300 gas) succeeded. See Section 09.


track_balance_change (lines 246-265)

def track_balance_change(
    state_changes: StateChanges,
    address: Address,
    new_balance: U256,
) -> None:
    idx = state_changes.block_access_index
    state_changes.balance_changes[(address, idx)] = new_balance

Called when balance changes:

  • Transaction value transfer
  • Gas payment (sender)
  • Gas refund (sender)
  • Fee payment (COINBASE)
  • SELFDESTRUCT balance transfer
  • Withdrawals
Multiple calls with same (addr, idx) overwrite—only final balance matters.


track_nonce_change (lines 268-287)

def track_nonce_change(
    state_changes: StateChanges,
    address: Address,
    new_nonce: U64,
) -> None:
    idx = state_changes.block_access_index
    state_changes.nonce_changes.add((address, idx, new_nonce))
Uses .add() not assignment: Multiple nonce increments in a tx (e.g., tx sender + CREATE) each add an entry. During merge, only highest nonce per address is kept.

Called when:

  • Transaction execution (sender nonce++)
  • CREATE/CREATE2 (deployer nonce++)
  • Newly deployed contract (nonce set to 1 per EIP-161)

track_code_change (lines 290-310)

def track_code_change(
    state_changes: StateChanges,
    address: Address,
    new_code: Bytes,
) -> None:
    idx = state_changes.block_access_index
    state_changes.code_changes[(address, idx)] = new_code

Called when:

  • Contract deployment completes (CREATE/CREATE2 returns)
  • EIP-7702 delegation set (0xef0100 + target)
  • SELFDESTRUCT clears code (same-tx creation, new_code = b'')
MAX_CODE_CHANGES = 1: At most one code change per address per tx. The dict key (addr, idx) enforces this—multiple calls overwrite.


track_selfdestruct (lines 313-365)

def track_selfdestruct(
    tx_frame: StateChanges,
    address: Address,
) -> None:
    assert tx_frame.parent is not None and tx_frame.parent.parent is None

    idx = tx_frame.block_access_index

    # Remove nonce changes from current transaction
    tx_frame.nonce_changes = {
        (addr, i, nonce)
        for addr, i, nonce in tx_frame.nonce_changes
        if not (addr == address and i == idx)
    }

    # Remove balance changes from current transaction
    if (address, idx) in tx_frame.balance_changes:
        pre_balance = tx_frame.pre_balances[address]
        if pre_balance == U256(0):
            del tx_frame.balance_changes[(address, idx)]

    # Remove code changes from current transaction
    if (address, idx) in tx_frame.code_changes:
        del tx_frame.code_changes[(address, idx)]

    # Convert storage writes from current transaction to reads
    for addr, key, i in list(tx_frame.storage_writes.keys()):
        if addr == address and i == idx:
            del tx_frame.storage_writes[(addr, key, i)]
            tx_frame.storage_reads.add((addr, key))
EIP-6780 interaction: SELFDESTRUCT only destroys contracts created in the same transaction. This function handles BAL implications:
  • Nonce: Removed (account ceases to exist)
  • Balance: Kept only if pre-balance was non-zero (transfer to beneficiary is recorded)
  • Code: Removed (no deployment in final state)
  • Storage writes: Converted to reads (slots were accessed but final state is empty)
Why tx_frame assertion?: SELFDESTRUCT can only happen within a tx, and we need access to pre_balances for the balance logic.

Recording Flow Example

TX 1: Alice (0xaaaa) sends 1 ETH to Bob (0xbbbb)

1. begin_transaction(block_frame) → tx_frame created
2. increment_block_access_index(block_frame) → idx = 1

3. track_address(tx_frame, ALICE)
4. capture_pre_balance(tx_frame, ALICE, 5 ETH)
5. track_balance_change(tx_frame, ALICE, 3.999 ETH)  # after value + gas
6. track_nonce_change(tx_frame, ALICE, 43)

7. track_address(tx_frame, BOB)
8. capture_pre_balance(tx_frame, BOB, 1 ETH)
9. track_balance_change(tx_frame, BOB, 2 ETH)

10. track_address(tx_frame, COINBASE)
11. capture_pre_balance(tx_frame, COINBASE, 100 ETH)
12. track_balance_change(tx_frame, COINBASE, 100.001 ETH)

13. commit_transaction_frame(tx_frame)  # filters net-zero, merges to block

Cross-References

  • Section 03: StateChanges dataclass these functions populate
  • Section 05: merge_ and commit_ that consume the recorded data
  • Section 09: VM integration showing where track_* is called from EVM opcodes

Section 05: State Tracker - Frame Management

Source

specs/execution-specs/src/ethereum/forks/amsterdam/state_tracker.py (lines 368-558)

merge_on_success (lines 368-412)

def merge_on_success(child_frame: StateChanges) -> None:
    assert child_frame.parent is not None
    parent_frame = child_frame.parent

    # Merge address accesses
    parent_frame.touched_addresses.update(child_frame.touched_addresses)

    # Merge storage: reads union, writes overwrite
    parent_frame.storage_reads.update(child_frame.storage_reads)
    for storage_key, storage_value in child_frame.storage_writes.items():
        parent_frame.storage_writes[storage_key] = storage_value

    # Merge balance changes: child overwrites parent
    for balance_key, balance_value in child_frame.balance_changes.items():
        parent_frame.balance_changes[balance_key] = balance_value

    # Merge nonce changes: keep highest nonce per address
    address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {}
    for addr, idx, nonce in child_frame.nonce_changes:
        if addr not in address_final_nonces or nonce > address_final_nonces[addr][1]:
            address_final_nonces[addr] = (idx, nonce)
    for addr, (idx, final_nonce) in address_final_nonces.items():
        parent_frame.nonce_changes.add((addr, idx, final_nonce))

    # Merge code changes: child overwrites parent
    for code_key, code_value in child_frame.code_changes.items():
        parent_frame.code_changes[code_key] = code_value

Called when a call frame returns successfully (no revert).

Key behaviors:
  • touched_addresses: Union (both parent and child addresses kept)
  • storage_reads: Union
  • storage_writes: Child overwrites (latest value wins)
  • balance_changes: Child overwrites
  • nonce_changes: Keep highest nonce per address
  • code_changes: Child overwrites
No net-zero filtering here: That happens once at commit_transaction_frame.

merge_on_failure (lines 415-437)

def merge_on_failure(child_frame: StateChanges) -> None:
    assert child_frame.parent is not None
    parent_frame = child_frame.parent

    # Only merge reads and address accesses on failure
    parent_frame.touched_addresses.update(child_frame.touched_addresses)
    parent_frame.storage_reads.update(child_frame.storage_reads)

    # Convert writes to reads
    for address, key, _idx in child_frame.storage_writes.keys():
        parent_frame.storage_reads.add((address, key))

    # balance_changes, nonce_changes, code_changes: NOT merged

Called when a call frame reverts.

Key behaviors:
  • touched_addresses: Merged (addresses were still accessed)
  • storage_reads: Merged
  • storage_writes: Converted to reads (write happened but was reverted)
  • balance_changes: Discarded
  • nonce_changes: Discarded
  • code_changes: Discarded
Why convert writes to reads?: The slot was accessed (for prefetch purposes) even though the final value didn't change.

commit_transaction_frame (lines 440-477)

def commit_transaction_frame(tx_frame: StateChanges) -> None:
    assert tx_frame.parent is not None
    block_frame = tx_frame.parent

    # Filter net-zero changes before committing
    filter_net_zero_frame_changes(tx_frame)

    # Merge all state to block frame
    block_frame.touched_addresses.update(tx_frame.touched_addresses)
    block_frame.storage_reads.update(tx_frame.storage_reads)
    for (addr, key, idx), value in tx_frame.storage_writes.items():
        block_frame.storage_writes[(addr, key, idx)] = value
    for (addr, idx), final_balance in tx_frame.balance_changes.items():
        block_frame.balance_changes[(addr, idx)] = final_balance
    for addr, idx, nonce in tx_frame.nonce_changes:
        block_frame.nonce_changes.add((addr, idx, nonce))
    for (addr, idx), final_code in tx_frame.code_changes.items():
        block_frame.code_changes[(addr, idx)] = final_code

Called after successful tx execution (or after rollback_transaction if failed).

Critical: Calls filter_net_zero_frame_changes BEFORE merging. This ensures only actual state changes make it to the block frame.

create_child_frame (lines 480-504)

def create_child_frame(parent: StateChanges) -> StateChanges:
    return StateChanges(
        parent=parent,
        block_access_index=parent.block_access_index,
    )

Creates a new frame linked to parent. Inherits block_access_index so track functions don't need to walk up the hierarchy.


filter_net_zero_frame_changes (lines 507-558)

def filter_net_zero_frame_changes(tx_frame: StateChanges) -> None:
    idx = tx_frame.block_access_index

    # Filter storage: convert net-zero writes to reads
    addresses_to_check_storage = [
        (addr, key)
        for (addr, key, i) in tx_frame.storage_writes.keys()
        if i == idx
    ]
    for addr, key in addresses_to_check_storage:
        assert (addr, key) in tx_frame.pre_storage
        pre_value = tx_frame.pre_storage[(addr, key)]
        post_value = tx_frame.storage_writes[(addr, key, idx)]
        if pre_value == post_value:
            del tx_frame.storage_writes[(addr, key, idx)]
            tx_frame.storage_reads.add((addr, key))

    # Filter balance: remove if pre == post
    addresses_to_check_balance = [
        addr for (addr, i) in tx_frame.balance_changes.keys() if i == idx
    ]
    for addr in addresses_to_check_balance:
        assert addr in tx_frame.pre_balances
        pre_balance = tx_frame.pre_balances[addr]
        post_balance = tx_frame.balance_changes[(addr, idx)]
        if pre_balance == post_balance:
            del tx_frame.balance_changes[(addr, idx)]

    # Filter code: remove if pre == post
    addresses_to_check_code = [
        addr for (addr, i) in tx_frame.code_changes.keys() if i == idx
    ]
    for addr in addresses_to_check_code:
        assert addr in tx_frame.pre_code
        pre_code = tx_frame.pre_code[addr]
        post_code = tx_frame.code_changes[(addr, idx)]
        if pre_code == post_code:
            del tx_frame.code_changes[(addr, idx)]

    # Nonces: no filtering (only increment, never net-zero)
Net-zero filtering logic:
FieldIf pre == postResult
StorageWrite → ReadSlot still appears in BAL (as read)
BalanceRemovedAddress may still appear (if touched)
CodeRemovedAddress may still appear (if touched)
NonceN/ANonces only increment, never return to original
Assertions: Every pre-value must have been captured. If assertion fails, there's a bug in the tracking logic (capture_pre_ wasn't called).

Net-Zero Example

// Pre-tx: slot[0] = 100
function foo() {
    slot[0] = 200;  // track_storage_write(addr, 0, 200)
    slot[0] = 300;  // track_storage_write(addr, 0, 300) - overwrites
    slot[0] = 100;  // track_storage_write(addr, 0, 100) - overwrites
}
// Post-tx: slot[0] = 100

At filter_net_zero_frame_changes:

pre_storage[(addr, 0)] = 100
storage_writes[(addr, 0, idx)] = 100
pre == post → delete from storage_writes, add to storage_reads

Result: Slot 0 appears in storage_reads, not storage_changes.


Full Transaction Lifecycle

begin_transaction():
    tx_frame = create_child_frame(block_frame)

execute_transaction():
    for each call:
        call_frame = create_child_frame(parent)
        execute_call()
        if success:
            merge_on_success(call_frame)
        else:
            merge_on_failure(call_frame)

end_transaction():
    if success:
        commit_transaction_frame(tx_frame)  # includes net-zero filter
    else:
        merge_on_failure(tx_frame)  # discards value changes
        commit_transaction_frame(tx_frame)  # commits reads only

Cross-References

  • Section 03: StateChanges dataclass
  • Section 04: track_ functions that populate frames
  • Section 06-07: Builder consumes block_frame after all txs
  • Section 08: blocks.py orchestrates this lifecycle

Section 06: BAL Builder - Construction

Source Files

  • specs/execution-specs/src/ethereum/forks/amsterdam/block_access_lists/builder.py (lines 1-290)

Overview

The BAL builder sits between the state tracker (Sections 03-05) and the final BlockAccessList output. During block execution, the state tracker records every state access. After execution completes, those recorded changes are fed to the builder, which:

  • Organizes changes by address, then by field type
  • Deduplicates within-transaction writes (only final value matters)
  • Prepares the data structure for sorting and finalization (Section 07)
This section covers the construction phase: the data structures and recording functions. The finalization phase (sorting, encoding) is in Section 07.

Module Docstring (lines 1-14)

"""
Implements the Block Access List builder that tracks all account
and storage accesses during block execution and constructs the final
[`BlockAccessList`].

The builder follows a two-phase approach:

1. **Collection Phase**: During transaction execution, all state accesses are
   recorded via the tracking functions.
2. **Build Phase**: After block execution, the accumulated data is sorted
   and encoded into the final deterministic format.

[`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList
"""
Key insight: The two-phase design separates concerns:
  • Collection phase (this section): Fast, append-mostly operations during hot execution
  • Build phase (Section 07): One-time sorting and encoding after execution
This matters because block execution is latency-sensitive. Recording should be O(1) or O(log n), not O(n log n). The expensive sorting happens once, after all transactions complete.

Imports (lines 15-31)

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Dict, List, Set

from ethereum_types.bytes import Bytes
from ethereum_types.numeric import U64, U256

from ..fork_types import Address
from .rlp_types import (
    AccountChanges,
    BalanceChange,
    BlockAccessIndex,
    BlockAccessList,
    CodeChange,
    NonceChange,
    SlotChanges,
    StorageChange,
)

if TYPE_CHECKING:
    from ..state_tracker import StateChanges
TYPE_CHECKING guard: The StateChanges import is only for type hints. At runtime, we avoid importing state_tracker to prevent circular dependencies (state_tracker imports builder types for recording). Import topology:
rlp_types.py (pure data)
     ↑
builder.py (construction)
     ↑
state_tracker.py (recording during execution)

The builder can import from rlp_types but only type-check against state_tracker.


AccountData Dataclass (lines 33-62)

@dataclass
class AccountData:
    """
    Account data stored in the builder during block execution.

    This dataclass tracks all changes made to a single account throughout
    the execution of a block, organized by the type of change and the
    transaction index where it occurred.
    """

    storage_changes: Dict[U256, List[StorageChange]] = field(
        default_factory=dict
    )
    """
    Mapping from storage slot to list of changes made to that slot.
    Each change includes the transaction index and new value.
    """

    storage_reads: Set[U256] = field(default_factory=set)
    """
    Set of storage slots that were read but not modified.
    """

    balance_changes: List[BalanceChange] = field(default_factory=list)
    """
    List of balance changes for this account, ordered by transaction index.
    """

    nonce_changes: List[NonceChange] = field(default_factory=list)
    """
    List of nonce changes for this account, ordered by transaction index.
    """

    code_changes: List[CodeChange] = field(default_factory=list)
    """
    List of code changes (contract deployments) for this account,
    ordered by transaction index.
    """

Design Rationale

Why Dict[U256, List[StorageChange]] for storage?

Storage changes need two levels of grouping:

  • By slot (outer dict key)
  • By transaction (inner list elements)
A single slot can be modified multiple times in a block (different transactions). Each modification needs its own StorageChange with the transaction's block_access_index.

Why List for changes, not Dict?

Lists preserve insertion order and allow append operations. Changes are typically added in transaction order. The final sorting happens in the build phase, not during collection.

Why Set[U256] for storage reads?

Read tracking only cares about "was this slot read?" — the transaction index doesn't matter for reads. A set provides:

  • O(1) membership testing
  • Automatic deduplication
  • Simpler data structure
Data structure comparison:

FieldStructureWhy
storage_changesDict[slot, List[change]]Group by slot, track per-tx values
storage_readsSet[slot]Just need "was read" flag
balance_changesList[change]Track each tx's final balance
nonce_changesList[change]Track each tx's nonce increment
code_changesList[change]Rare (typically 0-1 per account)

Memory Layout

Each AccountData instance contains:

  • 1 dict (storage_changes)
  • 1 set (storage_reads)
  • 3 lists (balance, nonce, code)
For a typical account touched by 1-2 transactions:
  • Dict overhead: ~232 bytes (empty Python dict)
  • Set overhead: ~216 bytes (empty Python set)
  • List overhead: ~56 bytes × 3 = 168 bytes
  • Total base: ~616 bytes per touched account
This adds up in blocks with many touched accounts (10,000+ addresses is common in busy blocks).


BlockAccessListBuilder Dataclass (lines 64-79)

@dataclass
class BlockAccessListBuilder:
    """
    Builder for constructing [`BlockAccessList`] efficiently during transaction
    execution.

    The builder accumulates all account and storage accesses during block
    execution and constructs a deterministic access list. Changes are tracked
    by address, field type, and transaction index to enable efficient
    reconstruction of state changes.

    [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList
    """

    accounts: Dict[Address, AccountData] = field(default_factory=dict)
    """
    Mapping from account address to its tracked changes during block execution.
    """
Minimal structure: The builder is just a dict from address to account data. Everything else is in the functions that manipulate it. Why not a class with methods?

EELS follows a functional style. Functions operating on dataclasses are:

  • Easier to test (pure functions)
  • Easier to reason about (no hidden state)
  • Consistent with the rest of the spec
Thread safety: The builder is NOT thread-safe. Block execution is single-threaded in EELS. Parallel execution (future work) would need per-thread builders with a merge step.


ensure_account (lines 81-99)

def ensure_account(builder: BlockAccessListBuilder, address: Address) -> None:
    """
    Ensure an account exists in the builder's tracking structure.

    Creates an empty [`AccountData`] entry for the given address if it
    doesn't already exist. This function is idempotent and safe to call
    multiple times for the same address.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address to ensure exists.

    [`AccountData`] :
        ref:ethereum.forks.amsterdam.block_access_lists.builder.AccountData

    """
    if address not in builder.accounts:
        builder.accounts[address] = AccountData()
Idempotency: Calling ensure_account multiple times for the same address is safe—it only creates the entry once. Why a separate function?

Every recording function needs this check. Factoring it out:

  • Reduces code duplication
  • Makes the intent clear ("ensure exists, then operate")
  • Allows future changes (e.g., logging, metrics) in one place
Alternative: defaultdict

# Could use defaultdict instead:
accounts: Dict[Address, AccountData] = field(
    default_factory=lambda: defaultdict(AccountData)
)

EELS prefers explicit checks over implicit defaultdict behavior. Explicit is clearer for spec-reading.


add_storage_write (lines 101-148)

def add_storage_write(
    builder: BlockAccessListBuilder,
    address: Address,
    slot: U256,
    block_access_index: BlockAccessIndex,
    new_value: U256,
) -> None:
    """
    Add a storage write operation to the block access list.

    Records a storage slot modification for a given address at a specific
    transaction index. If multiple writes occur to the same slot within the
    same transaction (same block_access_index), only the final value is kept.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address whose storage is being modified.
    slot :
        The storage slot being written to.
    block_access_index :
        The block access index for this change (0 for pre-execution,
        1..n for transactions, n+1 for post-execution).
    new_value :
        The new value being written to the storage slot.

    """
    ensure_account(builder, address)

    if slot not in builder.accounts[address].storage_changes:
        builder.accounts[address].storage_changes[slot] = []

    # Check if there's already an entry with the same block_access_index
    # If so, update it with the new value, keeping only the final write
    changes = builder.accounts[address].storage_changes[slot]
    for i, existing_change in enumerate(changes):
        if existing_change.block_access_index == block_access_index:
            # Update the existing entry with the new value
            changes[i] = StorageChange(
                block_access_index=block_access_index, new_value=new_value
            )
            return

    # No existing entry found, append new change
    change = StorageChange(
        block_access_index=block_access_index, new_value=new_value
    )
    builder.accounts[address].storage_changes[slot].append(change)

Same-Transaction Deduplication

Critical behavior: Multiple writes to the same slot within the same transaction keep only the final value.

Example:

# TX 3 writes slot 0x01 three times
add_storage_write(builder, addr, slot=0x01, index=3, value=0x100)
add_storage_write(builder, addr, slot=0x01, index=3, value=0x200)
add_storage_write(builder, addr, slot=0x01, index=3, value=0x300)
# Result: only StorageChange(index=3, value=0x300) stored

Why? The BAL records post-transaction state, not the trace. Intermediate values within a transaction don't matter for state reconstruction.

Cross-Transaction Preservation

Different transactions create separate entries:

# TX 2 and TX 5 both write slot 0x01
add_storage_write(builder, addr, slot=0x01, index=2, value=0xAAA)
add_storage_write(builder, addr, slot=0x01, index=5, value=0xBBB)
# Result: two entries
#   StorageChange(index=2, value=0xAAA)
#   StorageChange(index=5, value=0xBBB)

Both are preserved because they represent different states at different block heights (post-TX2, post-TX5).

Complexity Analysis

Worst case: O(n) where n = number of transactions that touched this slot

The linear scan for i, existing_change in enumerate(changes) looks slow, but:

  • Most slots are written by 1-2 transactions per block
  • Hot slots (like AMM pool reserves) might see ~10-50 writes
  • At 50 writes per slot, linear scan is still fast enough
If this became a bottleneck, the fix would be a dict keyed by block_access_index:
# Alternative structure (not used)
storage_changes: Dict[U256, Dict[BlockAccessIndex, U256]]

EELS prefers the simpler List for readability.


add_storage_read (lines 150-173)

def add_storage_read(
    builder: BlockAccessListBuilder, address: Address, slot: U256
) -> None:
    """
    Add a storage read operation to the block access list.

    Records that a storage slot was read during execution. Storage slots
    that are both read and written will only appear in the storage changes
    list, not in the storage reads list, as per [EIP-7928].

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address whose storage is being read.
    slot :
        The storage slot being read.

    [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928

    """
    ensure_account(builder, address)
    builder.accounts[address].storage_reads.add(slot)
Simplicity: Just add to a set. No deduplication needed (sets handle it). No transaction index needed (reads don't have per-tx granularity in the BAL).

Read vs Write Exclusivity

The docstring notes: "Storage slots that are both read and written will only appear in the storage changes list."

This filtering happens in the build phase (Section 07), not here. During collection, we record both reads and writes. The build phase excludes reads for slots that also have writes.

Why not filter here?

Because writes might be reverted. A slot that was written in a failed call still needs to be tracked as a read (the read happened, even though the write didn't persist).

Example:

SLOAD(slot)    → recorded as read
CALL(...)      → nested call writes slot, then REVERTs
SLOAD(slot)    → slot appears in reads (write was discarded)


add_balance_change (lines 175-218)

def add_balance_change(
    builder: BlockAccessListBuilder,
    address: Address,
    block_access_index: BlockAccessIndex,
    post_balance: U256,
) -> None:
    """
    Add a balance change to the block access list.

    Records the post-transaction balance for an account after it has been
    modified. This includes changes from transfers, gas fees, block rewards,
    and any other balance-affecting operations.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address whose balance changed.
    block_access_index :
        The block access index for this change (0 for pre-execution,
        1..n for transactions, n+1 for post-execution).
    post_balance :
        The account balance after the change as U256.

    """
    ensure_account(builder, address)

    # Balance value is already U256
    balance_value = post_balance

    # Check if we already have a balance change for this tx_index and update it
    # This ensures we only track the final balance per transaction
    existing_changes = builder.accounts[address].balance_changes
    for i, existing in enumerate(existing_changes):
        if existing.block_access_index == block_access_index:
            # Update the existing balance change with the new balance
            existing_changes[i] = BalanceChange(
                block_access_index=block_access_index,
                post_balance=balance_value,
            )
            return

    # No existing change for this tx_index, add a new one
    change = BalanceChange(
        block_access_index=block_access_index, post_balance=balance_value
    )
    builder.accounts[address].balance_changes.append(change)

Same-Transaction Deduplication (Again)

Same pattern as storage writes: multiple balance changes within one transaction → keep only the final value.

Why does this happen?

A transaction can modify an account's balance multiple times:

  • Gas deduction at start
  • Value transfer (msg.value)
  • Internal transfers via CALL
  • Gas refund at end
  • Priority fee payment to COINBASE
All of these are "the same transaction" with the same block_access_index. Only the final balance matters.

Example: COINBASE Balance Tracking

The block builder (COINBASE) receives fees from every transaction:

# TX 1: COINBASE balance → 1.0 ETH
add_balance_change(builder, COINBASE, index=1, post_balance=1e18)
# TX 2: COINBASE balance → 1.5 ETH
add_balance_change(builder, COINBASE, index=2, post_balance=1.5e18)
# TX 3: COINBASE balance → 2.2 ETH
add_balance_change(builder, COINBASE, index=3, post_balance=2.2e18)

Result: 3 separate BalanceChange entries (different indices).

No Pre-Balance Capture Here

Note that this function records post-balance, not the delta. Pre-balance capture happens in the state tracker (Section 04) via capture_pre_balance. The builder doesn't need to know the pre-state—it just records what the state tracker tells it.


add_nonce_change (lines 220-262)

def add_nonce_change(
    builder: BlockAccessListBuilder,
    address: Address,
    block_access_index: BlockAccessIndex,
    new_nonce: U64,
) -> None:
    """
    Add a nonce change to the block access list.

    Records a nonce increment for an account. This occurs when an EOA sends
    a transaction or when a contract performs [`CREATE`] or [`CREATE2`]
    operations.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address whose nonce changed.
    block_access_index :
        The block access index for this change (0 for pre-execution,
        1..n for transactions, n+1 for post-execution).
    new_nonce :
        The new nonce value after the change.

    [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create
    [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2

    """
    ensure_account(builder, address)

    # Check if we already have a nonce change for this tx_index and update it
    # This ensures we only track the final (highest) nonce per transaction
    existing_changes = builder.accounts[address].nonce_changes
    for i, existing in enumerate(existing_changes):
        if existing.block_access_index == block_access_index:
            # Keep the highest nonce value
            if new_nonce > existing.new_nonce:
                existing_changes[i] = NonceChange(
                    block_access_index=block_access_index, new_nonce=new_nonce
                )
            return

    # No existing change for this tx_index, add a new one
    change = NonceChange(
        block_access_index=block_access_index, new_nonce=new_nonce
    )
    builder.accounts[address].nonce_changes.append(change)

Highest-Nonce-Wins Semantics

Unlike balance and storage (which keep the last value), nonce keeps the highest value:

if new_nonce > existing.new_nonce:
    # Update only if new nonce is higher
Why?

Nonces only ever increase. If we see nonce=5 then nonce=3 for the same transaction, something is wrong. By keeping the highest, we're effectively keeping the final state (since nonce can only go up).

When does a single transaction increment nonce multiple times?

A factory contract deploying multiple contracts:

contract Factory {
    function deploy() external {
        new Child();  // nonce++ → 1
        new Child();  // nonce++ → 2
        new Child();  // nonce++ → 3
    }
}

All three increments happen in the same transaction. We want to record nonce=3, not nonce=1.


add_code_change (lines 264-306)

def add_code_change(
    builder: BlockAccessListBuilder,
    address: Address,
    block_access_index: BlockAccessIndex,
    new_code: Bytes,
) -> None:
    """
    Add a code change to the block access list.

    Records contract code deployment or modification. This typically occurs
    during contract creation via [`CREATE`], [`CREATE2`], or [`SETCODE`]
    operations.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address receiving new code.
    block_access_index :
        The block access index for this change (0 for pre-execution,
        1..n for transactions, n+1 for post-execution).
    new_code :
        The deployed contract bytecode.

    [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create
    [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2

    """
    ensure_account(builder, address)

    # Check if we already have a code change for this block_access_index
    # This handles the case of in-transaction selfdestructs where code is
    # first deployed and then cleared in the same transaction
    existing_changes = builder.accounts[address].code_changes
    for i, existing in enumerate(existing_changes):
        if existing.block_access_index == block_access_index:
            # Replace the existing code change with the new one
            # For selfdestructs, this ensures we only record the final state (empty code)
            existing_changes[i] = CodeChange(
                block_access_index=block_access_index, new_code=new_code
            )
            return

    # No existing change for this block_access_index, add a new one
    change = CodeChange(
        block_access_index=block_access_index, new_code=new_code
    )
    builder.accounts[address].code_changes.append(change)

The SELFDESTRUCT Edge Case

The comment is explicit:

# This handles the case of in-transaction selfdestructs where code is
# first deployed and then cleared in the same transaction

Scenario:

// In the same TX:
// 1. Deploy contract at address X → code = 0x6080...
// 2. Call X.selfdestruct() → code = 0x (empty)

Both operations have the same block_access_index. The BAL should record code = 0x (the final state), not code = 0x6080....

Last-Write-Wins: Unlike nonce (highest-wins), code uses last-write-wins. The final code state is what matters.

SETCODE (EIP-7702)

The docstring mentions SETCODE. This is the EIP-7702 delegation operation that allows EOAs to temporarily set code. The code change format is:

# EIP-7702 delegation code
new_code = b'\xef\x01\x00' + delegate_address  # 23 bytes total

The builder doesn't care about the format—it just records whatever new_code it receives.


add_touched_account (lines 308-334)

def add_touched_account(
    builder: BlockAccessListBuilder, address: Address
) -> None:
    """
    Add an account that was accessed but not modified.

    Records that an account was accessed during execution without any state
    changes. This is used for operations like [`EXTCODEHASH`], [`BALANCE`],
    [`EXTCODESIZE`], and [`EXTCODECOPY`] that read account data without
    modifying it.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address that was accessed.

    [`EXTCODEHASH`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodehash
    [`BALANCE`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.balance
    [`EXTCODESIZE`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodesize
    [`EXTCODECOPY`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodecopy

    """
    ensure_account(builder, address)
Simplest function: Just ensure the account exists. No data to record. Why track touched accounts?

An account that was accessed but not modified still appears in the BAL. This enables:

  • Prefetching: Clients can load the account's state ahead of execution
  • Proof generation: State proofs need to include accessed accounts
  • Witness completeness: Stateless execution needs ALL accessed data
What operations touch without modifying?

OpcodeEffect
BALANCEReads balance only
EXTCODESIZEReads code size only
EXTCODEHASHReads code hash only
EXTCODECOPYReads code only
CALL (value=0, no storage)Touches callee but may not modify

Cross-References

  • Section 01 (RLP Types): The StorageChange, BalanceChange, NonceChange, CodeChange types used here
  • Section 03-05 (State Tracker): The frame-based recording that feeds StateChanges to the builder
  • Section 07 (Builder Finalization): The _build_from_builder and build_block_access_list functions that consume the builder
  • Section 09 (VM Integration): Where the EVM calls these recording functions

Gotchas

1. Same-Transaction Semantics Vary by Field

FieldSame-TX BehaviorWhy
StorageLast-write-winsFinal value matters
BalanceLast-write-winsFinal value matters
NonceHighest-winsNonces only increase
CodeLast-write-winsFinal code matters

2. Reads Are Not Per-Transaction

Storage reads go into a Set[U256], not a list of (index, slot) pairs. The BAL doesn't track which transaction read a slot—just that it was read.

3. Read/Write Filtering Is Deferred

A slot can be in BOTH storage_reads and storage_changes during collection. The filtering (exclude reads for written slots) happens in the build phase.

4. AccountData Is Mutable

Unlike the final AccountChanges (which uses tuples), AccountData uses mutable lists and dicts. This is intentional—collection needs mutation; finalization produces immutable output.

5. No Validation Here

The builder doesn't validate:

  • Whether block_access_index is in range
  • Whether new_code fits in MAX_CODE_SIZE
  • Whether the slot/balance/nonce values are valid
Validation happens at higher levels (block processing, RLP encoding).

6. Empty Accounts Are Still Tracked

An account touched but never modified results in an AccountData with all-empty collections. This is correct—the address still appears in the final BAL.


Complexity Summary

FunctionTime ComplexitySpace
ensure_accountO(1) amortizedO(1) per new account
add_storage_writeO(k) where k = writes to slotO(1) per new write
add_storage_readO(1) amortizedO(1) per new slot
add_balance_changeO(t) where t = txs touching accountO(1) per new tx
add_nonce_changeO(t)O(1) per new tx
add_code_changeO(t)O(1) per new tx
add_touched_accountO(1) amortizedO(1) per new account
All operations are effectively O(1) for typical blocks (few repeated accesses). The worst case (hot contract accessed by many transactions) is still linear in the number of accessing transactions, which is bounded by MAX_TXS.

Section 07: BAL Builder - Finalization

Source Files

  • specs/execution-specs/src/ethereum/forks/amsterdam/block_access_lists/builder.py (lines 336-475)

Overview

After block execution completes, the accumulated changes in the builder must be transformed into a deterministic BlockAccessList. This section covers the finalization phase:

  • Sorting: All entries are sorted to ensure consensus-critical determinism
  • Read/Write Filtering: Slots that were both read and written appear only in writes
  • Flattening: The nested AccountData structure becomes flat AccountChanges tuples
  • Integration: How StateChanges from the state tracker flows into the builder
The finalization is intentionally deferred until after all execution completes. This amortizes the O(n log n) sorting cost over the entire block rather than paying it per-transaction.

add_touched_account (lines 336-369)

def add_touched_account(
    builder: BlockAccessListBuilder, address: Address
) -> None:
    """
    Add an account that was accessed but not modified.

    Records that an account was accessed during execution without any state
    changes. This is used for operations like [`EXTCODEHASH`], [`BALANCE`],
    [`EXTCODESIZE`], and [`EXTCODECOPY`] that read account data without
    modifying it.

    Parameters
    ----------
    builder :
        The block access list builder instance.
    address :
        The account address that was accessed.

    [`EXTCODEHASH`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodehash
    [`BALANCE`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.balance
    [`EXTCODESIZE`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodesize
    [`EXTCODECOPY`] :
        ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodecopy

    """
    ensure_account(builder, address)
Purpose: Records address access without any state modification. The simplest recording function—it just ensures the account exists in the builder. Why track touched-but-unmodified accounts?

An account that was accessed but not modified still appears in the BAL. Consider:

# TX reads BALANCE of address X
# X's balance doesn't change
# But X must appear in BAL so stateless clients can prefetch X's state

Use cases for touched accounts:

  • Witness generation: Stateless execution needs all accessed data
  • State prefetching: Clients can load accounts before replay
  • Access pattern analysis: Understanding hot accounts without modifying them
Operations that touch without modifying:

OpcodeBehavior
BALANCEReads balance, no write
EXTCODESIZEReads code length
EXTCODEHASHReads code hash
EXTCODECOPYCopies code to memory
CALL (no effect)May touch callee without state change

_build_from_builder (lines 371-433)

This is the core finalization function. It transforms the mutable BlockAccessListBuilder into the immutable, sorted BlockAccessList.

def _build_from_builder(
    builder: BlockAccessListBuilder,
) -> BlockAccessList:
    """
    Build the final [`BlockAccessList`] from a builder (internal helper).

    Constructs a deterministic block access list by sorting all accumulated
    changes. The resulting list is ordered by:

    1. Account addresses (lexicographically)
    2. Within each account:
       - Storage slots (lexicographically)
       - Transaction indices (numerically) for each change type

    Parameters
    ----------
    builder :
        The block access list builder containing all tracked changes.

    Returns
    -------
    block_access_list :
        The final sorted and encoded block access list.

    [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList

    """

The Sorting Contract

The docstring specifies a three-level sorting hierarchy:

Level 1: Addresses (lexicographic)
    └── Level 2: Storage slots (lexicographic)
            └── Level 3: Transaction indices (numeric)

This ordering is consensus-critical. Every client must produce the exact same byte sequence. Different sort orders → different RLP encoding → different hash → consensus failure.

Implementation Walkthrough

Phase 1: Initialize and Iterate

block_access_list: BlockAccessList = []

    for address, changes in builder.accounts.items():

Iteration order of dict.items() is insertion order in Python 3.7+. But insertion order is execution order, which varies by transaction. We can't rely on it—hence the explicit sorting later.

Phase 2: Process Storage Changes

storage_changes = []
        for slot, slot_changes in changes.storage_changes.items():
            sorted_changes = tuple(
                sorted(slot_changes, key=lambda x: x.block_access_index)
            )
            storage_changes.append(
                SlotChanges(slot=slot, changes=sorted_changes)
            )

For each slot, sort changes by block_access_index (transaction number). This produces entries like:

Slot 0x01:
  - TX 2: value = 0xAAA
  - TX 5: value = 0xBBB
  - TX 8: value = 0xCCC
Why sort by index within each slot?

The BAL records state evolution. For witness verification, a client needs to:

  • Start with pre-block state
  • Apply TX 2's change
  • Apply TX 5's change
  • Apply TX 8's change
  • Verify post-block state
The order matters for correctness of incremental state proofs.

Phase 3: Filter and Sort Reads

storage_reads = []
        for slot in changes.storage_reads:
            if slot not in changes.storage_changes:
                storage_reads.append(slot)
Critical filtering: A slot that was BOTH read and written appears ONLY in storage_changes, not in storage_reads. Why?
  • Non-redundancy: If you have the write history, you implicitly know the slot was accessed
  • Smaller encoding: No need to list the slot twice
  • Spec requirement: EIP-7928 mandates this behavior
Example:
# TX flow:
SLOAD(slot)   # recorded as read
SSTORE(slot)  # recorded as write
SLOAD(slot)   # redundant read (already have write)

# Result: slot appears in storage_changes only
# storage_reads does NOT include this slot
Edge case: Failed inner call
# TX flow:
SLOAD(slot)           # recorded as read
CALL(target) →
    SSTORE(slot)      # recorded as write in child frame
    REVERT            # write discarded, converted to read
# Result: slot appears in storage_reads (write was reverted)

The state tracker (Section 05) handles this via merge_on_failure, converting writes to reads. By the time data reaches the builder, this conversion already happened.

Phase 4: Sort All Change Lists

balance_changes = tuple(
            sorted(changes.balance_changes, key=lambda x: x.block_access_index)
        )
        nonce_changes = tuple(
            sorted(changes.nonce_changes, key=lambda x: x.block_access_index)
        )
        code_changes = tuple(
            sorted(changes.code_changes, key=lambda x: x.block_access_index)
        )

        storage_changes.sort(key=lambda x: x.slot)
        storage_reads.sort()

Each change type is sorted by its respective ordering key:

  • Changes within a field type: by block_access_index (numeric)
  • Slots: by slot value (lexicographic on U256)
  • Reads: by slot value (lexicographic)
Why tuple() for inner changes but list.sort() for slots?

  • Inner changes are small (typically 1-5 entries per slot/field)
  • Slots can be numerous (DeFi contract might touch 100+ slots)
  • list.sort() is in-place, avoiding allocation

Phase 5: Assemble AccountChanges

account_change = AccountChanges(
            address=address,
            storage_changes=tuple(storage_changes),
            storage_reads=tuple(storage_reads),
            balance_changes=balance_changes,
            nonce_changes=nonce_changes,
            code_changes=code_changes,
        )

        block_access_list.append(account_change)

Everything is converted to tuple for immutability. The AccountChanges dataclass (from rlp_types.py) uses tuples for all collection fields.

Phase 6: Sort by Address

block_access_list.sort(key=lambda x: x.address)

    return block_access_list

Final sort: order all accounts lexicographically by address. This is the top-level determinism guarantee.

Address sorting is byte-lexicographic: Address 0x0000...0001 comes before 0x0000...0002. This is natural Python bytes comparison.

Complexity Analysis

OperationComplexity
Iterate accountsO(A) where A = unique addresses
Sort slot changesO(S × T log T) where S = slots, T = txs per slot
Filter readsO(R) where R = read slots
Sort storage_changesO(S log S)
Sort storage_readsO(R log R)
Sort balance/nonce/codeO(T log T) each
Final address sortO(A log A)
Total: O(A × (S log S + T log T)) for typical blocks

For a worst-case block (many accounts, many slots):

  • A = 10,000 addresses
  • S = 10 slots per address average
  • T = 2 txs per slot average
This is ~10,000 × (10 log 10 + 2 log 2) ≈ 350,000 comparisons. Trivial for modern hardware.


build_block_access_list (lines 435-475)

The public entry point. Converts StateChanges from the state tracker into a BlockAccessList.

def build_block_access_list(
    state_changes: "StateChanges",
) -> BlockAccessList:
    """
    Build a [`BlockAccessList`] from a StateChanges frame.

    Converts the accumulated state changes from the frame-based architecture
    into the final deterministic BlockAccessList format.

    Parameters
    ----------
    state_changes :
        The block-level StateChanges frame containing all changes from the block.

    Returns
    -------
    block_access_list :
        The final sorted and encoded block access list.

    [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList
    [`StateChanges`]: ref:ethereum.forks.amsterdam.state_tracker.StateChanges

    """

StateChanges Structure (from state_tracker.py)

The input StateChanges has this shape after block execution:

@dataclass
class StateChanges:
    touched_addresses: Set[Address]
    storage_reads: Set[Tuple[Address, Bytes32]]
    storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256]
    balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256]
    nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]]
    code_changes: Dict[Tuple[Address, BlockAccessIndex], Bytes]
    # ... pre-state captures (not used in build)

Key observations:

  • Composite keys: Writes are keyed by (address, slot/field, tx_index)
  • Already filtered: Net-zero changes were removed by filter_net_zero_frame_changes (Section 05)
  • Flat structure: Frame hierarchy collapsed; this is block-level accumulation

Implementation Walkthrough

Step 1: Initialize Builder

builder = BlockAccessListBuilder()

Fresh builder. We don't reuse builders across blocks.

Step 2: Add Touched Addresses

# Add all touched addresses
    for address in state_changes.touched_addresses:
        add_touched_account(builder, address)

Every address that was accessed (even without modification) gets an entry.

Order of operations matters: We add touched addresses first because subsequent add_* calls use ensure_account, which is idempotent. But if we add specific changes first, those addresses would already exist. Either order works; this is just cleaner.

Step 3: Add Storage Reads

# Add all storage reads
    for address, slot in state_changes.storage_reads:
        add_storage_read(builder, address, U256(int.from_bytes(slot)))
Type conversion: slot is Bytes32 in the state tracker but U256 in the builder. The conversion U256(int.from_bytes(slot)) handles this. Why different types?
  • State tracker uses Bytes32 because storage keys are 32-byte hashes
  • Builder uses U256 because that's how slots are encoded in RLP
  • Both represent the same value, different representations

Step 4: Add Storage Writes

# Add all storage writes
    # Net-zero filtering happens at transaction commit time, not here.
    # At block level, we track ALL writes at their respective indices.
    for (
        address,
        slot,
        block_access_index,
    ), value in state_changes.storage_writes.items():
        u256_slot = U256(int.from_bytes(slot))
        add_storage_write(
            builder, address, u256_slot, block_access_index, value
        )
Critical comment: "Net-zero filtering happens at transaction commit time, not here."

The state tracker's filter_net_zero_frame_changes (Section 05) already removed writes where pre_value == post_value. By the time we're here, all remaining writes are actual state changes.

Why not filter here?
  • Separation of concerns: State tracker knows pre-state; builder doesn't
  • Performance: Filter once at commit, not multiple times
  • Correctness: Pre-state must be compared at transaction boundary, not block boundary
Example of why transaction-level matters:
TX 1: slot 0x01: 0 → 5
TX 2: slot 0x01: 5 → 0

Net result: 0 → 0 (no change at block level). But we DO want to record both changes because:

  • Intermediate state (after TX 1) was slot = 5
  • This matters for state proofs at different block heights
  • Filtering at block level would incorrectly eliminate both
So we filter at transaction level (TX 1: keep, TX 2: keep because 5 ≠ 0), not block level.

Step 5: Add Balance Changes

# Add all balance changes (balance_changes is keyed by (address, index))
    for (
        address,
        block_access_index,
    ), new_balance in state_changes.balance_changes.items():
        add_balance_change(builder, address, block_access_index, new_balance)

Balance changes are keyed by (address, block_access_index). Each transaction that modifies an account's balance gets one entry per transaction.

Common pattern: COINBASE has a balance change entry for every transaction (receives priority fees).

Step 6: Add Nonce Changes

# Add all nonce changes
    for address, block_access_index, new_nonce in state_changes.nonce_changes:
        add_nonce_change(builder, address, block_access_index, new_nonce)

Nonces are stored as a Set of tuples (address, index, new_nonce) rather than a Dict. This is because add_nonce_change uses highest-wins semantics internally.

Why a Set?

Multiple nonce changes within a transaction (e.g., CREATE → CREATE → CREATE) would overwrite in a Dict. A Set preserves all observations, letting add_nonce_change pick the highest.

Actually, looking at the code more carefully: the Set design allows multiple (address, index, nonce) tuples where nonce differs. The add_nonce_change function handles deduplication by keeping the highest nonce per (address, index).

Step 7: Add Code Changes

# Add all code changes
    # Filtering happens at transaction level in eoa_delegation.py
    for (
        address,
        block_access_index,
    ), new_code in state_changes.code_changes.items():
        add_code_change(builder, address, block_access_index, new_code)
Comment note: "Filtering happens at transaction level in eoa_delegation.py"

This refers to EIP-7702 delegation code. When an EOA sets delegation code, certain filtering rules apply:

  • Temporary delegation shouldn't persist
  • Same-transaction removal shouldn't appear in BAL
The eoa_delegation.py module handles these rules before data reaches the state tracker.

Step 8: Build and Return

return _build_from_builder(builder)

Delegate to the internal helper for sorting and finalization.


Data Flow Summary

┌─────────────────────────────────────────────────────────────────────┐
│                        Block Execution                               │
├─────────────────────────────────────────────────────────────────────┤
│  TX 1 Frame                                                          │
│    ├── SLOAD, SSTORE, CALL...                                       │
│    ├── track_storage_read, track_storage_write                      │
│    └── filter_net_zero_frame_changes → commit_transaction_frame     │
│                                                                      │
│  TX 2 Frame                                                          │
│    ├── ...                                                          │
│    └── filter_net_zero_frame_changes → commit_transaction_frame     │
│                                                                      │
│  ... more transactions ...                                          │
│                                                                      │
│  Block Frame (accumulated StateChanges)                              │
│    ├── touched_addresses: {0xA, 0xB, 0xC}                           │
│    ├── storage_writes: {(0xA, slot, 1): val, (0xB, slot, 2): val}  │
│    └── balance_changes: {(0xA, 1): 100, (0xA, 2): 150}             │
└─────────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    build_block_access_list                           │
├─────────────────────────────────────────────────────────────────────┤
│  1. Create BlockAccessListBuilder                                    │
│  2. add_touched_account for each address                            │
│  3. add_storage_read for each (addr, slot)                          │
│  4. add_storage_write for each (addr, slot, idx) → value            │
│  5. add_balance_change for each (addr, idx) → balance               │
│  6. add_nonce_change for each (addr, idx, nonce)                    │
│  7. add_code_change for each (addr, idx) → code                     │
└─────────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      _build_from_builder                             │
├─────────────────────────────────────────────────────────────────────┤
│  For each address in builder.accounts:                               │
│    1. Sort storage changes by slot, then by index                   │
│    2. Filter reads (exclude slots with writes)                       │
│    3. Sort balance/nonce/code changes by index                      │
│    4. Create AccountChanges tuple                                    │
│                                                                      │
│  Sort all AccountChanges by address                                  │
│  Return BlockAccessList                                              │
└─────────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                         BlockAccessList                              │
│   [AccountChanges(0xA, ...), AccountChanges(0xB, ...), ...]         │
│   Deterministically sorted, ready for RLP encoding                   │
└─────────────────────────────────────────────────────────────────────┘

Cross-References

SectionRelevance
Section 01 (RLP Types)AccountChanges, SlotChanges, StorageChange types used in output
Section 02 (RLP Utils)Encoding functions that consume the BlockAccessList
Section 05 (State Tracker Frames)filter_net_zero_frame_changes that cleans data before build
Section 06 (Builder Construction)Recording functions that populate the builder
Section 08 (Block Processing)Where build_block_access_list is called and hash is computed

Gotchas

1. Net-Zero Filtering Already Happened

Don't expect build_block_access_list to filter net-zero changes. That happened in filter_net_zero_frame_changes at transaction commit time. All data arriving here represents actual state modifications.

2. Read/Write Filtering Happens in _build_from_builder

The exclusion of reads for written slots is NOT done during recording. It's done during finalization:

if slot not in changes.storage_changes:
    storage_reads.append(slot)

This is correct because:

  • Recording doesn't know what will be written later
  • Filtering during finalization sees the complete picture

3. Type Conversions (Bytes32 ↔ U256)

Storage slots are Bytes32 in the state tracker but U256 in the builder. The conversion happens in build_block_access_list:

u256_slot = U256(int.from_bytes(slot))

Both represent the same 256-bit value. The RLP encoding uses U256's numeric representation.

4. Sorting Is Consensus-Critical

Every client MUST produce the exact same sorted order. The sort keys are:

  • Addresses: lexicographic (Python bytes comparison)
  • Slots: numeric (U256 comparison)
  • Indices: numeric (BlockAccessIndex comparison)
Python's sorted() and list.sort() are stable, but stability doesn't matter here—keys are unique at each level.

5. Empty Accounts Are Preserved

An account that was touched but has no changes still appears in the BAL:

AccountChanges(
    address=0xDEAD...,
    storage_changes=(),
    storage_reads=(),
    balance_changes=(),
    nonce_changes=(),
    code_changes=(),
)

This is intentional—the address was accessed, so it's part of the witness.

6. BlockAccessIndex 0 Is Pre-Execution

System calls before transaction execution (e.g., beacon root update) use index 0. Regular transactions use indices 1..n. Post-execution calls use n+1.

The builder doesn't care about this—it just records whatever indices it receives. But the sorting ensures pre-execution changes come first.

7. Nonce Changes Use Set, Not Dict

Unlike other changes, nonce_changes is a Set[Tuple[Address, BlockAccessIndex, U64]] not a Dict. This preserves multiple observations of nonce changes within a transaction, allowing add_nonce_change to select the highest.


Performance Considerations

Why Defer Sorting?

Recording during execution is hot path—every SLOAD, SSTORE, balance transfer triggers it. Sorting is expensive (O(n log n)). Deferring sorting to finalization means:

  • Recording: O(1) amortized (append/set-add)
  • Finalization: O(n log n) once, after all execution
Alternative (sort on insert) would be O(log n) per insert × millions of inserts = much slower.

Memory Profile

For a block with:

  • 10,000 touched accounts
  • 50,000 total storage accesses
  • 15,000 balance changes (including COINBASE)
Memory usage:
  • Builder dict overhead: ~1-2 MB
  • AccountData per account: ~600 bytes × 10,000 = ~6 MB
  • Total: ~10-15 MB
This is acceptable for modern clients. The finalization step doesn't allocate additional large structures—it creates tuples from existing data.

Parallelization Opportunity

The current implementation is single-threaded. A future optimization could:

  • Shard accounts by address prefix
  • Sort each shard in parallel
  • Merge sorted shards
This would help for blocks with 100k+ touched accounts (extreme DeFi activity).


Testing Considerations

Determinism Tests

The sorting must be deterministic. Test cases should:

  • Build BAL from scrambled input
  • Verify output matches expected sorted order
  • Verify RLP encoding matches expected bytes

Read/Write Exclusivity Tests

# Test: slot written → not in reads
builder.add_storage_read(addr, slot)
builder.add_storage_write(addr, slot, idx, value)
bal = _build_from_builder(builder)
assert slot not in bal[0].storage_reads
assert any(sc.slot == slot for sc in bal[0].storage_changes)

Empty Account Tests

# Test: touched-only account appears with empty changes
builder.add_touched_account(addr)
bal = _build_from_builder(builder)
assert len(bal) == 1
assert bal[0].address == addr
assert bal[0].storage_changes == ()

Multi-Transaction Tests

# Test: changes from different TXs are preserved and sorted
builder.add_storage_write(addr, slot, idx=3, value=100)
builder.add_storage_write(addr, slot, idx=1, value=50)
bal = _build_from_builder(builder)
changes = bal[0].storage_changes[0].changes
assert changes[0].block_access_index == 1  # sorted
assert changes[1].block_access_index == 3

Section 08: Block Processing

Source Files

  • specs/execution-specs/src/ethereum/forks/amsterdam/blocks.py (lines 1-417)
  • specs/execution-specs/src/ethereum/forks/amsterdam/fork.py (lines 207-1180)
  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/__init__.py (lines 36-97)

Overview

Block processing is where EIP-7928 becomes consensus-critical. This section covers:

  • Header Extension: The new block_access_list_hash field in the block header
  • State Transition: How blocks are validated, including BAL hash verification
  • Block Execution: The apply_body function orchestrating BAL construction
  • Transaction Processing: How each transaction contributes to the BAL
  • Post-Execution Operations: Withdrawals and system transactions
The key insight: the BAL is a commitment, not an input. It's computed deterministically from execution and then validated against the header. Any mismatch invalidates the entire block.

Block Header Extension

Header.block_access_list_hash (blocks.py, lines 234-254)

@slotted_freezable
@dataclass
class Header:
    """
    Header portion of a block on the chain, containing metadata and
    cryptographic commitments to the block's contents.
    """
    # ... 17 existing fields ...
    
    parent_beacon_block_root: Root
    """
    Root hash of the corresponding beacon chain block.
    """

    requests_hash: Hash32
    """
    [SHA2-256] hash of all the collected requests in this block. Introduced in
    [EIP-7685]. See [`compute_requests_hash`][crh] for more details.
    """

    block_access_list_hash: Hash32
    """
    [`keccak256`] hash of the Block Access List containing all accounts and
    storage locations accessed during block execution. Introduced in
    [EIP-7928]. See [`compute_block_access_list_hash`][cbalh] for more
    details.

    [`keccak256`]: ref:ethereum.crypto.hash.keccak256
    [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928
    [cbalh]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_utils.compute_block_access_list_hash
    """
Purpose: Commits the block to a specific access pattern. Why keccak256?

The BAL hash uses keccak256 (not SHA2-256 like requests_hash). This is intentional:

  • Consistency: keccak256 is the primary hash function in the EL
  • EVM compatibility: Solidity's keccak256 can verify proofs on-chain
  • Legacy alignment: State roots, transaction roots, receipts root all use keccak256
Position in Header

The field is added after requests_hash (the EIP-7685 field), maintaining chronological ordering of EIP additions:

  • Pre-merge fields (parent_hash through nonce)
  • EIP-1559: base_fee_per_gas
  • EIP-4895: withdrawals_root
  • EIP-4844: blob_gas_used, excess_blob_gas
  • EIP-4788: parent_beacon_block_root
  • EIP-7685: requests_hash
  • EIP-7928: block_access_list_hash
RLP Encoding Implications

Adding a field to the header changes:

  • RLP encoding of headers
  • Block hash computation (keccak256(rlp.encode(header)))
  • All existing tooling that parses headers
This is why the change requires a hard fork—older clients reject blocks with extra header fields.


Block Output Structure

BlockOutput (vm/__init__.py, lines 56-97)

@dataclass
class BlockOutput:
    """
    Output from applying the block body to the present state.

    Contains the following:
    # ... documentation ...
    block_access_list: `BlockAccessList`
        The block access list for the block.
    """

    block_gas_used: Uint = Uint(0)
    transactions_trie: Trie[Bytes, Optional[Bytes | LegacyTransaction]] = (
        field(default_factory=lambda: Trie(secured=False, default=None))
    )
    receipts_trie: Trie[Bytes, Optional[Bytes | Receipt]] = field(
        default_factory=lambda: Trie(secured=False, default=None)
    )
    receipt_keys: Tuple[Bytes, ...] = field(default_factory=tuple)
    block_logs: Tuple[Log, ...] = field(default_factory=tuple)
    withdrawals_trie: Trie[Bytes, Optional[Bytes | Withdrawal]] = field(
        default_factory=lambda: Trie(secured=False, default=None)
    )
    blob_gas_used: U64 = U64(0)
    requests: List[Bytes] = field(default_factory=list)
    block_access_list: BlockAccessList = field(default_factory=list)
Purpose: Accumulates all outputs from block execution. Why BlockAccessList is in BlockOutput:

The BAL is a derived artifact, not an input. It's computed as a side effect of execution and then validated. This differs from transaction-level access lists (EIP-2930), which are inputs to execution.

ComparisonTX Access List (EIP-2930)Block Access List (EIP-7928)
DirectionInputOutput
PurposeWarm addresses before executionRecord all accessed state
ValidationPre-execution gas checkPost-execution hash match
CompletenessOptional (optimization)Complete (all accesses)

BlockEnvironment with State Tracking

BlockEnvironment (vm/__init__.py, lines 36-52)

@dataclass
class BlockEnvironment:
    """
    Items external to the virtual machine itself, provided by the environment.
    """

    chain_id: U64
    state: State
    block_gas_limit: Uint
    block_hashes: List[Hash32]
    coinbase: Address
    number: Uint
    base_fee_per_gas: Uint
    time: U256
    prev_randao: Bytes32
    excess_blob_gas: U64
    parent_beacon_block_root: Hash32
    state_changes: StateChanges
Purpose: Provides block-scoped context for execution. The state_changes field:

This is the block-level frame for state tracking. It's initialized in state_transition and passed through all execution:

# In state_transition (fork.py line 244)
block_env = vm.BlockEnvironment(
    # ... other fields ...
    state_changes=StateChanges(),  # Fresh block frame
)

Every transaction, system call, and withdrawal operation creates child frames from this root. At the end of execution, build_block_access_list aggregates all recorded changes from this hierarchy.


State Transition Function

state_transition (fork.py, lines 207-290)

This is the main entry point for block validation and application.

def state_transition(chain: BlockChain, block: Block) -> None:
    """
    Attempts to apply a block to an existing block chain.

    All parts of the block's contents need to be verified before being added
    to the chain. Blocks are verified by ensuring that the contents of the
    block make logical sense with the contents of the parent block. The
    information in the block's header must also match the corresponding
    information in the block.
    """
    if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE:
        raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE")

    validate_header(chain, block.header)
    if block.ommers != ():
        raise InvalidBlock
Early Validation: Before executing anything:
  • Check RLP size limit (DoS protection)
  • Validate header consistency (parent hash, timestamps, gas limits)
  • Reject ommer blocks (post-merge)
block_env = vm.BlockEnvironment(
        chain_id=chain.chain_id,
        state=chain.state,
        block_gas_limit=block.header.gas_limit,
        block_hashes=get_last_256_block_hashes(chain),
        coinbase=block.header.coinbase,
        number=block.header.number,
        base_fee_per_gas=block.header.base_fee_per_gas,
        time=block.header.timestamp,
        prev_randao=block.header.prev_randao,
        excess_blob_gas=block.header.excess_blob_gas,
        parent_beacon_block_root=block.header.parent_beacon_block_root,
        state_changes=StateChanges(),
    )
Block Environment Setup: The fresh StateChanges() is the root of the frame hierarchy for this block. All state changes will be tracked here.
block_output = apply_body(
        block_env=block_env,
        transactions=block.transactions,
        withdrawals=block.withdrawals,
    )
Execute Block Body: This is where all the actual execution happens. The BAL is constructed as a side effect.
# ... compute various roots and hashes ...
    
    computed_block_access_list_hash = compute_block_access_list_hash(
        block_output.block_access_list
    )
Compute BAL Hash: After execution, serialize the BAL and hash it.
# ... validate gas_used, transactions_root, state_root, etc. ...
    
    if computed_block_access_list_hash != block.header.block_access_list_hash:
        raise InvalidBlock("Invalid block access list hash")
The Critical Check: If the computed BAL hash doesn't match the header, the block is invalid. This makes the BAL consensus-critical. Why this ordering matters:

The BAL hash is checked after state root and other validations. This isn't arbitrary:

  • If state root is wrong, the block is invalid regardless of BAL
  • If BAL is wrong but state root is correct, it's a commitment failure
  • Checking BAL after state root makes debugging easier
Cross-reference: See Section 02 (compute_block_access_list_hash) for hash computation details.


Block Body Execution

apply_body (fork.py, lines 770-838)

This function orchestrates the entire block execution.

def apply_body(
    block_env: vm.BlockEnvironment,
    transactions: Tuple[LegacyTransaction | Bytes, ...],
    withdrawals: Tuple[Withdrawal, ...],
) -> vm.BlockOutput:
    """
    Executes a block.

    Many of the contents of a block are stored in data structures called
    tries. There is a transactions trie which is similar to a ledger of the
    transactions stored in the current block. There is also a receipts trie
    which stores the results of executing a transaction, like the post state
    and gas used. This function creates and executes the block that is to be
    added to the chain.
    """
    block_output = vm.BlockOutput()

    # EIP-7928: System contracts use block_access_index 0
    # The block frame already starts at index 0, so system transactions
    # naturally use that index through the block frame
Index 0 for System Transactions: Pre-execution system calls (beacon roots, history storage) run at block_access_index = 0. This is before any user transactions.
process_unchecked_system_transaction(
        block_env=block_env,
        target_address=BEACON_ROOTS_ADDRESS,
        data=block_env.parent_beacon_block_root,
    )

    process_unchecked_system_transaction(
        block_env=block_env,
        target_address=HISTORY_STORAGE_ADDRESS,
        data=block_env.block_hashes[-1],  # The parent hash
    )
Pre-Execution System Calls: These are required by:
  • EIP-4788: Beacon block roots in the EVM
  • EIP-2935: Historical block hashes
Both modify state and contribute to the BAL at index 0.
for i, tx in enumerate(map(decode_transaction, transactions)):
        process_transaction(block_env, block_output, tx, Uint(i))
User Transactions: Each transaction gets its own frame. The index passed here becomes the block_access_index via increment_block_access_index.
# EIP-7928: Increment block frame to post-execution index
    # After N transactions, block frame is at index N
    # Post-execution operations (withdrawals, etc.) use index N+1
    increment_block_access_index(block_env.state_changes)
Post-Execution Index: After all N transactions, the index is incremented to N+1 for withdrawals and other post-tx operations. This ensures proper attribution.
PhaseIndexOperations
Pre-execution0Beacon roots, history storage
Transaction ii+1User transaction (0-indexed → 1-indexed in BAL)
Post-executionN+1Withdrawals, consolidation requests
Wait, there's an offset? Yes! System transactions run at index 0, but user transaction indices are 0-based in the block. So transaction 0 runs at block_access_index = 1. This is handled by increment_block_access_index being called at the start of process_transaction.
process_withdrawals(block_env, block_output, withdrawals)

    process_general_purpose_requests(
        block_env=block_env,
        block_output=block_output,
    )
Post-Transaction Operations: Both withdrawals and requests (EIP-7685) run at index N+1.
# Build block access list from block_env.state_changes
    block_output.block_access_list = build_block_access_list(
        block_env.state_changes
    )

    return block_output
Finalization: Call the builder to convert StateChanges into the final BlockAccessList. Cross-reference: See Section 06-07 for build_block_access_list implementation.

Transaction Processing

process_transaction (fork.py, lines 880-1080)

This is where most BAL entries originate.

def process_transaction(
    block_env: vm.BlockEnvironment,
    block_output: vm.BlockOutput,
    tx: Transaction,
    index: Uint,
) -> None:
    """
    Execute a transaction against the provided environment.

    This function processes the actions needed to execute a transaction.
    It decrements the sender's account balance after calculating the gas fee
    and refunds them the proper amount after execution.
    """
    # EIP-7928: Create a transaction-level StateChanges frame
    # The frame will read the current block_access_index from the block frame
    increment_block_access_index(block_env.state_changes)
    tx_state_changes = create_child_frame(block_env.state_changes)
Frame Creation:
  • Increment the block's access index (for attribution)
  • Create a child frame that inherits the current index
Why increment first? Consider transaction 0:
  • increment_block_access_index moves from 0 → 1
  • Child frame inherits index 1
  • Transaction 0's changes are attributed to index 1
This leaves index 0 for system transactions.
# Capture coinbase pre-balance for net-zero filtering
    coinbase_pre_balance = get_account(
        block_env.state, block_env.coinbase
    ).balance
    track_address(tx_state_changes, block_env.coinbase)
    capture_pre_balance(
        tx_state_changes, block_env.coinbase, coinbase_pre_balance
    )
Pre-State Capture for Coinbase:

The coinbase receives priority fees. To detect net-zero changes (e.g., coinbase sends a transaction and pays fees equal to what it receives), we must capture pre-balance.

Example scenario:
Coinbase balance: 10 ETH
Coinbase sends TX, pays 1 ETH in gas
TX tips coinbase 1 ETH
Final balance: 10 ETH (net-zero!)

Without pre-balance capture, we'd record coinbase as modified. With it, the net-zero filter removes coinbase from BAL.

# ... transaction validation and gas accounting ...
    
    # Track sender nonce increment
    increment_nonce(block_env.state, sender)
    sender_nonce_after = get_account(block_env.state, sender).nonce
    track_nonce_change(tx_state_changes, sender, U64(sender_nonce_after))
Nonce Tracking: Every transaction increments sender nonce. This is recorded explicitly.
# Track sender balance deduction for gas fee
    sender_balance_before = get_account(block_env.state, sender).balance
    track_address(tx_state_changes, sender)
    capture_pre_balance(tx_state_changes, sender, sender_balance_before)

    sender_balance_after_gas_fee = (
        Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee
    )
    set_account_balance(
        block_env.state, sender, U256(sender_balance_after_gas_fee)
    )
    track_balance_change(
        tx_state_changes,
        sender,
        U256(sender_balance_after_gas_fee),
    )
Gas Fee Deduction:
  • Capture sender's pre-balance
  • Deduct gas fee from balance
  • Track the new balance
The pre-balance capture enables net-zero detection for self-pays (sender pays gas but receives value equal to gas).
# ... create tx_env and message, execute ...
    
    tx_output = process_message_call(message)
EVM Execution: This is where the bulk of state changes happen. The message carries a call-level frame that's a child of tx_state_changes. Cross-reference: See Section 09 for VM integration details.
# ... gas refund calculation ...
    
    # refund gas
    sender_balance_after_refund = get_account(
        block_env.state, sender
    ).balance + U256(gas_refund_amount)
    set_account_balance(block_env.state, sender, sender_balance_after_refund)
    track_balance_change(
        tx_env.state_changes,
        sender,
        sender_balance_after_refund,
    )

    coinbase_balance_after_mining_fee = get_account(
        block_env.state, block_env.coinbase
    ).balance + U256(transaction_fee)

    set_account_balance(
        block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee
    )
    track_balance_change(
        tx_env.state_changes,
        block_env.coinbase,
        coinbase_balance_after_mining_fee,
    )
Post-Execution Tracking:
  • Track gas refund to sender
  • Track priority fee to coinbase
These are explicit balance changes outside the EVM proper.
for address in tx_output.accounts_to_delete:
        destroy_account(block_env.state, address)
        track_selfdestruct(tx_env.state_changes, address)

    # EIP-7928: Commit transaction frame (includes net-zero filtering).
    # Must happen AFTER destroy_account so filtering sees correct state.
    commit_transaction_frame(tx_env.state_changes)
Frame Commit:
  • Process SELFDESTRUCT accounts (deprecated but still tracked)
  • Commit the transaction frame to the block frame
Order matters: commit_transaction_frame includes net-zero filtering. If we committed before destroy_account, we'd see the pre-destruction balance as "unchanged" and filter it out incorrectly.

Withdrawal Processing

process_withdrawals (fork.py, lines 1083-1122)

Withdrawals are post-transaction balance credits from the consensus layer.

def process_withdrawals(
    block_env: vm.BlockEnvironment,
    block_output: vm.BlockOutput,
    withdrawals: Tuple[Withdrawal, ...],
) -> None:
    """
    Increase the balance of the withdrawing account.
    """
    # Capture pre-state for withdrawal balance filtering
    withdrawal_addresses = {wd.address for wd in withdrawals}
    for address in withdrawal_addresses:
        pre_balance = get_account(block_env.state, address).balance
        track_address(block_env.state_changes, address)
        capture_pre_balance(block_env.state_changes, address, pre_balance)
Pre-State Capture:

Why capture all addresses before processing any? Consider:

Withdrawal 1: 1 ETH to address A
Withdrawal 2: 1 ETH to address A

If we captured A's balance before withdrawal 1, then processed it, then captured before withdrawal 2, we'd capture the intermediate balance, not the pre-block balance. By gathering all addresses first, we capture true pre-state.

def increase_recipient_balance(recipient: Account) -> None:
        recipient.balance += wd.amount * U256(10**9)

    for i, wd in enumerate(withdrawals):
        trie_set(
            block_output.withdrawals_trie,
            rlp.encode(Uint(i)),
            rlp.encode(wd),
        )

        modify_state(block_env.state, wd.address, increase_recipient_balance)

        new_balance = get_account(block_env.state, wd.address).balance
        track_balance_change(
            block_env.state_changes,
            wd.address,
            new_balance,
        )
Balance Credit: Withdrawal amounts are in Gwei; multiply by 10^9 for Wei.
if account_exists_and_is_empty(block_env.state, wd.address):
            destroy_account(block_env.state, wd.address)

    # EIP-7928: Filter net-zero balance changes for withdrawals
    filter_net_zero_frame_changes(block_env.state_changes)
Net-Zero Filtering: Called on the block frame directly (not a child frame). This handles the case where withdrawals net to zero, though this is rare in practice. Difference from transaction commits: Transactions use commit_transaction_frame which merges and filters. Withdrawals use filter_net_zero_frame_changes directly because they operate on the block frame.

System Transaction Processing

process_system_transaction (fork.py, lines 610-680)

System transactions are internal calls that don't originate from a user signature.

def process_system_transaction(
    block_env: vm.BlockEnvironment,
    target_address: Address,
    system_contract_code: Bytes,
    data: Bytes,
) -> MessageCallOutput:
    """
    Process a system transaction with the given code.
    """
    # EIP-7928: Create a child frame for system transaction
    # This allows proper pre-state capture for net-zero filtering
    system_tx_state_changes = create_child_frame(block_env.state_changes)
Child Frame for System TX: Even system transactions get their own frame. This ensures:
  • Changes are attributed to the correct index (0 for pre-execution)
  • Net-zero filtering works within each system call
tx_env = vm.TransactionEnvironment(
        origin=SYSTEM_ADDRESS,
        gas_price=block_env.base_fee_per_gas,
        gas=SYSTEM_TRANSACTION_GAS,
        access_list_addresses=set(),
        access_list_storage_keys=set(),
        transient_storage=TransientStorage(),
        blob_versioned_hashes=(),
        authorizations=(),
        index_in_block=None,
        tx_hash=None,
        state_changes=system_tx_state_changes,
    )
System Address: 0xfffffffffffffffffffffffffffffffffffffffe — a special address that cannot have a private key (reserved address space). Unlimited Gas: SYSTEM_TRANSACTION_GAS = 30_000_000 — enough for any system call. No index or hash: System transactions aren't user-visible transactions; they have None for index and hash.
# Create call frame as child of tx frame
    call_frame = create_child_frame(tx_env.state_changes)

    system_tx_message = Message(
        # ... populate message fields ...
        state_changes=call_frame,
    )

    system_tx_output = process_message_call(system_tx_message)

    # Commit system transaction changes to block frame
    # System transactions always succeed (or block is invalid)
    commit_transaction_frame(tx_env.state_changes)

    return system_tx_output
Always Commit: System transactions always commit (unchecked) or raise InvalidBlock (checked). There's no rollback path within a single system call.

Validation Sequence Summary

The complete validation flow:

1. validate_header(chain, block.header)
   ├── Check parent hash
   ├── Check timestamps
   ├── Check gas limits
   ├── Check excess_blob_gas
   └── Check base_fee_per_gas

2. apply_body(block_env, transactions, withdrawals)
   ├── System transactions (index 0)
   │   ├── Beacon roots contract
   │   └── History storage contract
   ├── User transactions (indices 1..N)
   │   └── Each creates/commits transaction frame
   ├── Withdrawals (index N+1)
   └── Requests (index N+1)

3. Compute derived values
   ├── block_state_root
   ├── transactions_root
   ├── receipt_root
   ├── withdrawals_root
   ├── requests_hash
   └── computed_block_access_list_hash ← NEW

4. Validate commitments
   ├── gas_used == header.gas_used
   ├── transactions_root == header.transactions_root
   ├── state_root == header.state_root
   ├── receipt_root == header.receipt_root
   ├── bloom == header.bloom
   ├── withdrawals_root == header.withdrawals_root
   ├── blob_gas_used == header.blob_gas_used
   ├── requests_hash == header.requests_hash
   └── block_access_list_hash == header.block_access_list_hash ← NEW

5. If all valid: chain.blocks.append(block)

Gotchas and Edge Cases

Index Attribution

Gotcha: User transaction 0 uses block_access_index = 1, not 0.

The increment_block_access_index call at the start of process_transaction increments from 0 to 1 before the first user transaction. Index 0 is reserved for system transactions.

Coinbase Edge Cases

Edge Case 1: Coinbase is the sender.
  • Pre-balance captured for gas deduction
  • Priority fee added back to coinbase
  • Net effect may be zero (no BAL entry)
Edge Case 2: Coinbase doesn't exist.
  • If coinbase receives zero fees and is empty, it's destroyed
  • account_exists_and_is_empty check handles this

Withdrawal Batching

Gotcha: Multiple withdrawals to same address use single pre-balance capture.
withdrawal_addresses = {wd.address for wd in withdrawals}
for address in withdrawal_addresses:
    pre_balance = get_account(...)

The set deduplication ensures we capture pre-state once, not per-withdrawal.

System Transaction Failures

Checked vs. Unchecked:
  • process_unchecked_system_transaction: Returns output even if execution fails
  • process_checked_system_transaction: Raises InvalidBlock on failure
Beacon roots and history storage are unchecked (empty code is OK early in the chain). Withdrawal and consolidation requests are checked.

Cross-References

TopicSection
StateChanges data structureSection 03
Frame management (create/commit)Section 05
Builder constructionSection 06
Builder finalizationSection 07
VM integrationSection 09
Hash computationSection 02

Implementation Notes

Why the BAL is Validated Last

The InvalidBlock exception for BAL mismatch is thrown after all other validations. This ordering:

  • Ensures state root correctness before BAL check
  • Makes debugging easier (wrong state → wrong BAL)
  • Matches logical dependency (BAL depends on execution)

Performance Characteristics

BAL construction happens during execution (amortized). The final hash is O(n) where n is BAL size. For a 30M gas block with typical transactions:

  • ~100-200 accounts modified
  • ~500-1000 storage slots modified
  • BAL size: 10-50 KB typically
  • Hash time: <1ms

Determinism Requirements

Every operation must produce identical results across clients:

  • Sorting: Lexicographic by bytes (not numeric interpretation)
  • RLP encoding: Canonical form (no leading zeros except required)
  • Hash function: keccak256 (as implemented in every client)
Non-determinism in any step would cause consensus failures.

Section 09: VM Integration

Source Files

  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/__init__.py (lines 1-201)
  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/interpreter.py (lines 1-370)
  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/instructions/storage.py (lines 1-166)
  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/instructions/environment.py (lines 1-451)
  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/instructions/system.py (lines 1-670)
  • specs/execution-specs/src/ethereum/forks/amsterdam/state_tracker.py (frame management functions)

Overview

VM integration is where EIP-7928's state tracking actually happens. This section covers:

  • State Changes Threading: How StateChanges flows through Message → Evm → Instructions
  • Evm Class Extension: The new state_changes field in the EVM dataclass
  • Instruction-Level Hooks: Which opcodes trigger which tracking functions
  • Child Frame Management: How CALL/CREATE spawn child frames and merge results
  • Interpreter Hooks: Where the interpreter calls tracking functions for value transfers
The key architectural decision: tracking happens at the instruction level, not the state layer. This ensures all accesses are captured regardless of whether they modify state.

State Changes Threading

Architecture Overview

State tracking flows through three layers:

BlockEnvironment.state_changes (block frame)
         ↓
TransactionEnvironment.state_changes (tx frame)
         ↓
Message.state_changes → Evm.state_changes (call frames)

Each layer holds a StateChanges instance linked to its parent. This mirrors EVM execution semantics: a CALL can revert without affecting the parent's state changes.

BlockEnvironment.state_changes (vm/__init__.py, lines 36-52)

@dataclass
class BlockEnvironment:
    """
    Items external to the virtual machine itself, provided by the environment.
    """

    chain_id: U64
    state: State
    block_gas_limit: Uint
    block_hashes: List[Hash32]
    coinbase: Address
    number: Uint
    base_fee_per_gas: Uint
    time: U256
    prev_randao: Bytes32
    excess_blob_gas: U64
    parent_beacon_block_root: Hash32
    state_changes: StateChanges
Purpose: The block-level frame. Created in apply_body() before any transactions execute. Why here, not State? State is the canonical trie—it shouldn't know about access tracking. BlockEnvironment is the execution context, making it the natural home for execution-scoped metadata.

TransactionEnvironment.state_changes (vm/__init__.py, lines 92-107)

@dataclass
class TransactionEnvironment:
    """
    Items that are used by contract creation or message call.
    """

    origin: Address
    gas_price: Uint
    gas: Uint
    access_list_addresses: Set[Address]
    access_list_storage_keys: Set[Tuple[Address, Bytes32]]
    transient_storage: TransientStorage
    blob_versioned_hashes: Tuple[VersionedHash, ...]
    authorizations: Tuple[Authorization, ...]
    index_in_block: Optional[Uint]
    tx_hash: Optional[Hash32]
    state_changes: "StateChanges" = field(default_factory=StateChanges)
Purpose: The transaction-level frame. This is where pre-state captures happen (see Section 04). Key insight: Pre-values (pre_balances, pre_storage, pre_code) are stored at the tx frame level, not deeper. This ensures first-write-wins semantics across the entire transaction.

Message.state_changes (vm/__init__.py, lines 110-134)

@dataclass
class Message:
    """
    Items that are used by contract creation or message call.
    """

    block_env: BlockEnvironment
    tx_env: TransactionEnvironment
    caller: Address
    target: Bytes0 | Address
    current_target: Address
    gas: Uint
    value: U256
    data: Bytes
    code_address: Optional[Address]
    code: Bytes
    depth: Uint
    should_transfer_value: bool
    is_static: bool
    accessed_addresses: Set[Address]
    accessed_storage_keys: Set[Tuple[Address, Bytes32]]
    disable_precompiles: bool
    parent_evm: Optional["Evm"]
    is_create: bool
    state_changes: "StateChanges" = field(default_factory=StateChanges)
Purpose: Carries the state changes frame into the EVM. For the top-level call, this is the tx frame. For nested calls, it's a child frame created via create_child_frame(). Three state_changes references in Message:
  • message.state_changes — the current call's frame
  • message.tx_env.state_changes — the transaction frame (for pre-captures)
  • message.block_env.state_changes — the block frame (root)
Instructions use evm.state_changes for tracking, but pre-capture functions need access to tx_env.state_changes because pre-values are tx-scoped.

Evm Class Extension

Evm.state_changes (vm/__init__.py, lines 137-157)

@dataclass
class Evm:
    """The internal state of the virtual machine."""

    pc: Uint
    stack: List[U256]
    memory: bytearray
    code: Bytes
    gas_left: Uint
    valid_jump_destinations: Set[Uint]
    logs: Tuple[Log, ...]
    refund_counter: int
    running: bool
    message: Message
    output: Bytes
    accounts_to_delete: Set[Address]
    return_data: Bytes
    error: Optional[EthereumException]
    accessed_addresses: Set[Address]
    accessed_storage_keys: Set[Tuple[Address, Bytes32]]
    state_changes: StateChanges
Purpose: The EVM frame's state changes tracker. Instructions read from and write to this. Why separate from Message?

The Evm is the execution state; Message is the input parameters. By copying state_changes to Evm, instructions don't need to traverse evm.message.state_changes on every access.

Initialization in process_message() (interpreter.py, lines 243-273):
def process_message(message: Message) -> Evm:
    """
    Move ether and execute the relevant code.
    """
    state = message.block_env.state
    transient_storage = message.tx_env.transient_storage
    if message.depth > STACK_DEPTH_LIMIT:
        raise StackDepthLimitError("Stack depth limit reached")

    code = message.code
    valid_jump_destinations = get_valid_jump_destinations(code)
    evm = Evm(
        pc=Uint(0),
        stack=[],
        memory=bytearray(),
        code=code,
        gas_left=message.gas,
        valid_jump_destinations=valid_jump_destinations,
        logs=(),
        refund_counter=0,
        running=True,
        message=message,
        output=b"",
        accounts_to_delete=set(),
        return_data=b"",
        error=None,
        accessed_addresses=message.accessed_addresses,
        accessed_storage_keys=message.accessed_storage_keys,
        state_changes=message.state_changes,  # <-- Copy from message
    )

The state_changes is copied from the message, establishing the frame hierarchy.


Instruction-Level Tracking

Storage Instructions (storage.py)

sload (lines 24-54)

def sload(evm: Evm) -> None:
    """
    Loads to the stack, the value corresponding to a certain key from the
    storage of the current account.
    """
    # STACK
    key = pop(evm.stack).to_be_bytes32()

    # GAS
    if (evm.message.current_target, key) in evm.accessed_storage_keys:
        charge_gas(evm, GAS_WARM_ACCESS)
    else:
        evm.accessed_storage_keys.add((evm.message.current_target, key))
        charge_gas(evm, GAS_COLD_SLOAD)

    # OPERATION
    value = get_storage(
        evm.message.block_env.state, evm.message.current_target, key
    )
    track_storage_read(
        evm.state_changes,
        evm.message.current_target,
        key,
    )

    push(evm.stack, value)

    # PROGRAM COUNTER
    evm.pc += Uint(1)
Tracking point: After gas charging, before stack push. The track_storage_read() call records the slot access. Why track reads?

Reads prove which slots existed in pre-state. For stateless execution, the witness must include values for all read slots, even if unchanged.

sstore (lines 57-133)

def sstore(evm: Evm) -> None:
    """
    Stores a value at a certain key in the current context's storage.
    """
    if evm.message.is_static:
        raise WriteInStaticContext

    # STACK
    key = pop(evm.stack).to_be_bytes32()
    new_value = pop(evm.stack)

    # check we have at least the stipend gas
    check_gas(evm, GAS_CALL_STIPEND + Uint(1))

    state = evm.message.block_env.state
    original_value = get_storage_original(
        state, evm.message.current_target, key
    )
    current_value = get_storage(state, evm.message.current_target, key)

    gas_cost = Uint(0)

    if (evm.message.current_target, key) not in evm.accessed_storage_keys:
        evm.accessed_storage_keys.add((evm.message.current_target, key))
        gas_cost += GAS_COLD_SLOAD

    capture_pre_storage(
        evm.message.tx_env.state_changes,
        evm.message.current_target,
        key,
        current_value,
    )
    track_storage_read(
        evm.state_changes,
        evm.message.current_target,
        key,
    )

    # ... gas calculation using original_value and current_value ...

    charge_gas(evm, gas_cost)
    set_storage(state, evm.message.current_target, key, new_value)
    track_storage_write(
        evm.state_changes,
        evm.message.current_target,
        key,
        new_value,
    )

    # PROGRAM COUNTER
    evm.pc += Uint(1)
Critical sequence:
  • capture_pre_storage() — Records pre-value at tx frame (first-write-wins)
  • track_storage_read() — Records the read at call frame (SSTORE reads before writing)
  • set_storage() — Actually modifies state
  • track_storage_write() — Records the write at call frame
Why both read and write?

SSTORE semantically reads the current value (for gas calculation) before writing. If the call reverts, the write is discarded but the read persists. This is why merge_on_failure() converts writes to reads.

Pre-capture goes to tx_env.state_changes:
capture_pre_storage(
    evm.message.tx_env.state_changes,  # <-- tx frame, not call frame
    ...
)

Pre-values must survive nested call reversions. If a child CALL writes slot X, reverts, then the parent writes slot X, the pre-value captured by the child must still be available.

tload / tstore (lines 136-166)

def tload(evm: Evm) -> None:
    """
    Loads to the stack, the value corresponding to a certain key from the
    transient storage of the current account.
    """
    # STACK
    key = pop(evm.stack).to_be_bytes32()

    # GAS
    charge_gas(evm, GAS_WARM_ACCESS)

    # OPERATION
    value = get_transient_storage(
        evm.message.tx_env.transient_storage, evm.message.current_target, key
    )
    push(evm.stack, value)

    # PROGRAM COUNTER
    evm.pc += Uint(1)
No tracking: Transient storage (EIP-1153) is not tracked in the BAL. It's cleared after each transaction and has no cross-block implications.

Environment Instructions (environment.py)

balance (lines 47-73)

def balance(evm: Evm) -> None:
    """
    Pushes the balance of the given account onto the stack.
    """
    # STACK
    address = to_address_masked(pop(evm.stack))

    # GAS
    is_cold_access = address not in evm.accessed_addresses
    gas_cost = GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS
    if is_cold_access:
        evm.accessed_addresses.add(address)

    charge_gas(evm, gas_cost)

    # OPERATION
    state = evm.message.block_env.state
    balance = get_account(state, address).balance
    track_address(evm.state_changes, address)

    push(evm.stack, balance)

    # PROGRAM COUNTER
    evm.pc += Uint(1)
Tracking point: track_address() adds the address to touched_addresses. Why track BALANCE targets?

For stateless execution, the prover must include account data for any address whose balance is read. This includes non-existent accounts (returns 0).

extcodesize / extcodecopy / extcodehash (lines 286-362)

def extcodesize(evm: Evm) -> None:
    """
    Push the code size of a given account onto the stack.
    """
    # STACK
    address = to_address_masked(pop(evm.stack))

    # GAS
    is_cold_access = address not in evm.accessed_addresses
    access_gas_cost = (
        GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS
    )
    if is_cold_access:
        evm.accessed_addresses.add(address)

    charge_gas(evm, access_gas_cost)

    # OPERATION
    state = evm.message.block_env.state
    code = get_account(state, address).code
    track_address(evm.state_changes, address)

    codesize = U256(len(code))
    push(evm.stack, codesize)

All three EXTCODE opcodes follow the same pattern: track the address after gas charging.

self_balance (lines 395-418)

def self_balance(evm: Evm) -> None:
    """
    Pushes the balance of the current address to the stack.
    """
    # STACK
    pass

    # GAS
    charge_gas(evm, GAS_FAST_STEP)

    # OPERATION
    balance = get_account(
        evm.message.block_env.state, evm.message.current_target
    ).balance

    push(evm.stack, balance)

    # PROGRAM COUNTER
    evm.pc += Uint(1)
No tracking! SELFBALANCE doesn't call track_address(). Why not?

The current contract address is already tracked when execution enters the contract (in process_message()). SELFBALANCE is a cheap introspection—no external address access occurs.


System Instructions (system.py)

generic_create (lines 27-115)

def generic_create(
    evm: Evm,
    endowment: U256,
    contract_address: Address,
    memory_start_position: U256,
    memory_size: U256,
) -> None:
    """
    Core logic used by the `CREATE*` family of opcodes.
    """
    from ...vm.interpreter import (
        MAX_INIT_CODE_SIZE,
        STACK_DEPTH_LIMIT,
        process_create_message,
    )

    # Check static context first
    if evm.message.is_static:
        raise WriteInStaticContext

    # ... validation ...

    evm.accessed_addresses.add(contract_address)

    track_address(evm.state_changes, contract_address)
    if account_has_code_or_nonce(
        state, contract_address
    ) or account_has_storage(state, contract_address):
        increment_nonce(state, evm.message.current_target)
        nonce_after = get_account(state, evm.message.current_target).nonce
        track_nonce_change(
            evm.state_changes,
            evm.message.current_target,
            U64(nonce_after),
        )
        push(evm.stack, U256(0))
        return

    # Track nonce increment for CREATE
    increment_nonce(state, evm.message.current_target)
    nonce_after = get_account(state, evm.message.current_target).nonce
    track_nonce_change(
        evm.state_changes,
        evm.message.current_target,
        U64(nonce_after),
    )

    # Create call frame as child of parent EVM's frame
    child_state_changes = create_child_frame(evm.state_changes)

    child_message = Message(
        # ... other fields ...
        is_create=True,
        state_changes=child_state_changes,
    )
    child_evm = process_create_message(child_message)

    if child_evm.error:
        incorporate_child_on_error(evm, child_evm)
        evm.return_data = child_evm.output
        push(evm.stack, U256(0))
    else:
        incorporate_child_on_success(evm, child_evm)
        evm.return_data = b""
        push(evm.stack, U256.from_be_bytes(child_evm.message.current_target))
Critical tracking points:
  • track_address(contract_address) — Contract address is touched even if creation fails
  • track_nonce_change() — Sender's nonce increments regardless of outcome
  • create_child_frame() — Child gets its own state changes frame
The is_create=True flag:

This affects merge behavior. In process_create_message(), the merge happens after code deployment validation, not in generic_create().

generic_call (lines 234-297)

def generic_call(
    evm: Evm,
    gas: Uint,
    value: U256,
    caller: Address,
    to: Address,
    code_address: Address,
    should_transfer_value: bool,
    is_staticcall: bool,
    memory_input_start_position: U256,
    memory_input_size: U256,
    memory_output_start_position: U256,
    memory_output_size: U256,
    code: Bytes,
    disable_precompiles: bool,
) -> None:
    """
    Perform the core logic of the `CALL*` family of opcodes.
    """
    from ...vm.interpreter import STACK_DEPTH_LIMIT, process_message

    evm.return_data = b""

    if evm.message.depth + Uint(1) > STACK_DEPTH_LIMIT:
        evm.gas_left += gas
        push(evm.stack, U256(0))
        return

    call_data = memory_read_bytes(
        evm.memory, memory_input_start_position, memory_input_size
    )

    # Create call frame as child of parent EVM's frame
    child_state_changes = create_child_frame(evm.state_changes)

    child_message = Message(
        # ... other fields ...
        is_create=False,
        state_changes=child_state_changes,
    )

    child_evm = process_message(child_message)

    if child_evm.error:
        incorporate_child_on_error(evm, child_evm)
        evm.return_data = child_evm.output
        push(evm.stack, U256(0))
    else:
        incorporate_child_on_success(evm, child_evm)
        evm.return_data = child_evm.output
        push(evm.stack, U256(1))
Frame hierarchy in action:
parent.state_changes (tx frame or call frame)
         ↓ create_child_frame()
child.state_changes
         ↓ process_message()
    ... execution ...
         ↓ incorporate_child_on_success/error()
parent.state_changes (merged)

selfdestruct (lines 396-463)

def selfdestruct(evm: Evm) -> None:
    """
    Halt execution and register account for later deletion.
    """
    if evm.message.is_static:
        raise WriteInStaticContext

    # STACK
    beneficiary = to_address_masked(pop(evm.stack))

    # ... gas handling ...

    track_address(evm.state_changes, beneficiary)

    # ... more gas ...

    state = evm.message.block_env.state
    originator = evm.message.current_target
    originator_balance = get_account(state, originator).balance
    beneficiary_balance = get_account(state, beneficiary).balance

    # Get tracking context
    tx_frame = evm.message.tx_env.state_changes

    # Capture pre-balances for net-zero filtering
    track_address(evm.state_changes, originator)
    capture_pre_balance(tx_frame, originator, originator_balance)
    capture_pre_balance(tx_frame, beneficiary, beneficiary_balance)

    # Transfer balance
    move_ether(state, originator, beneficiary, originator_balance)

    # Track balance changes
    originator_new_balance = get_account(state, originator).balance
    beneficiary_new_balance = get_account(state, beneficiary).balance
    track_balance_change(
        evm.state_changes,
        originator,
        originator_new_balance,
    )
    track_balance_change(
        evm.state_changes,
        beneficiary,
        beneficiary_new_balance,
    )

    # register account for deletion only if it was created
    # in the same transaction
    if originator in state.created_accounts:
        set_account_balance(state, originator, U256(0))
        track_balance_change(evm.state_changes, originator, U256(0))
        evm.accounts_to_delete.add(originator)

    evm.running = False
Complex tracking sequence:
  • Track beneficiary address (may be new account)
  • Track originator address
  • Capture pre-balances for both (at tx frame)
  • Execute balance transfer
  • Track post-balances for both
  • If same-tx creation: additional balance zeroing
EIP-6780 interaction: Per EIP-6780, SELFDESTRUCT only deletes storage if the contract was created in the same transaction. The tracking handles this via track_selfdestruct() in state_tracker.py (see Section 04).

Interpreter-Level Tracking

Value Transfers in process_message (interpreter.py, lines 278-323)

def process_message(message: Message) -> Evm:
    """
    Move ether and execute the relevant code.
    """
    # ... evm initialization ...

    # take snapshot of state before processing the message
    begin_transaction(state, transient_storage)

    track_address(message.state_changes, message.current_target)

    if message.should_transfer_value and message.value != 0:
        # Track value transfer
        sender_balance = get_account(state, message.caller).balance
        recipient_balance = get_account(state, message.current_target).balance

        track_address(message.state_changes, message.caller)
        capture_pre_balance(
            message.tx_env.state_changes, message.caller, sender_balance
        )
        capture_pre_balance(
            message.tx_env.state_changes,
            message.current_target,
            recipient_balance,
        )

        move_ether(
            state, message.caller, message.current_target, message.value
        )

        sender_new_balance = get_account(state, message.caller).balance
        recipient_new_balance = get_account(
            state, message.current_target
        ).balance

        track_balance_change(
            message.state_changes,
            message.caller,
            U256(sender_new_balance),
        )
        track_balance_change(
            message.state_changes,
            message.current_target,
            U256(recipient_new_balance),
        )
This is the CALL value transfer, not an opcode. It happens before code execution begins. Tracking sequence:
  • track_address(current_target) — Always track the call recipient
  • If transferring value:
  • track_address(caller) — Track the sender
  • capture_pre_balance for both (tx frame)
  • move_ether — Actual balance modification
  • track_balance_change for both (call frame)

Contract Creation in process_create_message (interpreter.py, lines 154-222)

def process_create_message(message: Message) -> Evm:
    """
    Executes a call to create a smart contract.
    """
    state = message.block_env.state
    transient_storage = message.tx_env.transient_storage
    begin_transaction(state, transient_storage)

    destroy_storage(state, message.current_target)
    mark_account_created(state, message.current_target)

    increment_nonce(state, message.current_target)
    nonce_after = get_account(state, message.current_target).nonce
    track_nonce_change(
        message.state_changes,
        message.current_target,
        U64(nonce_after),
    )

    capture_pre_code(message.tx_env.state_changes, message.current_target, b"")

    evm = process_message(message)
    if not evm.error:
        contract_code = evm.output
        # ... code size validation ...
        try:
            # ... validation ...
            set_code(state, message.current_target, contract_code)
            if contract_code != b"":
                track_code_change(
                    message.state_changes,
                    message.current_target,
                    contract_code,
                )
            commit_transaction(state, transient_storage)
            merge_on_success(message.state_changes)
        except ExceptionalHalt as error:
            rollback_transaction(state, transient_storage)
            merge_on_failure(message.state_changes)
            # ... error handling ...
    else:
        rollback_transaction(state, transient_storage)
        merge_on_failure(message.state_changes)
    return evm
Creation-specific tracking:
  • track_nonce_change() — New contract gets nonce 1
  • capture_pre_code(b"") — Pre-code is always empty for creation
  • track_code_change() — Only if code is non-empty after deployment
Note: capture_pre_code uses b"" because the address has no code before creation. This enables net-zero filtering if creation reverts.

Child Frame Lifecycle

incorporate_child_on_success (vm/__init__.py, lines 172-189)

def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None:
    """
    Incorporate the state of a successful `child_evm` into the parent `evm`.
    """
    evm.gas_left += child_evm.gas_left
    evm.logs += child_evm.logs
    evm.refund_counter += child_evm.refund_counter
    evm.accounts_to_delete.update(child_evm.accounts_to_delete)
    evm.accessed_addresses.update(child_evm.accessed_addresses)
    evm.accessed_storage_keys.update(child_evm.accessed_storage_keys)

    merge_on_success(child_evm.state_changes)
What merges:
FieldBehavior
gas_leftAdded to parent
logsAppended to parent
refund_counterAdded to parent
accounts_to_deleteUnion with parent
accessed_addressesUnion with parent
accessed_storage_keysUnion with parent
state_changesVia merge_on_success()
The merge_on_success() call propagates all tracking data up the frame hierarchy.

incorporate_child_on_error (vm/__init__.py, lines 192-201)

def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None:
    """
    Incorporate the state of an unsuccessful `child_evm` into the parent `evm`.
    """
    evm.gas_left += child_evm.gas_left

    merge_on_failure(child_evm.state_changes)
What merges:
FieldBehavior
gas_leftAdded to parent
Everything elseDiscarded
The merge_on_failure() call only propagates reads and address touches; writes are converted to reads.

Opcode-to-Tracking Matrix

Opcodetrack_addresstrack_storage_readtrack_storage_writecapture_pre_track_*_change
SLOAD
SSTOREpre_storage (tx)
TLOAD
TSTORE
BALANCE
SELFBALANCE
EXTCODESIZE
EXTCODECOPY
EXTCODEHASH
CALL✓ (target)pre_balance (if value)balance_change (if value)
STATICCALL✓ (target)
DELEGATECALL✓ (code addr)
CALLCODE✓ (code addr)pre_balance (if value)
CREATE/CREATE2✓ (new addr)pre_codenonce_change, code_change
SELFDESTRUCT✓ (beneficiary)pre_balance (both)balance_change (both)
Key observations:
  • TLOAD/TSTORE: Not tracked (transient storage is tx-local)
  • SELFBALANCE: Not tracked (self address already tracked)
  • Pre-captures: Always go to tx_env.state_changes
  • Post-changes: Go to evm.state_changes (current frame)

Gotchas and Edge Cases

1. Reverted Writes Become Reads

When a call reverts, its writes are converted to reads via merge_on_failure():

def merge_on_failure(child_frame: StateChanges) -> None:
    # ...
    # Convert writes to reads (failed writes still accessed the slots)
    for address, key, _idx in child_frame.storage_writes.keys():
        parent_frame.storage_reads.add((address, key))
Why? The slot was accessed, even if the write didn't persist. Witnesses must include the pre-value.

2. Multiple Writes to Same Slot

Within a call frame, multiple SSTORE operations to the same slot:

SSTORE(slot, 1)  // write (slot, 1) at index X
SSTORE(slot, 2)  // write (slot, 2) at index X (overwrites)

Only the final value is recorded because writes use block_access_index as part of the key:

state_changes.storage_writes[(address, key, idx)] = value

Same (address, key, idx) → last write wins.

3. CREATE Collision

If CREATE targets an address with existing code/nonce:

track_address(evm.state_changes, contract_address)
if account_has_code_or_nonce(state, contract_address) or account_has_storage(...):
    track_nonce_change(...)  # Still track the sender nonce bump
    push(evm.stack, U256(0))
    return  # Early exit, no child frame

The address is tracked even though creation fails.

4. CALLCODE Value Transfer

CALLCODE transfers value but keeps the same storage context:

# EIP-7928: For CALLCODE with value transfer, capture pre-balance
# in transaction frame. CALLCODE transfers value from/to current_target
# (same address), affecting current storage context, not child frame
if value != 0 and sender_balance >= value:
    capture_pre_balance(
        evm.message.tx_env.state_changes,
        evm.message.current_target,
        sender_balance,
    )

This is a subtle case: the balance changes but the storage context doesn't change.

5. Precompiles

Precompiles don't execute EVM code, so tracking happens only for:

  • Address access (via CALL)
  • Value transfer (if applicable)
No storage tracking occurs for precompile calls.


Cross-References

  • Section 03-04: StateChanges dataclass and recording functions used here
  • Section 05: Frame management (create_child_frame, merge_on_success/failure)
  • Section 08: Block processing that initializes the top-level state_changes
  • Section 10: Fork definition where apply_body creates BlockEnvironment

Section 10: Fork Definition

Source Files

  • specs/execution-specs/src/ethereum/forks/amsterdam/fork.py (1179 lines)
  • specs/execution-specs/src/ethereum/forks/amsterdam/vm/__init__.py (206 lines)
  • specs/execution-specs/src/ethereum/forks/amsterdam/blocks.py (417 lines)

Overview

The fork definition is where EIP-7928 comes together at the block level. This section covers:

  • Header changes: The new block_access_list_hash field
  • Environment changes: StateChanges threading through block/tx/call execution
  • Validation changes: Computing and verifying the BAL hash
  • System transaction handling: Pre-execution calls with proper frame management
  • Transaction processing: Frame lifecycle, pre-value capture, commit timing
  • Withdrawal processing: Balance tracking for validator withdrawals

Data Structure Changes

Header: block_access_list_hash (blocks.py lines 245-254)

@slotted_freezable
@dataclass
class Header:
    # ... existing fields ...
    
    block_access_list_hash: Hash32
    """
    [`keccak256`] hash of the Block Access List containing all accounts and
    storage locations accessed during block execution. Introduced in
    [EIP-7928]. See [`compute_block_access_list_hash`][cbalh] for more
    details.
    """
Position in RLP: This field follows requests_hash in the header encoding. The exact position matters for consensus — all clients must encode headers identically. Why hash instead of full BAL?:
  • Block headers must be small for light clients
  • Hash commits to the full BAL without bloating headers
  • Full BAL is available in the block body (and via P2P)

BlockEnvironment: state_changes (vm/__init__.py lines 36-53)

@dataclass
class BlockEnvironment:
    """
    Items external to the virtual machine itself, provided by the environment.
    """
    chain_id: U64
    state: State
    block_gas_limit: Uint
    block_hashes: List[Hash32]
    coinbase: Address
    number: Uint
    base_fee_per_gas: Uint
    time: U256
    prev_randao: Bytes32
    excess_blob_gas: U64
    parent_beacon_block_root: Hash32
    state_changes: StateChanges  # NEW: EIP-7928
Purpose: The state_changes field holds the block-level frame that accumulates all state accesses across system transactions, user transactions, and withdrawals. Initialization: Created fresh in state_transition() before any execution begins:
block_env = vm.BlockEnvironment(
    # ... other fields ...
    state_changes=StateChanges(),  # Empty block frame
)
Lifetime: Lives for the entire block execution. After all transactions and withdrawals, this frame contains the complete state access record.

TransactionEnvironment: state_changes (vm/__init__.py lines 97-117)

@dataclass
class TransactionEnvironment:
    origin: Address
    gas_price: Uint
    gas: Uint
    access_list_addresses: Set[Address]
    access_list_storage_keys: Set[Tuple[Address, Bytes32]]
    transient_storage: TransientStorage
    blob_versioned_hashes: Tuple[VersionedHash, ...]
    authorizations: Tuple[Authorization, ...]
    index_in_block: Optional[Uint]
    tx_hash: Optional[Hash32]
    state_changes: "StateChanges" = field(default_factory=StateChanges)  # NEW
Parent relationship: Transaction frames link to the block frame via StateChanges.parent. This is established when calling create_child_frame(block_env.state_changes).

Message: state_changes (vm/__init__.py lines 120-143)

@dataclass
class Message:
    block_env: BlockEnvironment
    tx_env: TransactionEnvironment
    caller: Address
    target: Bytes0 | Address
    # ... other fields ...
    state_changes: "StateChanges" = field(default_factory=StateChanges)  # NEW
Call frame hierarchy: Each CALL/CREATE creates a child frame. On RETURN, merge_on_success or merge_on_failure propagates state accesses up.

BlockOutput: block_access_list (vm/__init__.py lines 56-93)

@dataclass
class BlockOutput:
    block_gas_used: Uint = Uint(0)
    transactions_trie: Trie[...] = field(default_factory=...)
    receipts_trie: Trie[...] = field(default_factory=...)
    receipt_keys: Tuple[Bytes, ...] = field(default_factory=tuple)
    block_logs: Tuple[Log, ...] = field(default_factory=tuple)
    withdrawals_trie: Trie[...] = field(default_factory=...)
    blob_gas_used: U64 = U64(0)
    requests: List[Bytes] = field(default_factory=list)
    block_access_list: BlockAccessList = field(default_factory=list)  # NEW
Populated by: build_block_access_list() at the end of apply_body().

State Transition: Entry Point

state_transition() (fork.py lines 199-262)

def state_transition(chain: BlockChain, block: Block) -> None:
    if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE:
        raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE")

    validate_header(chain, block.header)
    if block.ommers != ():
        raise InvalidBlock

    block_env = vm.BlockEnvironment(
        chain_id=chain.chain_id,
        state=chain.state,
        block_gas_limit=block.header.gas_limit,
        block_hashes=get_last_256_block_hashes(chain),
        coinbase=block.header.coinbase,
        number=block.header.number,
        base_fee_per_gas=block.header.base_fee_per_gas,
        time=block.header.timestamp,
        prev_randao=block.header.prev_randao,
        excess_blob_gas=block.header.excess_blob_gas,
        parent_beacon_block_root=block.header.parent_beacon_block_root,
        state_changes=StateChanges(),  # EIP-7928: Fresh block frame
    )

    block_output = apply_body(
        block_env=block_env,
        transactions=block.transactions,
        withdrawals=block.withdrawals,
    )
    
    # ... compute roots and hashes ...
    
    computed_block_access_list_hash = compute_block_access_list_hash(
        block_output.block_access_list
    )
    
    # ... validation checks ...
    
    if computed_block_access_list_hash != block.header.block_access_list_hash:
        raise InvalidBlock("Invalid block access list hash")

    chain.blocks.append(block)
EIP-7928 changes:
  • StateChanges() initialized as block frame
  • block_output.block_access_list computed during apply_body()
  • Hash computed and validated against header
Why validate the hash?: The BAL is consensus-critical. If a block proposes an incorrect BAL, validators must reject it. The hash provides a compact commitment that can be verified efficiently.

System Transaction Processing

process_system_transaction() (fork.py lines 520-582)

def process_system_transaction(
    block_env: vm.BlockEnvironment,
    target_address: Address,
    system_contract_code: Bytes,
    data: Bytes,
) -> MessageCallOutput:
    # EIP-7928: Create a child frame for system transaction
    # This allows proper pre-state capture for net-zero filtering
    system_tx_state_changes = create_child_frame(block_env.state_changes)

    tx_env = vm.TransactionEnvironment(
        origin=SYSTEM_ADDRESS,
        gas_price=block_env.base_fee_per_gas,
        gas=SYSTEM_TRANSACTION_GAS,
        access_list_addresses=set(),
        access_list_storage_keys=set(),
        transient_storage=TransientStorage(),
        blob_versioned_hashes=(),
        authorizations=(),
        index_in_block=None,  # System txs have no index
        tx_hash=None,
        state_changes=system_tx_state_changes,
    )

    # Create call frame as child of tx frame
    call_frame = create_child_frame(tx_env.state_changes)

    system_tx_message = Message(
        block_env=block_env,
        tx_env=tx_env,
        caller=SYSTEM_ADDRESS,
        target=target_address,
        gas=SYSTEM_TRANSACTION_GAS,
        value=U256(0),
        data=data,
        code=system_contract_code,
        depth=Uint(0),
        current_target=target_address,
        code_address=target_address,
        should_transfer_value=False,
        is_static=False,
        accessed_addresses=set(),
        accessed_storage_keys=set(),
        disable_precompiles=False,
        parent_evm=None,
        is_create=False,
        state_changes=call_frame,
    )

    system_tx_output = process_message_call(system_tx_message)

    # Commit system transaction changes to block frame
    # System transactions always succeed (or block is invalid)
    commit_transaction_frame(tx_env.state_changes)

    return system_tx_output
System transactions in EIP-7928:
  • Beacon roots contract (EIP-4788)
  • History storage contract (EIP-2935)
  • Withdrawal request contract (EIP-7002)
  • Consolidation request contract (EIP-7251)
Block access index: System transactions use block_access_index = 0. They execute before user transactions, so their state accesses are grouped at index 0. Frame hierarchy:
block_frame (index 0)
└── system_tx_frame
    └── call_frame
Why create frames for system txs?: Even though system txs "always succeed" (checked variant raises InvalidBlock on failure), we still need:
  • Proper pre-value capture for net-zero filtering
  • Frame-based state change accumulation
  • Consistent frame semantics across all execution types

Block Body Execution

apply_body() (fork.py lines 680-755)

def apply_body(
    block_env: vm.BlockEnvironment,
    transactions: Tuple[LegacyTransaction | Bytes, ...],
    withdrawals: Tuple[Withdrawal, ...],
) -> vm.BlockOutput:
    block_output = vm.BlockOutput()

    # EIP-7928: System contracts use block_access_index 0
    # The block frame already starts at index 0, so system transactions
    # naturally use that index through the block frame

    process_unchecked_system_transaction(
        block_env=block_env,
        target_address=BEACON_ROOTS_ADDRESS,
        data=block_env.parent_beacon_block_root,
    )

    process_unchecked_system_transaction(
        block_env=block_env,
        target_address=HISTORY_STORAGE_ADDRESS,
        data=block_env.block_hashes[-1],  # The parent hash
    )

    for i, tx in enumerate(map(decode_transaction, transactions)):
        process_transaction(block_env, block_output, tx, Uint(i))

    # EIP-7928: Increment block frame to post-execution index
    # After N transactions, block frame is at index N
    # Post-execution operations (withdrawals, etc.) use index N+1
    increment_block_access_index(block_env.state_changes)

    process_withdrawals(block_env, block_output, withdrawals)

    process_general_purpose_requests(
        block_env=block_env,
        block_output=block_output,
    )
    
    # Build block access list from block_env.state_changes
    block_output.block_access_list = build_block_access_list(
        block_env.state_changes
    )

    return block_output
Execution order and indices:
PhaseBlock Access IndexContents
System transactions0Beacon roots, history storage
Transaction 01First user transaction
Transaction 12Second user transaction
.........
Transaction N-1NLast user transaction
Post-executionN+1Withdrawals, request contracts
Why increment after transactions?: Withdrawals and post-execution system calls (withdrawal/consolidation requests) must have a distinct index from the last user transaction. Without this increment, their state accesses would be indistinguishable from the last tx. BAL construction timing: build_block_access_list() is called AFTER all state changes are recorded. The block frame at this point contains the complete, merged, filtered record of all state accesses.

Transaction Processing

process_transaction() (fork.py lines 770-930)

This is the heart of EIP-7928's transaction-level integration. Let's examine it section by section.

Frame Creation (lines 809-821)

def process_transaction(
    block_env: vm.BlockEnvironment,
    block_output: vm.BlockOutput,
    tx: Transaction,
    index: Uint,
) -> None:
    # EIP-7928: Create a transaction-level StateChanges frame
    # The frame will read the current block_access_index from the block frame
    increment_block_access_index(block_env.state_changes)
    tx_state_changes = create_child_frame(block_env.state_changes)
Key insight: increment_block_access_index happens BEFORE create_child_frame. This ensures the new transaction frame inherits the correct index. Index inheritance: When create_child_frame(parent) is called, the child copies parent.block_access_index. All track functions in the child use this index without needing to walk up the parent chain.

Coinbase Pre-Balance Capture (lines 823-829)

# Capture coinbase pre-balance for net-zero filtering
    coinbase_pre_balance = get_account(
        block_env.state, block_env.coinbase
    ).balance
    track_address(tx_state_changes, block_env.coinbase)
    capture_pre_balance(
        tx_state_changes, block_env.coinbase, coinbase_pre_balance
    )
Why track coinbase explicitly?: The coinbase receives the priority fee at the end of transaction execution. If coinbase doesn't appear elsewhere in the tx, we'd miss the pre-balance and couldn't do net-zero filtering. Net-zero scenario: Consider a transaction where:
  • Priority fee = 1 gwei
  • Coinbase also receives exactly 1 gwei from a SELFDESTRUCT
Without pre-balance capture, we couldn't detect if the net change is zero.

Sender Processing (lines 860-880)

# Track sender nonce increment
    increment_nonce(block_env.state, sender)
    sender_nonce_after = get_account(block_env.state, sender).nonce
    track_nonce_change(tx_state_changes, sender, U64(sender_nonce_after))

    # Track sender balance deduction for gas fee
    sender_balance_before = get_account(block_env.state, sender).balance
    track_address(tx_state_changes, sender)
    capture_pre_balance(tx_state_changes, sender, sender_balance_before)

    sender_balance_after_gas_fee = (
        Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee
    )
    set_account_balance(
        block_env.state, sender, U256(sender_balance_after_gas_fee)
    )
    track_balance_change(
        tx_state_changes,
        sender,
        U256(sender_balance_after_gas_fee),
    )
Nonce tracking: Unlike balances, nonces only increment. There's no net-zero filtering for nonces — if a nonce was incremented, it always appears in the BAL. Pre-balance capture timing: Must happen BEFORE the balance is modified. The pattern is:
  • Read current balance from state
  • capture_pre_balance() to record it
  • Modify balance in state
  • track_balance_change() to record new value

Gas Refund and Coinbase Payment (lines 918-940)

# refund gas
    sender_balance_after_refund = get_account(
        block_env.state, sender
    ).balance + U256(gas_refund_amount)
    set_account_balance(block_env.state, sender, sender_balance_after_refund)
    track_balance_change(
        tx_env.state_changes,
        sender,
        sender_balance_after_refund,
    )

    coinbase_balance_after_mining_fee = get_account(
        block_env.state, block_env.coinbase
    ).balance + U256(transaction_fee)

    set_account_balance(
        block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee
    )
    track_balance_change(
        tx_env.state_changes,
        block_env.coinbase,
        coinbase_balance_after_mining_fee,
    )
Why track refund?: The sender's balance changes twice:
  • Gas deduction (before execution)
  • Gas refund (after execution)
Both are tracked, but net-zero filtering will use the final value.

Frame Commit (lines 960-965)

for address in tx_output.accounts_to_delete:
        destroy_account(block_env.state, address)
        track_selfdestruct(tx_env.state_changes, address)

    # EIP-7928: Commit transaction frame (includes net-zero filtering).
    # Must happen AFTER destroy_account so filtering sees correct state.
    commit_transaction_frame(tx_env.state_changes)
Critical ordering: commit_transaction_frame must be called AFTER:
  • All state modifications (including SELFDESTRUCT)
  • All track_ calls
The commit:
  • Runs filter_net_zero_frame_changes() on the tx frame
  • Merges the filtered frame into the block frame

Withdrawal Processing

process_withdrawals() (fork.py lines 968-1005)

def process_withdrawals(
    block_env: vm.BlockEnvironment,
    block_output: vm.BlockOutput,
    withdrawals: Tuple[Withdrawal, ...],
) -> None:
    """
    Increase the balance of the withdrawing account.
    """
    # Capture pre-state for withdrawal balance filtering
    withdrawal_addresses = {wd.address for wd in withdrawals}
    for address in withdrawal_addresses:
        pre_balance = get_account(block_env.state, address).balance
        track_address(block_env.state_changes, address)
        capture_pre_balance(block_env.state_changes, address, pre_balance)

    def increase_recipient_balance(recipient: Account) -> None:
        recipient.balance += wd.amount * U256(10**9)

    for i, wd in enumerate(withdrawals):
        trie_set(
            block_output.withdrawals_trie,
            rlp.encode(Uint(i)),
            rlp.encode(wd),
        )

        modify_state(block_env.state, wd.address, increase_recipient_balance)

        new_balance = get_account(block_env.state, wd.address).balance
        track_balance_change(
            block_env.state_changes,
            wd.address,
            new_balance,
        )

        if account_exists_and_is_empty(block_env.state, wd.address):
            destroy_account(block_env.state, wd.address)

    # EIP-7928: Filter net-zero balance changes for withdrawals
    filter_net_zero_frame_changes(block_env.state_changes)
Unique characteristics of withdrawal tracking:
  • No transaction frame: Withdrawals operate directly on the block frame
  • Batch pre-capture: All addresses are captured before any balances change
  • Explicit filter call: Since there's no commit_transaction_frame, filtering is called explicitly
Why deduplicate addresses?: Multiple withdrawals can go to the same address. We only need one pre-balance capture per address, not per withdrawal. Net-zero scenario: A withdrawal of 0 ETH would result in pre == post, and the balance change would be filtered out. The address would still appear in touched_addresses.

EVM Integration Points

incorporate_child_on_success() (vm/__init__.py lines 182-194)

def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None:
    evm.gas_left += child_evm.gas_left
    evm.logs += child_evm.logs
    evm.refund_counter += child_evm.refund_counter
    evm.accounts_to_delete.update(child_evm.accounts_to_delete)
    evm.accessed_addresses.update(child_evm.accessed_addresses)
    evm.accessed_storage_keys.update(child_evm.accessed_storage_keys)

    merge_on_success(child_evm.state_changes)  # EIP-7928
When called: After a CALL/DELEGATECALL/STATICCALL/CREATE/CREATE2 returns successfully. State change propagation: merge_on_success propagates all state accesses from child to parent. Writes overwrite, reads union.

incorporate_child_on_error() (vm/__init__.py lines 197-206)

def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None:
    evm.gas_left += child_evm.gas_left

    merge_on_failure(child_evm.state_changes)  # EIP-7928
When called: After a CALL/etc. reverts. State change propagation: merge_on_failure converts writes to reads and discards value changes. The slots were still accessed (useful for prefetching), even though the final values didn't change.

Imports: EIP-7928 Dependencies

State Tracker Imports (fork.py lines 56-70)

from .state_tracker import (
    StateChanges,
    capture_pre_balance,
    commit_transaction_frame,
    create_child_frame,
    filter_net_zero_frame_changes,
    increment_block_access_index,
    track_address,
    track_balance_change,
    track_nonce_change,
    track_selfdestruct,
)

Builder Imports (fork.py lines 25-27)

from .block_access_lists.builder import build_block_access_list
from .block_access_lists.rlp_utils import compute_block_access_list_hash

Gotchas and Edge Cases

1. Index Timing

The block_access_index must be incremented BEFORE creating the child frame, not after:

# CORRECT
increment_block_access_index(block_env.state_changes)
tx_state_changes = create_child_frame(block_env.state_changes)

# WRONG - child would inherit old index
tx_state_changes = create_child_frame(block_env.state_changes)
increment_block_access_index(block_env.state_changes)

2. Pre-Balance Capture Ordering

Pre-balance must be captured before ANY modification to that balance:

# CORRECT
pre = get_account(state, addr).balance
capture_pre_balance(frame, addr, pre)
modify_balance(state, addr, new_value)
track_balance_change(frame, addr, new_value)

# WRONG - pre-value already changed
modify_balance(state, addr, new_value)
pre = get_account(state, addr).balance  # This is the new value!
capture_pre_balance(frame, addr, pre)

3. Commit After SELFDESTRUCT

The frame commit must happen AFTER track_selfdestruct:

for address in tx_output.accounts_to_delete:
    destroy_account(block_env.state, address)
    track_selfdestruct(tx_env.state_changes, address)

# Now commit - selfdestruct is recorded
commit_transaction_frame(tx_env.state_changes)

4. System Transaction Index

System transactions all share block_access_index = 0. This means:

  • Beacon roots and history storage accesses are grouped together
  • Post-execution system calls (withdrawal/consolidation requests) use index N+1

5. Empty Withdrawals

An empty withdrawals list still triggers filter_net_zero_frame_changes. This is harmless but worth noting — the filter will find nothing to filter.


Cross-References

SectionRelationship
Section 03StateChanges dataclass definition
Section 04track_ functions that record state accesses
Section 05Frame management (create, merge, commit)
Section 06-07Builder that consumes block frame
Section 08Block validation (header hash check)
Section 09VM interpreter hooks into state_changes
Section 11Engine API exposes BAL to CL

Summary

The fork definition is where EIP-7928's state tracking integrates with Ethereum's execution flow:

  • Block frame created in state_transition() with StateChanges()
  • System transactions create child frames, commit back to block frame
  • User transactions create child frames with incremented indices
  • Withdrawals operate on block frame with explicit net-zero filtering
  • BAL construction happens after all execution, from the complete block frame
  • Hash validation ensures consensus on the access list
The design is conservative: all existing execution logic remains, with state tracking woven through via frame creation, tracking calls, and commit points.

Section 11: Engine API

Source Files

  • specs/execution-apis/src/engine/amsterdam.md (237 lines)

Overview

The Engine API is the critical interface between the Consensus Layer (CL) and Execution Layer (EL). EIP-7928 introduces Block Access Lists (BALs) that must flow through this interface in both directions: from EL to CL during block building (engine_getPayloadV6), and from CL to EL during block validation (engine_newPayloadV5). This section annotates every change Amsterdam makes to the Engine API.

Architecture Context

The Engine API follows a versioned pattern: each hard fork bumps the version numbers of methods that change. Amsterdam introduces:

MethodVersionChange
engine_newPayloadV5V4 → V5Validates BAL in ExecutionPayloadV4
engine_getPayloadV6V5 → V6Returns BAL in ExecutionPayloadV4
engine_getPayloadBodiesByHashV2V1 → V2Returns BAL in ExecutionPayloadBodyV2
engine_getPayloadBodiesByRangeV2V1 → V2Returns BAL in ExecutionPayloadBodyV2
engine_forkchoiceUpdatedV4V3 → V4Uses PayloadAttributesV4 with slotNumber
The version bumps are NOT arbitrary—they signal breaking changes that require coordinated client updates.

Structures

ExecutionPayloadV4 (lines 23-38)

This structure has the syntax of [`ExecutionPayloadV3`](./cancun.md#executionpayloadv3) 
and appends the new field: `blockAccessList`.

- `parentHash`: `DATA`, 32 Bytes
- `feeRecipient`:  `DATA`, 20 Bytes
- `stateRoot`: `DATA`, 32 Bytes
- `receiptsRoot`: `DATA`, 32 Bytes
- `logsBloom`: `DATA`, 256 Bytes
- `prevRandao`: `DATA`, 32 Bytes
- `blockNumber`: `QUANTITY`, 64 Bits
- `gasLimit`: `QUANTITY`, 64 Bits
- `gasUsed`: `QUANTITY`, 64 Bits
- `timestamp`: `QUANTITY`, 64 Bits
- `extraData`: `DATA`, 0 to 32 Bytes
- `baseFeePerGas`: `QUANTITY`, 256 Bits
- `blockHash`: `DATA`, 32 Bytes
- `transactions`: `Array of DATA` - Array of transaction objects...
- `withdrawals`: `Array of WithdrawalV1` - Array of withdrawals...
- `blobGasUsed`: `QUANTITY`, 64 Bits
- `excessBlobGas`: `QUANTITY`, 64 Bits
- `blockAccessList`: `DATA` - RLP-encoded block access list as defined in [EIP-7928]
- `slotNumber`: `QUANTITY`, 64 Bits
Purpose: The canonical representation of an execution payload for Amsterdam blocks. New fields:
  • blockAccessList — RLP-encoded BAL (see Section 01: RLP Types)
  • slotNumber — Beacon chain slot for this block
Why RLP, not SSZ?

The blockAccessList is transmitted as raw RLP bytes (DATA), not as structured JSON. This is intentional:

  • Efficiency: BALs can be large (hundreds of KB). RLP is more compact than JSON.
  • Hash consistency: The EL computes block_access_list_hash = keccak256(rlp_encoded_bal). Sending raw bytes ensures CL and EL agree on the hash input.
  • Layering: The CL treats the BAL as opaque bytes—it doesn't need to parse the RLP structure.
Why slotNumber?

This field enables the EL to know which beacon slot the block is for, which is useful for:

  • MEV-related timing games (knowing how much of the slot has elapsed)
  • Cross-referencing EL blocks with CL attestations
  • Future slot-aware execution features
Gotchas:
  • blockAccessList is NOT nullable in V4. Pre-Amsterdam blocks use older payload versions.
  • The blockHash includes the block_access_list_hash in the header—if the BAL is wrong, the hash won't match.
Cross-references:
  • Section 01: RLP Types — BAL encoding format
  • Section 12: Consensus Layer — how CL wraps this payload

ExecutionPayloadBodyV2 (lines 40-46)

This structure has the syntax of [`ExecutionPayloadBodyV1`](./shanghai.md#executionpayloadbodyv1) 
and appends the new field: `blockAccessList`.

- `transactions`: `Array of DATA` - Array of transaction objects...
- `withdrawals`: `Array of WithdrawalV1` - Array of withdrawals...
- `blockAccessList`: `DATA|null` - RLP-encoded block access list...
Purpose: A "body-only" view of the payload, used for historical block retrieval. Why nullable?
Value is `null` for blocks produced before Amsterdam or if the data has been pruned.

Unlike ExecutionPayloadV4, the body structure is used retrospectively to fetch old blocks. This creates two null cases:

  • Pre-fork blocks: Blocks before Amsterdam activation have no BAL.
  • Pruned data: Clients may discard BALs after some retention period.
Why separate from full payload?

The "body" methods (getPayloadBodiesByHash, getPayloadBodiesByRange) are designed for sync and historical queries. They don't include header fields like stateRoot or parentHash because:

  • Callers typically already have the header
  • Omitting duplicated data saves bandwidth
Gotchas:
  • Null handling is mandatory—clients must not crash on missing BALs.
  • Pruning behavior is implementation-defined. The spec doesn't mandate retention periods.

Methods

engine_newPayloadV5 (lines 50-71)

This method is updated to support the new `ExecutionPayloadV4` structure.

#### Request

* method: `engine_newPayloadV5`
* params:
  1. `executionPayload`: [`ExecutionPayloadV4`](#executionpayloadv4).
  2. `expectedBlobVersionedHashes`: `Array of DATA`, 32 Bytes
  3. `parentBeaconBlockRoot`: `DATA`, 32 Bytes
  4. `executionRequests`: `Array of DATA` - List of execution layer triggered requests.
Purpose: The CL sends a complete payload to the EL for validation and potential import. Flow:
CL receives block from network
    → CL extracts ExecutionPayload
    → CL calls engine_newPayloadV5(payload, blobs, parentRoot, requests)
    → EL validates payload (including BAL)
    → EL returns VALID/INVALID/SYNCING

Specification (lines 64-71)

1. Client software **MUST** return `-38005: Unsupported fork` error if the 
   `timestamp` of the payload does not fall within the time frame of the 
   Amsterdam activation.

2. Client software **MUST** return `-32602: Invalid params` error if the 
   `blockAccessList` field is missing.

3. Client software **MUST** validate the `blockAccessList` field by executing 
   the payload's transactions and verifying that the computed access list 
   matches the provided one. If this validation fails, the call **MUST** return 
   `{status: INVALID, latestValidHash: null, validationError: errorMessage | null}`.
Rule 1: Fork enforcement

Error code -38005 means "you're using the wrong API version for this fork." The EL uses the payload's timestamp to determine which fork rules apply.

if timestamp < AMSTERDAM_TIMESTAMP:
    return error(-38005, "Unsupported fork")

This ensures:

  • CL can't accidentally send V5 requests for pre-Amsterdam blocks
  • Prevents confusion during fork transition
Rule 2: Missing BAL is fatal

A missing blockAccessList is a protocol violation, not a validation failure. The -32602: Invalid params error says "your request is malformed"—distinct from an INVALID payload status.

Rule 3: BAL validation is re-execution

This is the critical rule. The EL must:

  • Execute all transactions in the payload
  • Build a BAL from the observed state accesses (see Section 06-07)
  • Compare the built BAL with the provided BAL
  • Return INVALID if they differ
Why re-execute instead of trusting the BAL?

The BAL is not trusted input. A malicious proposer could include a wrong BAL to:

  • Cause validators to disagree on validity
  • Enable state reconstruction attacks
  • Break parallel execution invariants
By re-executing and comparing, the EL ensures the BAL is exactly what a correct execution would produce.

Performance implication: BAL validation has the same cost as block execution. There's no shortcut. However, after validation, the BAL enables parallelization for state root computation and sync. Error response semantics:
{
  "status": "INVALID",
  "latestValidHash": null,
  "validationError": "blockAccessList mismatch at account 0x..."
}
  • latestValidHash: null — The EL doesn't know where the chain diverged
  • validationError — Human-readable error (optional)
Cross-references:
  • Section 06-07: BAL Builder — how the comparison BAL is constructed
  • Section 08: Block Processing — where validation integrates into process_block

engine_getPayloadV6 (lines 73-101)

This method is updated to return the new `ExecutionPayloadV4` structure.

#### Request

* method: `engine_getPayloadV6`
* params:
  1. `payloadId`: `DATA`, 8 Bytes - Identifier of the payload build process
* timeout: 1s
Purpose: CL asks EL to produce a block. EL returns a complete payload including the BAL. Flow:
CL selects proposer for slot N
    → CL calls engine_forkchoiceUpdatedV4 with PayloadAttributesV4
    → EL begins building block, returns payloadId
    → CL waits (up to timeout)
    → CL calls engine_getPayloadV6(payloadId)
    → EL returns ExecutionPayloadV4 with BAL populated
    → CL wraps payload in BeaconBlock and broadcasts

Response (lines 81-89)

* result: `object`
  - `executionPayload`: [`ExecutionPayloadV4`](#executionpayloadv4)
  - `blockValue` : `QUANTITY`, 256 Bits - The expected value to be received by 
    the `feeRecipient` in wei
  - `blobsBundle`: [`BlobsBundleV2`](./osaka.md#blobsbundlev2)
  - `shouldOverrideBuilder` : `BOOLEAN` - Suggestion from the execution layer 
    to use this `executionPayload` instead of an externally provided one
  - `executionRequests`: `Array of DATA` - Execution layer triggered requests...
Why blockValue?

MEV-boost integration. The CL compares local blockValue against builder bids to decide whether to use the local block or accept a builder's block.

Why shouldOverrideBuilder?

Edge cases where the EL knows the builder block is suboptimal:

  • Builder block fails validation
  • Builder block has lower value than claimed
  • Censorship resistance concerns

Specification (lines 93-101)

1. Client software **MUST** return `-38005: Unsupported fork` error if the 
   `timestamp` of the built payload does not fall within the time frame of the 
   Amsterdam activation.

2. When building the block, client software **MUST** collect all account 
   accesses and state changes during transaction execution and populate the 
   `blockAccessList` field in the returned `ExecutionPayloadV4` with the 
   RLP-encoded access list.
Rule 2: BAL construction is mandatory

The EL doesn't have a choice—when building an Amsterdam block, it MUST:

  • Track all state accesses during execution (see Section 03-05: State Tracker)
  • Build the BAL after transaction execution (see Section 06-07: Builder)
  • RLP-encode and include in the payload
Builder vs Validator divergence risk

A subtle issue: if the block builder's EL constructs the BAL differently than validators' ELs reconstruct it, the block will be rejected. This is why the BAL spec is so precise about:

  • Ordering (lexicographic by address, then by slot)
  • Net-zero filtering (changes that result in no net change are excluded)
  • Gas validation rules (which accesses are included vs excluded)
See Section 07: Builder Finalization for the deterministic construction algorithm.

Cross-references:
  • Section 06-07: BAL Builder — construction during block building
  • Section 03-05: State Tracker — how accesses are recorded

engine_getPayloadBodiesByHashV2 (lines 103-121)

This method retrieves execution payload bodies including block access lists 
for specified blocks.

#### Request

* method: `engine_getPayloadBodiesByHashV2`
* params:
  1. `blockHashes`: `Array of DATA`, 32 Bytes - Array of block hashes
* timeout: 10s
Purpose: Fetch historical block bodies by hash, now including BALs. Use cases:
  • Sync: New nodes catching up on historical blocks
  • Reorg handling: Re-fetching blocks after a chain reorganization
  • Archive queries: Historical analysis

Specification (lines 118-121)

1. Client software **MUST** set the `blockAccessList` field to `null` for 
   blocks that predate the Amsterdam fork activation.

2. Client software **MUST** set the `blockAccessList` field to `null` if 
   the block access list has been pruned from storage.
Rule 1: Pre-fork null handling

When querying a pre-Amsterdam block hash, the response includes null for blockAccessList. This is NOT an error—it's expected behavior.

{
  "transactions": [...],
  "withdrawals": [...],
  "blockAccessList": null  // Pre-Amsterdam or pruned
}
Rule 2: Pruning tolerance

EL clients may prune BALs to save storage. The spec explicitly permits this:

  • Callers must handle null gracefully
  • There's no mandatory retention period
  • Archive nodes may retain longer than full nodes
Why allow pruning?

BALs can be large. For a worst-case block:

  • ~6,000 unique addresses touched
  • Each with multiple storage slots
  • RLP overhead per entry
Estimates suggest BALs average 50-200 KB per block, but can reach 500+ KB. At 12-second block times, that's ~1.5 TB/year just for BALs if not pruned.

Gotchas:
  • Don't assume null means error—check block timestamp vs fork activation
  • If you need guaranteed BAL availability, use an archive node

engine_getPayloadBodiesByRangeV2 (lines 123-145)

This method retrieves execution payload bodies including block access lists 
for a range of blocks.

#### Request

* method: `engine_getPayloadBodiesByRangeV2`
* params:
  1. `start`: `QUANTITY`, 64 Bits - Starting block number
  2. `count`: `QUANTITY`, 64 Bits - Number of blocks to retrieve
* timeout: 10s
Purpose: Batch fetch of historical blocks by number range. Why range queries?

More efficient for sequential sync than individual hash lookups:

  • Single round-trip for many blocks
  • Natural for "give me blocks 1000-2000" during initial sync
The specification rules mirror getPayloadBodiesByHashV2:

1. Client software **MUST** set the `blockAccessList` field to `null` for 
   blocks that predate the Amsterdam fork activation.

2. Client software **MUST** set the `blockAccessList` field to `null` if 
   the block access list has been pruned from storage.
Mixed-fork ranges

A range query spanning the Amsterdam fork returns:

  • null BALs for pre-fork blocks
  • Actual BALs for post-fork blocks
Request: start=15537390, count=5 (assuming fork at 15537393)
Response: [null, null, null, <BAL>, <BAL>]


engine_forkchoiceUpdatedV4 (lines 147-185)

#### Request

* method: `engine_forkchoiceUpdatedV4`
* params:
  1. `forkchoiceState`: [`ForkchoiceStateV1`](./paris.md#ForkchoiceStateV1).
  2. `payloadAttributes`: `Object|null` - Instance of [`PayloadAttributesV4`] or `null`.
* timeout: 8s
Purpose: CL tells EL the current fork choice head, and optionally asks EL to start building a new block. Dual role:
  • Fork choice update: "This is the canonical head now"
  • Block building trigger: "Start building a block with these attributes"
If payloadAttributes is non-null, the EL returns a payloadId that the CL uses later with engine_getPayloadV6.

Specification (lines 174-185)

1. Client software **MUST** verify that `forkchoiceState` matches the 
   [`ForkchoiceStateV1`](./paris.md#ForkchoiceStateV1) structure and return 
   `-32602: Invalid params` on failure.

2. Extend point (7) of the `engine_forkchoiceUpdatedV1` [specification] by 
   defining the following sequence of checks:

    1. `payloadAttributes` matches the [`PayloadAttributesV4`] structure
    2. `payloadAttributes.timestamp` does not fall within the time frame of 
       the Amsterdam fork, return `-38005: Unsupported fork` on failure.
    3. `payloadAttributes.timestamp` is greater than `timestamp` of a block 
       referenced by `forkchoiceState.headBlockHash`
    4. If any of the above checks fails, the `forkchoiceState` update 
       **MUST NOT** be rolled back.
Check 4 is subtle: If payload attributes validation fails, the fork choice state update still applies. This prevents:
  • CL sending bad attributes → EL rejects → CL retries with same state
  • Wasted work re-processing the fork choice update
Error atomicity:
forkchoiceState update: SUCCESS
payloadAttributes validation: FAIL (-38003)
Result: forkchoiceState applied, no payload building started

PayloadAttributesV4 (lines 187-196)

This structure has the syntax of [`PayloadAttributesV3`](./cancun.md#payloadattributesv3) 
and appends a single field: `slotNumber`.

- `timestamp`: `QUANTITY`, 64 Bits
- `prevRandao`: `DATA`, 32 Bytes
- `suggestedFeeRecipient`: `DATA`, 20 Bytes
- `withdrawals`: `Array of WithdrawalV1`
- `parentBeaconBlockRoot`: `DATA`, 32 Bytes
- `slotNumber`: `QUANTITY`, 64 Bits
Why slotNumber in attributes?

The CL passes the slot number so the EL can include it in the payload. This is a new Amsterdam field that:

  • Lets EL know the exact beacon slot
  • Enables slot-aware block building (MEV timing)
  • Provides cross-layer consistency
Note: slotNumber was added alongside BALs but isn't directly related to BAL functionality.


Update the Methods of Previous Forks (lines 198-211)

#### Osaka API

For the following methods:

- [`engine_newPayloadV4`](./prague.md#engine_newpayloadv4)
- [`engine_getPayloadV5`](./osaka.md#engine_getpayloadv5)
- [`engine_forkchoiceUpdatedV3`](./cancun.md#engine_forkchoiceupdatedv3)

a validation **MUST** be added:

1. Client software **MUST** return `-38005: Unsupported fork` error if the 
   `timestamp` of payload greater or equal to the Amsterdam activation timestamp.
Purpose: Prevent old API versions from being used for Amsterdam blocks. Why explicitly disable?

Without this rule, a misconfigured client might:

  • Use engine_newPayloadV4 for an Amsterdam block
  • Validation passes (no BAL field expected)
  • Block is accepted without BAL validation
  • Chain split between V4 and V5 clients
By mandating -38005 for old methods on new timestamps, the spec ensures fail-fast behavior.

Upgrade coordination:
Before Amsterdam: V4/V5 methods work
At Amsterdam: V4 rejects, V5 required
After Amsterdam: V4 always rejects for new blocks

Error Code Reference

CodeNameWhen
-32602Invalid paramsMalformed request (missing fields, wrong types)
-38003Invalid payload attributesPayloadAttributes validation failed
-38005Unsupported forkTimestamp/method version mismatch
Error vs INVALID status:
  • Error codes: Request-level problems (bad JSON, wrong method version)
  • INVALID status: Payload-level problems (bad BAL, invalid state root)

Cross-References

SectionConnection
Section 01: RLP TypesBAL encoding format for blockAccessList field
Section 06-07: BAL BuilderHow BALs are constructed for getPayloadV6
Section 08: Block ProcessingBAL validation during newPayloadV5
Section 12: Consensus LayerCL containers that wrap these payloads
Section 13: eth/71 ProtocolAlternative BAL retrieval path (devp2p)

Implementation Notes

Timeout Implications

MethodTimeoutImplication
getPayloadV61sBlock building must be fast enough
getPayloadBodiesBy*10sHistorical queries can be slower
forkchoiceUpdatedV48sComplex state updates allowed
The 1s timeout on getPayloadV6 is tight. If BAL construction is slow, it could delay block production. Implementations should:
  • Build BAL incrementally during execution
  • Not defer BAL construction to the getPayload call

Bandwidth Considerations

For syncing nodes, getPayloadBodiesByRangeV2 now returns significantly more data due to BALs. Clients should:

  • Consider BAL size when setting count parameter
  • Be prepared for larger responses
  • Possibly prune BALs earlier on bandwidth-constrained nodes

Version Negotiation

The Engine API uses method names for versioning, not protocol negotiation. Clients must:

  • Support all active versions (V4 for pre-Amsterdam, V5 for Amsterdam+)
  • Route requests based on block timestamp
  • Reject cross-version misuse with -38005

Section 12: Consensus Layer

Source Files

  • specs/consensus-specs/specs/_features/eip7928/beacon-chain.md (199 lines)
  • specs/consensus-specs/specs/_features/eip7928/p2p-interface.md (40 lines)

Overview

The Consensus Layer (CL) changes for EIP-7928 are minimal by design. The CL doesn't interpret the Block Access List (BAL)—it treats it as an opaque blob of bytes that it:

  • Receives from the Execution Layer (EL) via the Engine API
  • Stores in the ExecutionPayload container
  • Commits to via hash_tree_root in the ExecutionPayloadHeader
  • Passes to the EL for validation during process_execution_payload
This section covers the SSZ type definitions, container modifications, and the single function change needed to support BALs in the beacon chain.

Design Philosophy: CL as Opaque Carrier

The consensus layer has a clear boundary: it handles consensus (validators, slots, attestations) and treats execution data as opaque. This is evident in how the BAL is typed:

BlockAccessList = ByteList[MAX_BYTES_PER_TRANSACTION]

Not a structured type. Just bytes. The CL doesn't know or care what's inside—that's the EL's job.

Why this layering matters:
  • Upgrade independence: The EL can change BAL encoding (e.g., compression) without CL changes
  • Validation separation: The CL validates SSZ structure; the EL validates semantic correctness
  • Simpler CL clients: No need to implement RLP parsing or BAL logic
  • Attack surface reduction: CL bugs can't corrupt BAL validation, and vice versa
This is the same pattern used for transactions (also ByteList), withdrawals, and execution_requests.

Type Definitions

BlockAccessList Type (beacon-chain.md, lines 24-26)

| Name              | SSZ equivalent                        | Description                   |
| ----------------- | ------------------------------------- | ----------------------------- |
| `BlockAccessList` | `ByteList[MAX_BYTES_PER_TRANSACTION]` | RLP encoded block access list |
Purpose: Define an SSZ-compatible type for the BAL bytes. Why ByteList[MAX_BYTES_PER_TRANSACTION]?

The size bound is inherited from the existing MAX_BYTES_PER_TRANSACTION constant, which is 1,073,741,824 bytes (1 GiB). This is deliberately enormous—far larger than any realistic BAL—because:

  • Future-proofing: BAL size depends on state access patterns, which could grow
  • Consistency: Using an existing constant avoids introducing new limits
  • Practical ceiling: A 1 GiB BAL would require hundreds of millions of storage accesses, which is impossible within gas limits
Actual expected sizes:
Block typeExpected BAL sizeNotes
Empty block~100 bytesOnly system calls (beacon root, withdrawals)
Typical mainnet10-100 KB~200 unique accounts, ~500 storage slots
DEX-heavy block100-500 KBMany AMM pool interactions
Worst-case spam1-5 MBAdversarial access pattern
Gotcha: The 1 GiB limit is an SSZ encoding limit, not a consensus limit. Gas costs naturally constrain BAL size—you can't access enough state to fill 1 GiB. Cross-references:
  • Section 01: RLP Types — The actual encoding within these bytes
  • Section 11: Engine API — How blockAccessList is transmitted as raw DATA

Extended Containers

ExecutionPayload (beacon-chain.md, lines 30-51)

class ExecutionPayload(Container):
    parent_hash: Hash32
    fee_recipient: ExecutionAddress
    state_root: Bytes32
    receipts_root: Bytes32
    logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM]
    prev_randao: Bytes32
    block_number: uint64
    gas_limit: uint64
    gas_used: uint64
    timestamp: uint64
    extra_data: ByteList[MAX_EXTRA_DATA_BYTES]
    base_fee_per_gas: uint256
    block_hash: Hash32
    transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD]
    withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]
    blob_gas_used: uint64
    excess_blob_gas: uint64
    # [New in EIP7928]
    block_access_list: BlockAccessList
Purpose: The full execution payload container, extended with the BAL. Field position matters:

The block_access_list field is added at the end of the container. This is critical for SSZ:

  • Backwards compatibility of hash_tree_root: Adding fields at the end preserves the Merkle tree structure for all preceding fields
  • Generalized indices stability: Light clients using generalized indices for proof verification don't break
  • Optional field pattern: SSZ supports "extending" containers by appending fields
What the CL receives:

The CL receives this container from the EL via engine_getPayloadV6. The block_access_list field contains raw RLP bytes that the CL stores but doesn't interpret:

EL builds block → EL populates block_access_list → EL sends ExecutionPayloadV4 via Engine API
                                                            ↓
CL receives → CL wraps in SSZ ExecutionPayload → CL gossips BeaconBlock
Relationship to Engine API:
Engine API fieldSSZ container fieldEncoding
ExecutionPayloadV4.blockAccessListExecutionPayload.block_access_listRLP bytes
The naming convention differs slightly (blockAccessList vs block_access_list) due to JSON/SSZ conventions. Cross-references:
  • Section 11: Engine API — ExecutionPayloadV4 structure

ExecutionPayloadHeader (beacon-chain.md, lines 53-73)

class ExecutionPayloadHeader(Container):
    parent_hash: Hash32
    fee_recipient: ExecutionAddress
    state_root: Bytes32
    receipts_root: Bytes32
    logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM]
    prev_randao: Bytes32
    block_number: uint64
    gas_limit: uint64
    gas_used: uint64
    timestamp: uint64
    extra_data: ByteList[MAX_EXTRA_DATA_BYTES]
    base_fee_per_gas: uint256
    block_hash: Hash32
    transactions_root: Root
    withdrawals_root: Root
    blob_gas_used: uint64
    excess_blob_gas: uint64
    # [New in EIP7928]
    block_access_list_root: Root
Purpose: A compact commitment to the execution payload, used in BeaconState. Key distinction: block_access_list vs block_access_list_root:
ContainerFieldTypeContains
ExecutionPayloadblock_access_listBlockAccessList (bytes)Full RLP-encoded BAL
ExecutionPayloadHeaderblock_access_list_rootRoot (32 bytes)hash_tree_root(block_access_list)
The header stores a commitment (root), not the full data. This enables:
  • State size efficiency: BeaconState stores the header, not the full payload
  • Light client proofs: Prove BAL inclusion without transmitting the full BAL
  • Gossip efficiency: Headers can be verified before requesting full payloads
Why hash_tree_root, not keccak256?

This is subtle but important. The block_access_list_root uses SSZ's hash_tree_root, while the EL's block_access_list_hash uses keccak256(rlp_encoded_bal):

LayerCommitmentHash functionInput
EL (Header)block_access_list_hashkeccak256RLP bytes
CL (Header)block_access_list_roothash_tree_rootSSZ ByteList
These are different hashes of the same data!
  • EL: keccak256(rlp_bytes) — used in block hash, validates BAL correctness
  • CL: hash_tree_root(ByteList(rlp_bytes)) — used in SSZ Merkleization, enables CL proofs
This duality exists because each layer uses its native hash function. The CL doesn't need to compute keccak256—it delegates that validation to the EL via verify_and_notify_new_payload. Cross-references:
  • Section 08: Block Processing — EL's block_access_list_hash computation

BeaconState (beacon-chain.md, lines 75-114)

class BeaconState(Container):
    genesis_time: uint64
    genesis_validators_root: Root
    slot: Slot
    fork: Fork
    latest_block_header: BeaconBlockHeader
    block_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT]
    state_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT]
    historical_roots: List[Root, HISTORICAL_ROOTS_LIMIT]
    eth1_data: Eth1Data
    eth1_data_votes: List[Eth1Data, EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH]
    eth1_deposit_index: uint64
    validators: List[Validator, VALIDATOR_REGISTRY_LIMIT]
    balances: List[Gwei, VALIDATOR_REGISTRY_LIMIT]
    randao_mixes: Vector[Bytes32, EPOCHS_PER_HISTORICAL_VECTOR]
    slashings: Vector[Gwei, EPOCHS_PER_SLASHINGS_VECTOR]
    previous_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT]
    current_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT]
    justification_bits: Bitvector[JUSTIFICATION_BITS_LENGTH]
    previous_justified_checkpoint: Checkpoint
    current_justified_checkpoint: Checkpoint
    finalized_checkpoint: Checkpoint
    inactivity_scores: List[uint64, VALIDATOR_REGISTRY_LIMIT]
    current_sync_committee: SyncCommittee
    next_sync_committee: SyncCommittee
    # [Modified in EIP7928]
    latest_execution_payload_header: ExecutionPayloadHeader
    next_withdrawal_index: WithdrawalIndex
    next_withdrawal_validator_index: ValidatorIndex
    historical_summaries: List[HistoricalSummary, HISTORICAL_ROOTS_LIMIT]
    deposit_requests_start_index: uint64
    deposit_balance_to_consume: Gwei
    exit_balance_to_consume: Gwei
    earliest_exit_epoch: Epoch
    consolidation_balance_to_consume: Gwei
    earliest_consolidation_epoch: Epoch
    pending_deposits: List[PendingDeposit, PENDING_DEPOSITS_LIMIT]
    pending_partial_withdrawals: List[PendingPartialWithdrawal, PENDING_PARTIAL_WITHDRAWALS_LIMIT]
    pending_consolidations: List[PendingConsolidation, PENDING_CONSOLIDATIONS_LIMIT]
    proposer_lookahead: Vector[ValidatorIndex, (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH]
Purpose: The full beacon chain state, extended with the modified ExecutionPayloadHeader type. What changes:

The BeaconState itself doesn't gain new fields—it's the type of latest_execution_payload_header that changes:

Before EIP-7928: latest_execution_payload_header: ExecutionPayloadHeader  # Fulu version
After EIP-7928:  latest_execution_payload_header: ExecutionPayloadHeader  # EIP7928 version

Same field name, different container definition. The EIP-7928 version has block_access_list_root.

State transition implications:

When a block is processed, latest_execution_payload_header is updated to include the new BAL root:

Block N processed → state.latest_execution_payload_header.block_access_list_root updated

This enables:

  • Verification that subsequent blocks reference valid execution state
  • Historical proofs about what BAL was committed to at each slot

NewPayloadRequest (beacon-chain.md, lines 116-119)

*Note*: The `NewPayloadRequest` is unchanged. The `block_access_list` is
included in the `execution_payload` field.
Purpose: Clarify that no separate BAL transmission is needed. Why explicitly state "unchanged"?

Previous EIP additions sometimes required new fields in NewPayloadRequest. For example:

  • EIP-4844 added versioned_hashes and parent_beacon_block_root
  • EIP-7685 added execution_requests
EIP-7928 is different: the BAL is embedded inside the execution_payload, so no structural change to the request is needed. The EL extracts block_access_list from the payload directly.

Data flow:
CL: NewPayloadRequest(
    execution_payload=ExecutionPayload(
        ...,
        block_access_list=<RLP bytes>  # BAL embedded here
    ),
    versioned_hashes=[...],
    parent_beacon_block_root=...,
    execution_requests=[...]
)
                    ↓
EL: Extracts block_access_list from payload, validates against computed BAL

Beacon Chain State Transition Function

Modified process_execution_payload (beacon-chain.md, lines 127-199)

def process_execution_payload(
    state: BeaconState, body: BeaconBlockBody, execution_engine: ExecutionEngine
) -> None:
    payload = body.execution_payload

    # Verify consistency of the parent hash with respect to the previous execution payload header
    assert payload.parent_hash == state.latest_execution_payload_header.block_hash
    # Verify prev_randao
    assert payload.prev_randao == get_randao_mix(state, get_current_epoch(state))
    # Verify timestamp
    assert payload.timestamp == compute_time_at_slot(state, state.slot)
    # Verify commitments are under limit
    assert (
        len(body.blob_kzg_commitments)
        <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block
    )

    # Compute list of versioned hashes
    versioned_hashes = [
        kzg_commitment_to_versioned_hash(commitment) for commitment in body.blob_kzg_commitments
    ]

    # Verify the execution payload is valid
    assert execution_engine.verify_and_notify_new_payload(
        NewPayloadRequest(
            execution_payload=payload,
            versioned_hashes=versioned_hashes,
            parent_beacon_block_root=state.latest_block_header.parent_root,
            execution_requests=body.execution_requests,
        )
    )

    # Cache execution payload header
    state.latest_execution_payload_header = ExecutionPayloadHeader(
        parent_hash=payload.parent_hash,
        fee_recipient=payload.fee_recipient,
        state_root=payload.state_root,
        receipts_root=payload.receipts_root,
        logs_bloom=payload.logs_bloom,
        prev_randao=payload.prev_randao,
        block_number=payload.block_number,
        gas_limit=payload.gas_limit,
        gas_used=payload.gas_used,
        timestamp=payload.timestamp,
        extra_data=payload.extra_data,
        base_fee_per_gas=payload.base_fee_per_gas,
        block_hash=payload.block_hash,
        transactions_root=hash_tree_root(payload.transactions),
        withdrawals_root=hash_tree_root(payload.withdrawals),
        blob_gas_used=payload.blob_gas_used,
        excess_blob_gas=payload.excess_blob_gas,
        # [New in EIP7928]
        block_access_list_root=hash_tree_root(payload.block_access_list),
    )
Purpose: Process execution payloads, now including BAL commitment. What changes vs Fulu:

Only ONE line is added:

block_access_list_root=hash_tree_root(payload.block_access_list),

This is the entire CL logic for EIP-7928. Everything else is unchanged.

Detailed breakdown:
LinesPurposeEIP-7928 Change
131-140Verify parent hash, randao, timestampNone
141-145Verify blob commitment countNone
147-150Compute versioned hashesNone
152-159Call EL for validationNone (BAL inside payload)
162-181Update latest_execution_payload_headerAdd block_access_list_root
Where BAL validation actually happens:

The CL does NOT validate the BAL contents. It calls:

execution_engine.verify_and_notify_new_payload(NewPayloadRequest(...))

This triggers the EL's engine_newPayloadV5, which:

  • Executes transactions
  • Builds the expected BAL
  • Computes keccak256(expected_bal_rlp)
  • Compares against block.header.block_access_list_hash
  • Returns VALID or INVALID
The CL only cares about the boolean result. If the EL says VALID, the CL trusts that the BAL is correct.

Why hash_tree_root on bytes?

When you call hash_tree_root(ByteList), SSZ:

  • Pads the bytes to a multiple of 32
  • Chunks into 32-byte leaves
  • Merkleizes the chunks
  • Mixes in the length
This produces a 32-byte root that commits to the exact byte sequence. Any change to the BAL bytes changes the root.

Edge case: Empty BAL:

For blocks with no state changes (theoretically impossible in practice due to system calls), the BAL is 0xc0 (empty RLP list):

block_access_list = b'\xc0'  # RLP for []
block_access_list_root = hash_tree_root(ByteList(b'\xc0'))

The CL handles this identically to any other BAL—it just commits to the bytes.

Cross-references:
  • Section 08: Block Processing — EL-side block_access_list_hash validation
  • Section 11: Engine API — engine_newPayloadV5 validation flow

P2P Interface Modifications

Modified compute_fork_version (p2p-interface.md, lines 19-35)

def compute_fork_version(epoch: Epoch) -> Version:
    """
    Return the fork version at the given ``epoch``.
    """
    if epoch >= EIP7928_FORK_EPOCH:
        return EIP7928_FORK_VERSION
    if epoch >= FULU_FORK_EPOCH:
        return FULU_FORK_VERSION
    if epoch >= ELECTRA_FORK_EPOCH:
        return ELECTRA_FORK_VERSION
    if epoch >= DENEB_FORK_EPOCH:
        return DENEB_FORK_VERSION
    if epoch >= CAPELLA_FORK_EPOCH:
        return CAPELLA_FORK_VERSION
    if epoch >= BELLATRIX_FORK_EPOCH:
        return BELLATRIX_FORK_VERSION
    if epoch >= ALTAIR_FORK_EPOCH:
        return ALTAIR_FORK_VERSION
    return GENESIS_FORK_VERSION
Purpose: Return the correct fork version for network protocol identification. Why fork versions matter:

Fork versions are 4-byte identifiers used throughout the CL networking stack:

UsagePurpose
ENR eth2 fieldPeer discovery filtering
Gossipsub topicsTopic name construction
ForkDigestDomain separation in messages
SigningDataSignature domain separation
When EIP-7928 activates, nodes MUST use EIP7928_FORK_VERSION to:
  • Find peers on the correct fork
  • Subscribe to the correct gossip topics
  • Validate signatures with the correct domain
Network partition risk:

If a node uses the wrong fork version:

  • Pre-fork nodes reject post-fork blocks (different topics)
  • Post-fork nodes reject pre-fork attestations (different domain)
  • Network partitions along the fork boundary
This is intentional—it prevents accidental cross-fork message acceptance.

Order of checks:

The function checks forks in reverse chronological order:

EIP7928 → Fulu → Electra → Deneb → Capella → Bellatrix → Altair → Genesis

This ensures the most recent applicable fork is returned. For epoch = 500000:

  • If EIP7928_FORK_EPOCH = 450000, return EIP7928_FORK_VERSION
  • If EIP7928_FORK_EPOCH = 600000, fall through to Fulu check
What's NOT in this spec:

Notably absent from the p2p-interface.md:

  • Topic changes (none needed—payload format changes, not topic structure)
  • Req/resp changes (none needed—EIP-7928 doesn't add new CL requests)
  • Gossip validation changes (none needed—CL treats BAL as opaque)
This is because EIP-7928 is execution-layer focused. The CL networking changes are minimal.


Cross-Layer Data Flow

Block Production Flow

1. CL: Request payload via engine_forkchoiceUpdatedV4 (with PayloadAttributesV4)
   ↓
2. EL: Build block, record state accesses, construct BAL
   ↓
3. EL: Return ExecutionPayloadV4 via engine_getPayloadV6
   - Includes blockAccessList (RLP bytes)
   ↓
4. CL: Wrap in BeaconBlock
   - block.body.execution_payload.block_access_list = <RLP bytes>
   ↓
5. CL: Gossip BeaconBlock
   ↓
6. Validators: Attest to block (includes execution payload commitment)

Block Validation Flow

1. CL: Receive BeaconBlock via gossip
   ↓
2. CL: Run process_execution_payload
   ↓
3. CL: Call verify_and_notify_new_payload(NewPayloadRequest)
   - Passes execution_payload (with block_access_list) to EL
   ↓
4. EL: Execute transactions, build expected BAL
   ↓
5. EL: Compute keccak256(expected_bal_rlp)
   ↓
6. EL: Compare against block.header.block_access_list_hash
   ↓
7. EL: Return {status: VALID} or {status: INVALID}
   ↓
8. CL: If VALID, update state.latest_execution_payload_header
   - Computes block_access_list_root = hash_tree_root(block_access_list)

Gotchas and Edge Cases

1. Two Different Hashes for the Same Data

The BAL has TWO commitments:

  • EL: keccak256(rlp_bytes) in block header
  • CL: hash_tree_root(ByteList(rlp_bytes)) in ExecutionPayloadHeader
These are NOT interchangeable. Don't confuse them in proofs.

2. SSZ ByteList Length Limit

The BlockAccessList type uses MAX_BYTES_PER_TRANSACTION (1 GiB). This is an SSZ encoding limit, not an operational limit. Actual BALs are constrained by gas limits to ~megabytes.

3. No CL Validation of BAL Semantics

The CL never parses the BAL. It's opaque bytes. Invalid RLP, wrong accounts, missing slots—all caught by the EL, not the CL.

4. Fork Version Must Match

Post-EIP-7928, nodes MUST use EIP7928_FORK_VERSION. Mixed versions cause network partitioning.

5. Header Field Order

block_access_list_root is the LAST field in ExecutionPayloadHeader. Adding it elsewhere would break SSZ generalized indices.

Summary

EIP-7928's consensus layer changes are minimal:

ComponentChange
TypesAdd BlockAccessList = ByteList[MAX_BYTES_PER_TRANSACTION]
ExecutionPayloadAdd block_access_list: BlockAccessList field
ExecutionPayloadHeaderAdd block_access_list_root: Root field
BeaconStateIndirectly via latest_execution_payload_header type
process_execution_payloadAdd one line: block_access_list_root=hash_tree_root(...)
P2PAdd EIP7928_FORK_VERSION to compute_fork_version
The CL is a carrier, not an interpreter. All semantic validation happens in the EL via the Engine API.

Section 13: Networking and Synchronization

Source Files

  • specs/EIPs/EIPS/eip-7928.md (networking-related sections)
  • specs/execution-apis/src/engine/amsterdam.md (Engine API retrieval methods)

Overview

This section documents how Block-Level Access Lists (BALs) are transmitted across the network. Unlike transactions (eth protocol) or blobs (CL gossip + DAS), BALs follow a unique path: they flow exclusively through the Engine API between the Execution Layer and Consensus Layer, with no dedicated peer-to-peer protocol.

This design represents a deliberate architectural choice with significant implications for synchronization, pruning, and the trust model between layers.

Key Finding: No eth Protocol Extension

EIP-7928 does not define an eth/71 or any other devp2p protocol for BAL transmission.

The PROJECT.md reference to EIP-8070 as the "eth/71 Protocol" for BALs is incorrect. EIP-8070 defines eth/71 for the Sparse Blobpool feature (custody-aligned blob sampling)—a completely separate proposal unrelated to Block Access Lists.

What EIP-7928 Actually Specifies

From the EIP text:

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

And:

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

This means:

  • BALs are not part of the RLP-encoded block body used in eth protocol messages (GetBlockBodies, BlockBodies)
  • BALs are transmitted as part of ExecutionPayload via Engine API
  • Historical BAL retrieval uses dedicated Engine API methods

Why No Dedicated eth Protocol?

Several factors likely drove this design:

1. CL Already Coordinates Sync

Post-merge, the Consensus Layer drives block synchronization. The EL receives blocks through engine_newPayloadVX calls. Adding BAL retrieval to the same flow is natural.

2. BALs Couple Tightly to Execution

BALs are computed output of block execution, not user-submitted data. They must match exactly what the EL computes. Receiving BALs from untrusted peers would require full re-execution to validate—negating any benefit.

3. Avoiding Protocol Complexity

Adding new eth protocol messages requires coordination across all EL clients, protocol negotiation, and backwards compatibility handling. Using the existing Engine API avoids this.

4. Storage and Pruning Locality

BALs are used primarily for re-execution optimization and state reconstruction. Keeping them on the EL side (accessible via Engine API) allows EL-specific pruning policies.

Engine API Methods for BAL Retrieval

engine_getPayloadBodiesByHashV2

# Request
method: engine_getPayloadBodiesByHashV2
params:
  - blockHashes: Array[DATA, 32 Bytes]  # Blocks to retrieve
timeout: 10s

# Response
result: Array[ExecutionPayloadBodyV2 | null]
Specification (from amsterdam.md lines 104-120):
#### Specification

This method follows the same specification as
[`engine_getPayloadBodiesByHashV1`](./shanghai.md#engine_getpayloadbodiesbyhashv1)
with the following additions:

1. Client software **MUST** set the `blockAccessList` field to `null` for
   blocks that predate the Amsterdam fork activation.

2. Client software **MUST** set the `blockAccessList` field to `null` if the
   block access list has been pruned from storage.
Key semantics:
  • Returns null for pre-Amsterdam blocks (backwards compatibility)
  • Returns null for pruned BALs (allows EL to manage storage)
  • Timeout of 10 seconds allows batch retrieval

engine_getPayloadBodiesByRangeV2

# Request
method: engine_getPayloadBodiesByRangeV2
params:
  - start: QUANTITY, 64 Bits   # Starting block number
  - count: QUANTITY, 64 Bits   # Number of blocks
timeout: 10s

# Response
result: Array[ExecutionPayloadBodyV2 | null]
Specification (from amsterdam.md lines 126-143):
#### Specification

This method follows the same specification as
[`engine_getPayloadBodiesByRangeV1`](./shanghai.md#engine_getpayloadbodiesbyrangev1)
with the following additions:

1. Client software **MUST** set the `blockAccessList` field to `null` for
   blocks that predate the Amsterdam fork activation.

2. Client software **MUST** set the `blockAccessList` field to `null` if the
   block access list has been pruned from storage.
Key semantics:
  • Range-based retrieval for efficient batch sync
  • Same null semantics as hash-based retrieval
  • Useful for checkpoint sync scenarios

ExecutionPayloadBodyV2 Structure

From amsterdam.md lines 33-42:

### ExecutionPayloadBodyV2

This structure has the syntax of
[`ExecutionPayloadBodyV1`](./shanghai.md#executionpayloadbodyv1) and appends
the new field: `blockAccessList`.

- `transactions`: `Array of DATA` - Array of transaction objects...
- `withdrawals`: `Array of WithdrawalV1` - Array of withdrawals...
- `blockAccessList`: `DATA|null` - RLP-encoded block access list as defined
  in [EIP-7928](https://eips.ethereum.org/EIPS/eip-7928). Value is `null`
  for blocks produced before Amsterdam or if the data has been pruned.
Design notes:
  • BAL is included alongside transactions and withdrawals
  • null serves double duty: pre-fork and pruned states
  • No separate field for "BAL unavailable but should exist"

Synchronization Flow

Block Production (Builder/Proposer)

┌─────────────────────────────────────────────────────────────────────┐
│                        BLOCK PRODUCTION                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. CL requests payload build                                       │
│     ┌──────┐  engine_forkchoiceUpdatedV4  ┌──────┐                 │
│     │  CL  │ ────────────────────────────→│  EL  │                 │
│     └──────┘  (payloadAttributes)          └──────┘                 │
│                                               │                     │
│  2. EL builds block, executes txs, collects BAL                    │
│                                               │                     │
│  3. CL retrieves built payload                │                     │
│     ┌──────┐  engine_getPayloadV6           │                      │
│     │  CL  │ ←──────────────────────────────┤                      │
│     └──────┘                                 │                      │
│     Response includes:                       │                      │
│     - ExecutionPayloadV4 (with blockAccessList)                    │
│     - BlobsBundle                                                   │
│     - executionRequests                                             │
│                                                                     │
│  4. CL broadcasts beacon block                                      │
│     - Beacon block includes execution_payload with BAL              │
│     - BAL hash committed in block header                            │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Block Validation (Attester/Full Node)

┌─────────────────────────────────────────────────────────────────────┐
│                        BLOCK VALIDATION                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. CL receives beacon block via gossip                            │
│     - Extracts ExecutionPayload (including blockAccessList)        │
│                                                                     │
│  2. CL sends payload for validation                                │
│     ┌──────┐  engine_newPayloadV5          ┌──────┐                │
│     │  CL  │ ─────────────────────────────→│  EL  │                │
│     └──────┘  (ExecutionPayloadV4)          └──────┘                │
│                                               │                     │
│  3. EL validates:                             │                     │
│     a. Executes transactions                 │                      │
│     b. Computes actual BAL                   │                      │
│     c. Compares with provided blockAccessList│                      │
│     d. Verifies keccak256(BAL) matches header hash                 │
│                                               │                     │
│  4. EL returns validation result              │                     │
│     ┌──────┐  PayloadStatus                  │                      │
│     │  CL  │ ←──────────────────────────────┤                      │
│     └──────┘  (VALID/INVALID)                │                      │
│                                                                     │
│  5. If VALID: EL stores BAL separately                             │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Historical Sync (Checkpoint Sync / Backfill)

┌─────────────────────────────────────────────────────────────────────┐
│                      HISTORICAL SYNC                                │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Scenario: Node syncing from checkpoint, needs historical BALs     │
│                                                                     │
│  1. CL identifies blocks needing BAL data                          │
│                                                                     │
│  2. CL requests from EL (if EL has stored them)                    │
│     ┌──────┐  engine_getPayloadBodiesByRangeV2  ┌──────┐           │
│     │  CL  │ ──────────────────────────────────→│  EL  │           │
│     └──────┘  (start, count)                     └──────┘           │
│                                                    │                │
│  3. EL returns stored BALs (or null if pruned)    │                 │
│                                                    │                │
│  PROBLEM: If EL has pruned BALs, how does CL get them?             │
│                                                                     │
│  Options:                                                           │
│  a. CL fetches from beacon chain archive peers                     │
│     - BALs are part of ExecutionPayload in beacon blocks           │
│  b. EL re-executes blocks to regenerate BALs                       │
│     - Defeats purpose of BALs for sync optimization                │
│  c. Separate archive service                                        │
│     - Not specified in EIP                                          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

The Sync Gap: EL-to-EL BAL Retrieval

The Problem

Consider these scenarios:

  • EL-only sync: An EL client syncing directly from peers (pre-merge style, or execution sync without CL)
  • EL archive node: Node that wants full history but CL has pruned old beacon blocks
  • Cross-EL data sharing: EL-to-EL requests for specific blocks
In all cases, there's no mechanism for an EL to request BALs from another EL peer.

Why This Might Be Intentional

Post-Merge Reality: EL clients don't sync independently anymore. The CL drives all synchronization. Every EL receives blocks via Engine API from its paired CL. Trust Model: BALs received from untrusted EL peers would need full re-execution to validate. At that point, you've computed the BAL yourself—there's no benefit to receiving it. CL as Archive: Beacon chain already stores the full ExecutionPayload (including BAL). Historical data retrieval can go through CL beacon chain sync.

What's Missing

The EIP and Engine API spec don't address:

  • Pruning coordination: How does CL know if EL has pruned BALs?
  • Regeneration policy: When should EL regenerate BALs vs. signal unavailability?
  • Archive expectations: Must archive nodes store BALs indefinitely?

Weak Subjectivity Consideration

From EIP-7928:

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

This defines a minimum retention period (~2 weeks at 12-second slots), but:

  • Doesn't define what happens for older blocks
  • Doesn't specify how nodes sync BALs for the WSP window if they've been offline

Comparison to Other Data Types

Data TypeTransmission MethodPeer-to-Peer ProtocolTrust Model
Transactionseth protocol (Transactions, PooledTransactions)eth/68+Untrusted, mempool validation
Block Headerseth protocol (GetBlockHeaders, BlockHeaders)eth/68+Chain validation
Block Bodieseth protocol (GetBlockBodies, BlockBodies)eth/68+Re-execution validates
BlobsCL gossip + DASblob_sidecar_subnet_*KZG commitment validation
BALsEngine API onlyNoneRe-execution required
BALs are unique: They're the only execution-related data type without peer-to-peer retrieval.

Design Implications

For Client Implementers

  • Storage decisions are local: EL decides pruning policy; CL queries and handles nulls
  • Re-execution fallback: If BAL is unavailable, re-execute to regenerate
  • No peer discovery needed: BAL availability doesn't affect peering

For Protocol Designers

  • Engine API is the boundary: All BAL flow crosses EL↔CL via Engine API
  • CL holds authority: CL decides when to request BALs, handles unavailability
  • Future extensions possible: Could add eth protocol messages later if needed

For Node Operators

  • Archive nodes need coordination: EL and CL archive policies should align
  • Pruning has consequences: Pruned BALs require re-execution to regenerate
  • WSP retention is mandatory: Must keep BALs for ~2 weeks minimum

Potential Future Extensions

If EL-to-EL BAL retrieval becomes necessary, possible approaches:

Option A: eth Protocol Extension

# New messages for hypothetical eth/72

GetBlockAccessLists (0x15)
  [request_id: P, [block_hash_0: B32, block_hash_1: B32, ...]]

BlockAccessLists (0x16)
  [request_id: P, [bal_0: B, bal_1: B, ...]]
  # bal is RLP-encoded BlockAccessList or empty for unavailable
Pros: Standard eth protocol pattern, works with existing infrastructure Cons: Requires full re-execution to validate received BALs

Option B: Portal Network

Add BAL content type to Portal Network for light client access.

Pros: Designed for historical data retrieval Cons: Portal Network adoption, additional infrastructure

Option C: Accept Current Design

The current Engine API approach may be sufficient:

  • Post-merge, all sync goes through CL
  • CL beacon chain stores ExecutionPayloads with BALs
  • Re-execution is always possible as fallback

Summary

EIP-7928 takes a minimalist approach to BAL networking:

  • No dedicated protocol: BALs flow through Engine API only
  • CL coordinates: Consensus Layer requests BALs via retrieval methods
  • Storage is EL-local: EL decides retention, returns null when unavailable
  • Re-execution fallback: Always possible to regenerate BALs
  • Gap acknowledged: EL-to-EL retrieval not specified, may not be needed
This design reflects post-merge reality: the Consensus Layer drives synchronization, and the Engine API is the definitive interface between layers.

Cross-References

  • Section 11: Engine API — Full specification of all Engine API methods including BAL fields
  • Section 12: Consensus Layer — How CL handles ExecutionPayload and BAL validation
  • Section 08: Block Processing — BAL validation during process_block
  • Section 01: RLP Types — BAL encoding format transmitted via Engine API

Section 14: EIP Overview

Source Files

  • specs/EIPs/EIPS/eip-7928.md (549 lines)

Overview

This section annotates the canonical EIP-7928 text itself—the authoritative specification from which all implementations derive. While previous sections covered the EELS implementation in detail, this section examines the EIP's design rationale, normative requirements, edge cases, and security considerations.

EIP-7928 is unusually comprehensive for an EIP, spanning both specification and implementation guidance. Understanding the EIP text itself is essential because:

  • It's the source of truth—implementations must conform to the EIP, not vice versa
  • It explains WHY—rationale sections capture design decisions that aren't in code
  • It defines the edge cases—normative behavior for corner cases that might seem arbitrary

EIP Metadata (lines 1-12)

---
eip: 7928
title: Block-Level Access Lists
description: Enforced block access lists with state locations and post-transaction state diffs
author: Toni Wahrstätter (@nerolation), Dankrad Feist (@dankrad), Francesco D`Amato (@fradamt), 
        Jochem Brouwer (@jochem-brouwer), Ignacio Hagopian (@jsign)
discussions-to: https://ethereum-magicians.org/t/eip-7928-block-level-access-lists/23337
status: Draft
type: Standards Track
category: Core
created: 2025-03-31
---
Author composition: The author list reflects the cross-cutting nature of BALs:
  • Protocol researchers (Toni, Dankrad, Francesco): Design and rationale
  • Client developers (Jochem, Ignacio): Implementation feasibility and edge cases
Category: Core: This is a consensus-critical change. Every node must compute identical BALs or the network partitions. This elevates the precision requirements beyond typical EIPs.

Abstract (lines 14-17)

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.
Four capabilities, one data structure:
CapabilityHow BAL Enables It
Parallel disk readsAll accessed addresses known upfront → prefetch simultaneously
Parallel tx validationPost-values included → verify tx i without waiting for tx i-1
Parallel state rootAll modified accounts known → hash trie paths concurrently
Executionless state updatesDiff-only sync: apply changes without replaying EVM
The key insight: BALs transform block validation from inherently sequential (execute tx 1, then tx 2, ...) to embarrassingly parallel. The cost is ~70KB of additional data per block—a trade-off the EIP argues is worthwhile.

Motivation (lines 19-31)

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
- Parallel post-state root calculation
- State reconstruction without executing transactions
- Reduced execution time to `parallel IO + parallel EVM`

Why EIP-2930 Didn't Solve This

EIP-2930 introduced optional access lists per transaction. Problems:
  • Not enforced: Transactions can access addresses not in their access list
  • Superset, not exact set: Lists are hints for gas discounts, not commitments
  • Per-transaction, not per-block: Parallel execution still requires knowing cross-tx conflicts
BALs differ fundamentally:
  • Enforced: Block invalid if BAL doesn't match execution
  • Exact set: Every accessed address, nothing more
  • Post-values included: Know final state without executing

The Execution Parallelism Problem

Consider a block with 200 transactions. Traditional execution:

T1 → T2 → T3 → ... → T200
     |
     Each must finish before next starts
     (might write what next reads)

With BALs:

T1 ─┬── T2 ─┬── T3 ─┬── ... ─┬── T200
    │       │       │        │
    └───────┴───────┴────────┘
    All execute in parallel
    (BAL provides pre/post values)

The BAL tells you: "T47 reads slot X, T89 writes slot X." You can execute them independently because the BAL provides T47's read value and T89's write value.


Block Structure Modification (lines 33-54)

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.

Why Hash in Header, Data Elsewhere?

The block header includes block_access_list_hash, not the BAL itself. This mirrors transactions (header has transactions_root, body has transactions).

Rationale:
  • Header stays small: Headers propagate faster than bodies
  • Data can be pruned: Old BALs can be discarded; hash remains for verification
  • Consistent with Ethereum's data model: Commitment in header, data in body/payload

Empty Block Handling

# When no state changes present:
block_access_list_hash = keccak256(rlp.encode([]))  # = 0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347
Why keccak256(rlp([])) and not keccak256(b'')?

The empty hash is 0x1dcc4de8... (the RLP empty list hash), not 0xc5d2460... (empty bytes hash). This is because:

  • BALs are always RLP-encoded lists, even when empty
  • Consistent encoding → consistent hashing
  • Matches the "empty withdrawals" pattern from EIP-4895
Cross-reference: See Section 08: Block Processing for make_block_access_list handling of empty blocks.


RLP Data Structures (lines 56-95)

The EIP defines the type aliases that appear in the EELS implementation:

# 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  # 0 = pre-exec, 1..n = txs, n+1 = post-exec
Balance = uint256          # Post-transaction balance in wei
Nonce = uint64             # Account nonce

BlockAccessIndex Range

BlockAccessIndex is uint16, supporting up to 65,535 transactions per block. Current Ethereum blocks have at most ~1,500 transactions, leaving headroom for future scaling. Why not uint8? Would limit to 255 transactions—already exceeded by some blocks. Why not uint32? Unnecessary; 16 bits saves 2 bytes per change entry.

The Core Structures

# StorageChange: [block_access_index, new_value]
StorageChange = [BlockAccessIndex, StorageValue]

# SlotChanges: [slot, [changes]]
SlotChanges = [StorageKey, List[StorageChange]]

# AccountChanges: [address, storage_changes, storage_reads, balance_changes, nonce_changes, code_changes]
AccountChanges = [
    Address,                    # address
    List[SlotChanges],          # storage_changes
    List[StorageKey],           # storage_reads
    List[BalanceChange],        # balance_changes
    List[NonceChange],          # nonce_changes
    List[CodeChange]            # code_changes
]
Design decision: Why separate storage_changes and storage_reads?

Storage slots fall into three categories:

  • Written (value changed): Goes in storage_changes
  • Read-only: Goes in storage_reads
  • Written with same value (no-op write): Goes in storage_reads
This separation enables:
  • Smaller diffs: Reads don't carry values (just keys)
  • Clear semantics: storage_changes = actual state mutations
  • Parallel execution: Know which reads can't conflict with writes
Cross-reference: See Section 01: RLP Types for the implementation.


Scope and Inclusion (lines 97-124)

This is the most critical normative section—it defines exactly what MUST appear in the BAL.

Must Include

- Addresses with state changes (storage, balance, nonce, or code).
- Addresses accessed without state changes, including:
  - Targets of BALANCE, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH opcodes
  - Targets of CALL, CALLCODE, DELEGATECALL, STATICCALL (even if they revert)
  - Target addresses of CREATE/CREATE2 if the target account is accessed
  - Transaction sender and recipient addresses (even for zero-value transfers)
  - COINBASE address if the block contains transactions or withdrawals
  - Beneficiary addresses for SELFDESTRUCT
  - System contract addresses accessed during pre/post-execution
  - Withdrawal recipient addresses
  - Precompiled contracts when called or accessed

Why Include Unchanged Addresses?

The EIP explicitly requires: "Addresses with no state changes MUST still be present with empty change lists."

Rationale: BALs serve two purposes:
  • State diffs (what changed) — requires only changed addresses
  • IO scheduling (what was accessed) — requires ALL accessed addresses
For parallel disk reads, you need to prefetch every address touched, not just those modified. An EXTCODESIZE on address X doesn't change state but still requires reading X's code from disk.

EIP-2930 Access Lists Are NOT Included

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.

This is important: EIP-2930 access lists are hints that may be supersets of actual access. BALs are precise—only what was actually accessed.

Gotcha: A transaction might include 0xABCD in its EIP-2930 access list but never actually touch it. That address must NOT appear in the BAL.

Gas Validation Before State Access (lines 126-170)

This section specifies when state access occurs—critical for determining BAL inclusion.

Two-Phase Validation

State-accessing opcodes perform gas validation in two phases:
- Pre-state validation: Gas costs determinable without state access
- Post-state validation: Gas costs requiring state access

Pre-state validation MUST pass before any state access occurs.
Why this matters: If an opcode fails gas validation, does the target address appear in the BAL?

The answer depends on which validation failed:

  • Pre-state fails: Target NOT accessed, NOT in BAL
  • Post-state fails: Target WAS accessed (to compute post-state costs), IS in BAL

The Validation Table

| Instruction    | Pre-state Validation                                          |
|----------------|---------------------------------------------------------------|
| BALANCE        | access_cost                                                   |
| CALL           | access_cost + memory_expansion + GAS_CALL_VALUE (if value > 0)|
| SLOAD          | access_cost                                                   |
| SSTORE         | More than GAS_CALL_STIPEND available                          |
| CREATE         | memory_expansion + INITCODE_WORD_COST + GAS_CREATE            |
SSTORE is special: The validation is "more than GAS_CALL_STIPEND available", not a specific gas amount. This is the EIP-2200 reentrancy guard. Cross-reference: See Section 09: VM Integration for opcode-level implementation.

EIP-7702 Delegation Complexity

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.

This creates a two-hop access pattern:

CALL 0xTargetEOA
  │
  ├── Access 0xTargetEOA (to check for delegation)
  │   └── BAL: include 0xTargetEOA
  │
  └── If delegated to 0xDelegatedContract:
      ├── Check access_cost for 0xDelegatedContract
      │   ├── If passes: Access and include in BAL
      │   └── If fails: Do NOT include in BAL

The original target is always included (it was accessed to resolve delegation). The delegated address is only included if its access_cost check passes.


Ordering and Determinism (lines 172-182)

The following ordering rules MUST apply:
- Accounts: Lexicographic by address
- storage_changes: Slots lexicographic by storage key; within each slot, changes by block access index
- storage_reads: Lexicographic by storage key
- balance_changes, nonce_changes, code_changes: By block access index (ascending)

Why Strict Ordering?

BALs are consensus-critical. Two clients processing the same block must produce identical BALs. Without deterministic ordering, semantically equivalent BALs could have different encodings → different hashes → consensus failure.

Lexicographic ordering was chosen because:
  • Simple: Sort bytes numerically (big-endian)
  • Deterministic: No locale or implementation dependencies
  • Efficient: Single-pass sorting with standard comparators
Cross-reference: See Section 07: Builder Finalization for the sorting implementation.

BlockAccessIndex Assignment (lines 184-192)

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

The Three Phases

IndexPhaseExamples
0Pre-executionEIP-2935 (store parent hash), EIP-4788 (store beacon root)
1..nTransactionsUser transactions in block order
n+1Post-executionEIP-4895 (withdrawals), EIP-7002/7251 (validator operations)
Why separate indices for system calls?

System calls are not transactions but do modify state. Separating them:

  • Allows validators to verify pre/post system state independently
  • Enables clear attribution of state changes
  • Matches the execution phases in process_block
Gotcha: Index 0 is pre-execution, NOT "first transaction." A block with 3 transactions uses indices 0, 1, 2, 3, 4 (not 1, 2, 3).


Recording Semantics (lines 194-238)

Storage Write vs Read Classification

- 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
  - Slots written with unchanged values (no-op writes)
The no-op write rule: If you SSTORE the same value that was already there, it's recorded as a read, not a write. Why? Post-execution values in storage_changes are for state reconstruction. If a slot's value didn't change, including it in storage_changes would be misleading—the diff-only sync path would see a "change" that isn't. Implementation check: See Section 04: State Tracker Recording for how record_storage_write handles this.

Balance Recording Rules

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.

This is the "net-zero filter" for balances. Example:

1. Alice sends 1 ETH to Bob  (Alice: -1 ETH)
2. Bob sends 1 ETH to Alice  (Alice: +1 ETH)
Result: Alice's balance unchanged, no balance_change recorded
But the address must be included: Even with no balance_change, Alice appears in the BAL because she was accessed. She just has an empty balance_changes list.

Edge Cases (lines 240-310)

The EIP specifies behavior for numerous edge cases. These are normative—implementations must handle them exactly as described.

COINBASE / Fee Recipient

The COINBASE address MUST be included if it experiences any state change. It MUST NOT 
be included for blocks with no transactions, provided there are no other state changes. 
If the COINBASE reward is zero, the COINBASE address MUST be included as a read.

Three scenarios:

ScenarioCOINBASE in BAL?balance_change?
Block has transactions, non-zero rewardYesYes
Block has transactions, zero rewardYesNo (read-only)
Empty block, no withdrawalsNoN/A

SELFDESTRUCT (In-Transaction)

Accounts destroyed within a transaction MUST be included in AccountChanges without 
nonce or code changes. However, if the account had a positive balance pre-transaction, 
the balance change to zero MUST be recorded.
Why no nonce/code changes? The account is destroyed—there's no "post" state for nonce/code. But the balance going to zero IS a state change that must be recorded. Storage handling: "Storage keys within the self-destructed contracts that were modified or read MUST be included as a storage_reads entry."

After SELFDESTRUCT, all storage is considered read-only (you can't write to a dead contract). Any keys that were modified before destruction go to storage_reads because the final state is "gone" (conceptually zero/empty).

System Contract Storage Diffs

- EIP-2935 (block hash): Record single updated storage slot in ring buffer
- EIP-4788 (beacon root): Record two updated storage slots in ring buffer
- EIP-7002 (withdrawals): Record storage slots 0-3 after dequeuing
- EIP-7251 (consolidations): Record storage slots 0-3 after dequeuing

These are explicit: system contracts have specific storage layouts, and the BAL must capture exactly those slots.

Why enumerate? Implementers might otherwise miss system contract state changes that happen outside normal transaction execution.

Engine API Section (lines 312-370)

ExecutionPayloadV4 extends ExecutionPayloadV3 with:
- blockAccessList: RLP-encoded block access list

engine_newPayloadV5 validates execution payloads:
- Accepts ExecutionPayloadV4 structure
- Validates that computed access list matches provided blockAccessList
- Returns INVALID if access list is malformed or doesn't match

The Validation Flow

CL → engine_newPayloadV5(payload with BAL)
       │
       ├── EL computes block_access_list_hash = keccak256(payload.blockAccessList)
       ├── EL sets header.block_access_list_hash = computed_hash
       ├── EL executes block, generates actual_BAL
       └── EL compares: RLP(actual_BAL) == payload.blockAccessList?
             │
             ├── Match: VALID
             └── Mismatch: INVALID
Critical: The EL doesn't just hash and compare—it re-executes and regenerates the BAL. This ensures the BAL actually represents the block's execution.

Retrieval Methods

engine_getPayloadBodiesByHashV2: Returns ExecutionPayloadBodyV2 objects containing 
transactions, withdrawals, and blockAccessList
Purpose: Historical BAL retrieval for:
  • Syncing nodes that missed recent blocks
  • Rebuilding state without re-execution
  • Archive queries
Retention requirement: "The EL MUST retain BALs for at least the duration of the weak subjectivity period (~3533 epochs)"

This is ~15 days. After that, nodes can prune BALs and require full re-execution for sync.

Cross-reference: See Section 11: Engine API for method signatures.

State Transition Function (lines 372-450)

The EIP provides pseudocode for the complete validation flow:

def validate_block(execution_payload, block_header):
    # 1. Set hash in header
    block_header.block_access_list_hash = keccak(execution_payload.blockAccessList)
    
    # 2. Execute and collect
    actual_bal = execute_and_collect_accesses(execution_payload)
    
    # 3. Verify match
    assert rlp.encode(actual_bal) == execution_payload.blockAccessList

The Collection Process

def execute_and_collect_accesses(block):
    accesses = {}
    
    # Pre-execution (index = 0)
    track_system_contracts_pre(block, accesses, block_access_index=0)
    
    # Transactions (index = 1..n)
    for i, tx in enumerate(block.transactions):
        execute_transaction(tx)
        track_state_changes(tx, accesses, block_access_index=i+1)
    
    # Post-execution (index = n+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)
    
    return build_bal(accesses)

This pseudocode maps directly to the EELS implementation:

  • track_system_contracts_preprocess_pre_block in blocks.py
  • execute_transactionprocess_transaction in fork.py
  • track_state_changesStateTracker hooks
  • build_balmake_block_access_list in builder.py
Cross-reference: See Section 08: Block Processing for the implementation.


Concrete Example (lines 452-520)

The EIP provides a detailed example block. Key observations:

Example Structure

Pre-execution:
  - EIP-2935: Store parent hash (block_access_index = 0)

Transactions:
  1. Alice → Bob (1 ETH) + BALANCE check (block_access_index = 1)
  2. Charlie deploys via factory (block_access_index = 2)

Post-execution:
  - Withdrawal to Eve (block_access_index = 3)

The Resulting BAL

[
    # 0x0000F908... (Block hash contract) - storage write at index 0
    [0x0000F908..., [[slot, [[0, parent_hash]]]], [], [], [], []],
    
    # 0x2222... (checked by BALANCE) - accessed but unchanged
    [0x2222..., [], [], [], [], []],
    
    # 0xaaaa... (Alice) - balance and nonce change at index 1
    [0xaaaa..., [], [], [[1, post_balance]], [[1, 10]], []],
    
    # ... sorted lexicographically by address ...
]
Observations:
  • Addresses sorted lexicographically (0x0000... < 0x2222... < 0xaaaa...)
  • Accessed-but-unchanged addresses included with empty lists
  • Each change tagged with its block_access_index

Rationale Section (lines 522-560)

Why Include All Accessed Addresses?

Size vs parallelization: BALs include all accessed addresses (even unchanged) 
for complete parallel IO and execution.

Trade-off: Larger BALs (~70KB average) vs. complete prefetch capability.

Why Post-Values?

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

Alternative considered: Include only address+slot, require proofs for values. Rejected because:

  • Proofs are expensive (~1KB per value)
  • Would prevent diff-only sync
  • Adds verification complexity

RLP vs SSZ

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

SSZ was considered for CL compatibility. RLP chosen because:

  • EL natively uses RLP
  • Engine API already bridges EL↔CL serialization
  • No SSZ dependency in EL

Size Analysis (lines 562-590)

Average BAL size: ~72.4 KiB (compressed)

- Storage writes: ~29.2 KiB (40.3%)
- Storage reads: ~18.7 KiB (25.8%)
- Balance diffs: ~6.7 KiB (9.2%)
- Nonce diffs: ~1.1 KiB (1.5%)
- Code diffs: ~1.2 KiB (1.6%)
- Account addresses: ~7.7 KiB (10.7%)
- Touched-only addresses: ~3.5 KiB (4.8%)
- RLP overhead: ~4.4 KiB (6.1%)

Composition Analysis

Component% of BALWhy This Size?
Storage writes40%Full 32-byte values, 32-byte keys
Storage reads26%Keys only, no values
Balance diffs9%~300 balance changes per block
Addresses15%~1000 unique addresses per block
Key insight: Storage dominates. Optimizations should focus on storage encoding.

Compared to Current Block Sizes

~70KB average is smaller than current worst-case calldata blocks (~128KB). Network overhead is manageable.


Security Considerations (lines 592-610)

Validation Overhead

Validating access lists and balance diffs adds validation overhead but is essential 
to prevent acceptance of invalid blocks.
Attack vector: Without validation, an attacker could include a malformed BAL that passes hash check but misrepresents state changes. Mitigation: Full re-execution and BAL regeneration. Every validating node does this.

Block Size Impact

Increased block size impacts propagation but overhead (~70 KiB average) is 
reasonable for performance gains.
Trade-off analysis:
  • 70KB additional per block
  • At 12s block time: ~6KB/s additional bandwidth
  • Benefit: Potential 10x parallelization of execution
The ratio favors inclusion.

Backwards Compatibility (lines 612-618)

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

Breaking changes:

  • Block header: New block_access_list_hash field
  • Engine API: V5 methods with new payload format
  • ExecutionPayload: New blockAccessList field
  • Validation logic: New verification step
No path exists for gradual rollout—all nodes must upgrade simultaneously at the fork block.


Cross-Reference Summary

This EIP defines the specification that other sections implement:

EIP SectionImplementation
RLP Data StructuresSection 01: RLP Types
Scope and InclusionSection 04: State Tracker Recording
OrderingSection 07: Builder Finalization
Recording SemanticsSection 04, Section 06
Gas ValidationSection 09: VM Integration
Engine APISection 11: Engine API
Block ProcessingSection 08: Block Processing
CL IntegrationSection 12: Consensus Layer

Design Decisions and Alternatives

What Could Have Been Different

Choice MadeAlternative ConsideredWhy This Choice?
Block-level, not tx-levelPer-tx access listsBlock-level captures cross-tx dependencies
RLP encodingSSZ encodingNative EL format, no new dependencies
Post-values includedOnly addresses/slotsEnables diff-only sync
Include unchanged addressesOnly changed addressesEnables complete parallel IO
Lexicographic orderingInsertion orderDeterministic across implementations
Hash in header, data in payloadFull BAL in headerKeeps header small

Open Questions

  • Pruning policy: 3533 epochs is the minimum; should clients keep longer?
  • Compression: RLP is uncompressed; should the spec define compression?
  • Streaming validation: Can BAL validation happen during execution rather than after?

Conclusion

EIP-7928 is a comprehensive specification that enables fundamental changes to how Ethereum processes blocks. By committing to complete access information upfront, it transforms block validation from inherently sequential to embarrassingly parallel.

The ~70KB overhead per block is a conscious trade-off for:

  • 10x+ potential speedup in block validation
  • Diff-only state sync without re-execution
  • Parallel state root computation
  • Foundation for future statelessness work
The specification is precise, the edge cases are enumerated, and the implementation in EELS demonstrates feasibility. What remains is client adoption and real-world performance validation.