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
- 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
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?
| Alias | Underlying | Reason |
|---|---|---|
Address | Bytes20 | Ethereum addresses are exactly 20 bytes |
StorageKey | U256 | Storage slots are 256-bit keys |
StorageValue | U256 | Storage values are 256-bit |
CodeData | Bytes | Contract bytecode is variable-length |
BlockAccessIndex | Uint | See below |
Balance | U256 | Wei balances can exceed 2^64 |
Nonce | U64 | EIP-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)1ton= 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/CREATE2deploys code onceSELFDESTRUCT(in same tx) clears it once- EIP-7702 sets delegation once
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''
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)
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)
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)
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
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.pycallscompute_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
| Field | Key Type | Value Type | Purpose |
|---|---|---|---|
parent | - | StateChanges? | Links frame hierarchy |
block_access_index | - | Uint | Current 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) | U256 | Slot writes with tx attribution |
balance_changes | (addr, idx) | U256 | Balance after tx |
nonce_changes | (addr, idx, nonce) | - | Nonce increments (set, not dict) |
code_changes | (addr, idx) | Bytes | Code deployments/delegations |
pre_balances | addr | U256 | Pre-tx balance (for net-zero filter) |
pre_storage | (addr, slot) | U256 | Pre-tx storage (for net-zero filter) |
pre_code | addr | Bytes | Pre-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.
parent is None→ block frameparent.parent is None→ transaction frameparent.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,EXTCODEHASHtargetsCALL,DELEGATECALL,STATICCALL,CALLCODEtargetsCREATE,CREATE2deployments- 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_indexto track per-tx writes - Value is the new value, not delta
- Same
(addr, key, idx)overwrites previous (latest value wins within a tx)
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)
SELFDESTRUCTbalance transfer- Withdrawals
(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/CREATE2returns) - EIP-7702 delegation set (
0xef0100 + target) SELFDESTRUCTclears code (same-tx creation,new_code = b'')
(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)
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: Unionstorage_writes: Child overwrites (latest value wins)balance_changes: Child overwritesnonce_changes: Keep highest nonce per addresscode_changes: Child overwrites
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: Mergedstorage_writes: Converted to reads (write happened but was reverted)balance_changes: Discardednonce_changes: Discardedcode_changes: Discarded
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: Callsfilter_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:
| Field | If pre == post | Result |
|---|---|---|
| Storage | Write → Read | Slot still appears in BAL (as read) |
| Balance | Removed | Address may still appear (if touched) |
| Code | Removed | Address may still appear (if touched) |
| Nonce | N/A | Nonces only increment, never return to original |
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_
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)
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
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
WhyDict[U256, List[StorageChange]] for storage?
Storage changes need two levels of grouping:
- By slot (outer dict key)
- By transaction (inner list elements)
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.
WhySet[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
| Field | Structure | Why |
|---|---|---|
storage_changes | Dict[slot, List[change]] | Group by slot, track per-tx values |
storage_reads | Set[slot] | Just need "was read" flag |
balance_changes | List[change] | Track each tx's final balance |
nonce_changes | List[change] | Track each tx's nonce increment |
code_changes | List[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)
- 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
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
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
# 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 slotThe 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
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
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....
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
| Opcode | Effect |
|---|---|
BALANCE | Reads balance only |
EXTCODESIZE | Reads code size only |
EXTCODEHASH | Reads code hash only |
EXTCODECOPY | Reads code only |
CALL (value=0, no storage) | Touches callee but may not modify |
Cross-References
- Section 01 (RLP Types): The
StorageChange,BalanceChange,NonceChange,CodeChangetypes used here - Section 03-05 (State Tracker): The frame-based recording that feeds
StateChangesto the builder - Section 07 (Builder Finalization): The
_build_from_builderandbuild_block_access_listfunctions that consume the builder - Section 09 (VM Integration): Where the EVM calls these recording functions
Gotchas
1. Same-Transaction Semantics Vary by Field
| Field | Same-TX Behavior | Why |
|---|---|---|
| Storage | Last-write-wins | Final value matters |
| Balance | Last-write-wins | Final value matters |
| Nonce | Highest-wins | Nonces only increase |
| Code | Last-write-wins | Final 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_indexis in range - Whether
new_codefits in MAX_CODE_SIZE - Whether the slot/balance/nonce values are valid
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
| Function | Time Complexity | Space |
|---|---|---|
ensure_account | O(1) amortized | O(1) per new account |
add_storage_write | O(k) where k = writes to slot | O(1) per new write |
add_storage_read | O(1) amortized | O(1) per new slot |
add_balance_change | O(t) where t = txs touching account | O(1) per new tx |
add_nonce_change | O(t) | O(1) per new tx |
add_code_change | O(t) | O(1) per new tx |
add_touched_account | O(1) amortized | O(1) per new account |
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
AccountDatastructure becomes flatAccountChangestuples - Integration: How
StateChangesfrom the state tracker flows into the builder
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
| Opcode | Behavior |
|---|---|
BALANCE | Reads balance, no write |
EXTCODESIZE | Reads code length |
EXTCODEHASH | Reads code hash |
EXTCODECOPY | Copies 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
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
# 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)
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: Address0x0000...0001 comes before 0x0000...0002. This is natural Python bytes comparison.
Complexity Analysis
| Operation | Complexity |
|---|---|
| Iterate accounts | O(A) where A = unique addresses |
| Sort slot changes | O(S × T log T) where S = slots, T = txs per slot |
| Filter reads | O(R) where R = read slots |
| Sort storage_changes | O(S log S) |
| Sort storage_reads | O(R log R) |
| Sort balance/nonce/code | O(T log T) each |
| Final address sort | O(A log A) |
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
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 subsequentadd_* 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
Bytes32because storage keys are 32-byte hashes - Builder uses
U256because 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.
- 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
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
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.
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.
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
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
| Section | Relevance |
|---|---|
| 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)
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
Memory Profile
For a block with:
- 10,000 touched accounts
- 50,000 total storage accesses
- 15,000 balance changes (including COINBASE)
- Builder dict overhead: ~1-2 MB
- AccountData per account: ~600 bytes × 10,000 = ~6 MB
- Total: ~10-15 MB
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
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_hashfield in the block header - State Transition: How blocks are validated, including BAL hash verification
- Block Execution: The
apply_bodyfunction orchestrating BAL construction - Transaction Processing: How each transaction contributes to the BAL
- Post-Execution Operations: Withdrawals and system transactions
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
keccak256can verify proofs on-chain - Legacy alignment: State roots, transaction roots, receipts root all use keccak256
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
Adding a field to the header changes:
- RLP encoding of headers
- Block hash computation (
keccak256(rlp.encode(header))) - All existing tooling that parses headers
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.
| Comparison | TX Access List (EIP-2930) | Block Access List (EIP-7928) |
|---|---|---|
| Direction | Input | Output |
| Purpose | Warm addresses before execution | Record all accessed state |
| Validation | Pre-execution gas check | Post-execution hash match |
| Completeness | Optional (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
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
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.
| Phase | Index | Operations |
|---|---|---|
| Pre-execution | 0 | Beacon roots, history storage |
| Transaction i | i+1 | User transaction (0-indexed → 1-indexed in BAL) |
| Post-execution | N+1 | Withdrawals, consolidation requests |
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
increment_block_access_indexmoves from 0 → 1- Child frame inherits index 1
- Transaction 0's changes are attributed to index 1
# 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
# ... 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
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
SELFDESTRUCTaccounts (deprecated but still tracked) - Commit the transaction frame to the block frame
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 usesblock_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)
- If coinbase receives zero fees and is empty, it's destroyed
account_exists_and_is_emptycheck 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 failsprocess_checked_system_transaction: RaisesInvalidBlockon failure
Cross-References
| Topic | Section |
|---|---|
| StateChanges data structure | Section 03 |
| Frame management (create/commit) | Section 05 |
| Builder construction | Section 06 |
| Builder finalization | Section 07 |
| VM integration | Section 09 |
| Hash computation | Section 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)
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
StateChangesflows through Message → Evm → Instructions - Evm Class Extension: The new
state_changesfield 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
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 framemessage.tx_env.state_changes— the transaction frame (for pre-captures)message.block_env.state_changes— the block frame (root)
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.
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 statetrack_storage_write()— Records the write at call frame
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.
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 failstrack_nonce_change()— Sender's nonce increments regardless of outcomecreate_child_frame()— Child gets its own state changes frame
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
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_balancefor both (tx frame)
move_ether— Actual balance modification
track_balance_changefor 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 1capture_pre_code(b"")— Pre-code is always empty for creationtrack_code_change()— Only if code is non-empty after deployment
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:
| Field | Behavior |
|---|---|
gas_left | Added to parent |
logs | Appended to parent |
refund_counter | Added to parent |
accounts_to_delete | Union with parent |
accessed_addresses | Union with parent |
accessed_storage_keys | Union with parent |
state_changes | Via merge_on_success() |
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:
| Field | Behavior |
|---|---|
gas_left | Added to parent |
| Everything else | Discarded |
merge_on_failure() call only propagates reads and address touches; writes are converted to reads.
Opcode-to-Tracking Matrix
| Opcode | track_address | track_storage_read | track_storage_write | capture_pre_ | track_*_change |
|---|---|---|---|---|---|
| SLOAD | — | ✓ | — | — | — |
| SSTORE | — | ✓ | ✓ | pre_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_code | nonce_change, code_change |
| SELFDESTRUCT | ✓ (beneficiary) | — | — | pre_balance (both) | balance_change (both) |
- 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)
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_hashfield - Environment changes:
StateChangesthreading 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 frameblock_output.block_access_listcomputed duringapply_body()- Hash computed and validated against header
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 = 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:
| Phase | Block Access Index | Contents |
|---|---|---|
| System transactions | 0 | Beacon roots, history storage |
| Transaction 0 | 1 | First user transaction |
| Transaction 1 | 2 | Second user transaction |
| ... | ... | ... |
| Transaction N-1 | N | Last user transaction |
| Post-execution | N+1 | Withdrawals, request contracts |
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
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)
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
- 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
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
| Section | Relationship |
|---|---|
| Section 03 | StateChanges dataclass definition |
| Section 04 | track_ functions that record state accesses |
| Section 05 | Frame management (create, merge, commit) |
| Section 06-07 | Builder that consumes block frame |
| Section 08 | Block validation (header hash check) |
| Section 09 | VM interpreter hooks into state_changes |
| Section 11 | Engine 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()withStateChanges() - 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
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:
| Method | Version | Change |
|---|---|---|
engine_newPayloadV5 | V4 → V5 | Validates BAL in ExecutionPayloadV4 |
engine_getPayloadV6 | V5 → V6 | Returns BAL in ExecutionPayloadV4 |
engine_getPayloadBodiesByHashV2 | V1 → V2 | Returns BAL in ExecutionPayloadBodyV2 |
engine_getPayloadBodiesByRangeV2 | V1 → V2 | Returns BAL in ExecutionPayloadBodyV2 |
engine_forkchoiceUpdatedV4 | V3 → V4 | Uses PayloadAttributesV4 with slotNumber |
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
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.
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
blockAccessListis NOT nullable in V4. Pre-Amsterdam blocks use older payload versions.- The
blockHashincludes theblock_access_list_hashin the header—if the BAL is wrong, the hash won't match.
- 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.
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
- 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
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.
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
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
{
"status": "INVALID",
"latestValidHash": null,
"validationError": "blockAccessList mismatch at account 0x..."
}
latestValidHash: null— The EL doesn't know where the chain divergedvalidationError— Human-readable error (optional)
- 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.
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
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)
- 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
BALs can be large. For a worst-case block:
- ~6,000 unique addresses touched
- Each with multiple storage slots
- RLP overhead per entry
- Don't assume
nullmeans 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
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:
nullBALs 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"
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
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
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_newPayloadV4for an Amsterdam block - Validation passes (no BAL field expected)
- Block is accepted without BAL validation
- Chain split between V4 and V5 clients
-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
| Code | Name | When |
|---|---|---|
-32602 | Invalid params | Malformed request (missing fields, wrong types) |
-38003 | Invalid payload attributes | PayloadAttributes validation failed |
-38005 | Unsupported fork | Timestamp/method version mismatch |
- Error codes: Request-level problems (bad JSON, wrong method version)
- INVALID status: Payload-level problems (bad BAL, invalid state root)
Cross-References
| Section | Connection |
|---|---|
| Section 01: RLP Types | BAL encoding format for blockAccessList field |
| Section 06-07: BAL Builder | How BALs are constructed for getPayloadV6 |
| Section 08: Block Processing | BAL validation during newPayloadV5 |
| Section 12: Consensus Layer | CL containers that wrap these payloads |
| Section 13: eth/71 Protocol | Alternative BAL retrieval path (devp2p) |
Implementation Notes
Timeout Implications
| Method | Timeout | Implication |
|---|---|---|
getPayloadV6 | 1s | Block building must be fast enough |
getPayloadBodiesBy* | 10s | Historical queries can be slower |
forkchoiceUpdatedV4 | 8s | Complex state updates allowed |
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
getPayloadcall
Bandwidth Considerations
For syncing nodes, getPayloadBodiesByRangeV2 now returns significantly more data due to BALs. Clients should:
- Consider BAL size when setting
countparameter - 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
ExecutionPayloadcontainer - Commits to via
hash_tree_rootin theExecutionPayloadHeader - Passes to the EL for validation during
process_execution_payload
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
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
| Block type | Expected BAL size | Notes |
|---|---|---|
| Empty block | ~100 bytes | Only system calls (beacon root, withdrawals) |
| Typical mainnet | 10-100 KB | ~200 unique accounts, ~500 storage slots |
| DEX-heavy block | 100-500 KB | Many AMM pool interactions |
| Worst-case spam | 1-5 MB | Adversarial access pattern |
- Section 01: RLP Types — The actual encoding within these bytes
- Section 11: Engine API — How
blockAccessListis transmitted as rawDATA
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
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 field | SSZ container field | Encoding |
|---|---|---|
ExecutionPayloadV4.blockAccessList | ExecutionPayload.block_access_list | RLP bytes |
blockAccessList vs block_access_list) due to JSON/SSZ conventions.
Cross-references:
- Section 11: Engine API —
ExecutionPayloadV4structure
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:
| Container | Field | Type | Contains |
|---|---|---|---|
ExecutionPayload | block_access_list | BlockAccessList (bytes) | Full RLP-encoded BAL |
ExecutionPayloadHeader | block_access_list_root | Root (32 bytes) | hash_tree_root(block_access_list) |
- State size efficiency:
BeaconStatestores 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
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):
| Layer | Commitment | Hash function | Input |
|---|---|---|---|
| EL (Header) | block_access_list_hash | keccak256 | RLP bytes |
| CL (Header) | block_access_list_root | hash_tree_root | SSZ ByteList |
- 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
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_hashcomputation
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.
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_hashesandparent_beacon_block_root - EIP-7685 added
execution_requests
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:| Lines | Purpose | EIP-7928 Change |
|---|---|---|
| 131-140 | Verify parent hash, randao, timestamp | None |
| 141-145 | Verify blob commitment count | None |
| 147-150 | Compute versioned hashes | None |
| 152-159 | Call EL for validation | None (BAL inside payload) |
| 162-181 | Update latest_execution_payload_header | Add block_access_list_root |
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
VALIDorINVALID
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
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_hashvalidation - Section 11: Engine API —
engine_newPayloadV5validation 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:
| Usage | Purpose |
|---|---|
ENR eth2 field | Peer discovery filtering |
| Gossipsub topics | Topic name construction |
ForkDigest | Domain separation in messages |
SigningData | Signature domain separation |
EIP7928_FORK_VERSION to:
- Find peers on the correct fork
- Subscribe to the correct gossip topics
- Validate signatures with the correct domain
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
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, returnEIP7928_FORK_VERSION - If
EIP7928_FORK_EPOCH = 600000, fall through to Fulu check
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)
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))inExecutionPayloadHeader
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:
| Component | Change |
|---|---|
| Types | Add BlockAccessList = ByteList[MAX_BYTES_PER_TRANSACTION] |
ExecutionPayload | Add block_access_list: BlockAccessList field |
ExecutionPayloadHeader | Add block_access_list_root: Root field |
BeaconState | Indirectly via latest_execution_payload_header type |
process_execution_payload | Add one line: block_access_list_root=hash_tree_root(...) |
| P2P | Add EIP7928_FORK_VERSION to compute_fork_version |
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 aneth/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
ExecutionPayloadvia 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 SyncPost-merge, the Consensus Layer drives block synchronization. The EL receives blocks through engine_newPayloadVX calls. Adding BAL retrieval to the same flow is natural.
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 ComplexityAdding 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 LocalityBALs 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
nullfor pre-Amsterdam blocks (backwards compatibility) - Returns
nullfor 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
nullserves 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
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 Type | Transmission Method | Peer-to-Peer Protocol | Trust Model |
|---|---|---|---|
| Transactions | eth protocol (Transactions, PooledTransactions) | eth/68+ | Untrusted, mempool validation |
| Block Headers | eth protocol (GetBlockHeaders, BlockHeaders) | eth/68+ | Chain validation |
| Block Bodies | eth protocol (GetBlockBodies, BlockBodies) | eth/68+ | Re-execution validates |
| Blobs | CL gossip + DAS | blob_sidecar_subnet_* | KZG commitment validation |
| BALs | Engine API only | None | Re-execution required |
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 infrastructureOption 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
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
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:
| Capability | How BAL Enables It |
|---|---|
| Parallel disk reads | All accessed addresses known upfront → prefetch simultaneously |
| Parallel tx validation | Post-values included → verify tx i without waiting for tx i-1 |
| Parallel state root | All modified accounts known → hash trie paths concurrently |
| Executionless state updates | Diff-only sync: apply changes without replaying EVM |
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
- 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).
- 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
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
- 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
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
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 include0xABCD 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
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
| Index | Phase | Examples |
|---|---|---|
| 0 | Pre-execution | EIP-2935 (store parent hash), EIP-4788 (store beacon root) |
| 1..n | Transactions | User transactions in block order |
| n+1 | Post-execution | EIP-4895 (withdrawals), EIP-7002/7251 (validator operations) |
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
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:
| Scenario | COINBASE in BAL? | balance_change? |
|---|---|---|
| Block has transactions, non-zero reward | Yes | Yes |
| Block has transactions, zero reward | Yes | No (read-only) |
| Empty block, no withdrawals | No | N/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
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_pre→process_pre_blockinblocks.pyexecute_transaction→process_transactioninfork.pytrack_state_changes→StateTrackerhooksbuild_bal→make_block_access_listinbuilder.py
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 BAL | Why This Size? |
|---|---|---|
| Storage writes | 40% | Full 32-byte values, 32-byte keys |
| Storage reads | 26% | Keys only, no values |
| Balance diffs | 9% | ~300 balance changes per block |
| Addresses | 15% | ~1000 unique addresses per block |
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
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_hashfield - Engine API: V5 methods with new payload format
- ExecutionPayload: New
blockAccessListfield - Validation logic: New verification step
Cross-Reference Summary
This EIP defines the specification that other sections implement:
| EIP Section | Implementation |
|---|---|
| RLP Data Structures | Section 01: RLP Types |
| Scope and Inclusion | Section 04: State Tracker Recording |
| Ordering | Section 07: Builder Finalization |
| Recording Semantics | Section 04, Section 06 |
| Gas Validation | Section 09: VM Integration |
| Engine API | Section 11: Engine API |
| Block Processing | Section 08: Block Processing |
| CL Integration | Section 12: Consensus Layer |
Design Decisions and Alternatives
What Could Have Been Different
| Choice Made | Alternative Considered | Why This Choice? |
|---|---|---|
| Block-level, not tx-level | Per-tx access lists | Block-level captures cross-tx dependencies |
| RLP encoding | SSZ encoding | Native EL format, no new dependencies |
| Post-values included | Only addresses/slots | Enables diff-only sync |
| Include unchanged addresses | Only changed addresses | Enables complete parallel IO |
| Lexicographic ordering | Insertion order | Deterministic across implementations |
| Hash in header, data in payload | Full BAL in header | Keeps 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