Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-7928: 区块级访问列表

强制执行包含存储位置和状态差异的区块访问列表

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

摘要

本 EIP 引入了区块级访问列表 (BAL),用于记录区块执行期间访问的所有帐户和存储位置,以及执行后的值。BAL 支持并行磁盘读取、并行交易验证和无执行状态更新。

动机

如果不预先知道将访问哪些地址和存储槽,就无法有效地并行化交易执行。EIP-2930 引入了可选的交易访问列表,但它们不是强制执行的。

此提案在区块级别强制执行访问列表,从而实现:

  • 并行磁盘读取和交易执行
  • 无需执行交易即可进行状态重构
  • 减少执行时间至 parallel IO + parallel EVM

规范

区块结构修改

我们向区块头引入一个新字段:

class Header:
    # Existing fields
    # 现有字段
    ...
    
    bal_hash: Hash32

区块体包含一个 BlockAccessList,其中包含所有帐户访问和状态更改。

SSZ 数据结构

BAL 使用 SSZ 编码,遵循 address -> field -> tx_index -> change 模式。

# Type aliases using SSZ types
# 使用 SSZ 类型的类型别名
Address = Bytes20  # 20-byte Ethereum address
StorageKey = Bytes32  # 32-byte storage slot key
StorageValue = Bytes32  # 32-byte storage value
CodeData = List[byte, MAX_CODE_SIZE]  # Variable-length contract bytecode
# 可变长度的合约字节码
TxIndex = uint16  # Transaction index within block (max 65,535)
# 区块内的交易索引(最大 65,535)
Balance = uint128  # Post-transaction balance in wei (16 bytes, sufficient for total ETH supply)
# 交易后的余额(单位为 wei)(16 字节,足以满足 ETH 总供应量)
Nonce = uint64  # Account nonce
# 帐户 nonce

# Constants; chosen to support a 630m block gas limit
# 常量;选择用于支持 6.3 亿 gas 的区块 gas 限制
MAX_TXS = 30_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
MAX_CODE_SIZE = 24_576  # Maximum contract bytecode size in bytes
# 合约字节码的最大大小(以字节为单位)

# Core change structures (no redundant keys)
# 核心更改结构(没有冗余键)
class StorageChange(Container):
    """Single storage write: tx_index -> new_value"""
    # 单个存储写入:tx_index -> new_value
    tx_index: TxIndex
    new_value: StorageValue

class BalanceChange(Container):
    """Single balance change: tx_index -> post_balance"""
    # 单个余额更改:tx_index -> post_balance
    tx_index: TxIndex
    post_balance: Balance

class NonceChange(Container):
    """Single nonce change: tx_index -> new_nonce"""
    # 单个 nonce 更改:tx_index -> new_nonce
    tx_index: TxIndex
    new_nonce: Nonce

class CodeChange(Container):
    """Single code change: tx_index -> new_code"""
    # 单个代码更改:tx_index -> new_code
    tx_index: TxIndex
    new_code: CodeData

# Slot-level mapping (eliminates slot key redundancy)
# 槽级别映射(消除槽键冗余)
class SlotChanges(Container):
    """All changes to a single storage slot"""
    # 对单个存储槽的所有更改
    slot: StorageKey
    changes: List[StorageChange, MAX_TXS]  # Only changes, no redundant slot
    # 仅更改,没有冗余槽

class SlotRead(Container):
    """Read-only access to a storage slot (no changes)"""
    # 对存储槽的只读访问(没有更改)
    slot: StorageKey

# Account-level mapping (groups all account changes)
# 帐户级别映射(对所有帐户更改进行分组)
class AccountChanges(Container):
    """
    All changes for a single account, grouped by field type.
    This eliminates address redundancy across different change types.
    """
    # 对单个帐户的所有更改,按字段类型分组。
    # 这消除了不同更改类型的地址冗余。
    address: Address
    
    # Storage changes (slot -> [tx_index -> new_value])
    # 存储更改 (slot -> [tx_index -> new_value])
    storage_changes: List[SlotChanges, MAX_SLOTS]
    storage_reads: List[SlotRead, MAX_SLOTS]
    
    # Balance changes ([tx_index -> post_balance])
    # 余额更改 ([tx_index -> post_balance])
    balance_changes: List[BalanceChange, MAX_TXS]
    
    # Nonce changes ([tx_index -> new_nonce])
    # Nonce 更改 ([tx_index -> new_nonce])
    nonce_changes: List[NonceChange, MAX_TXS]
    
    # Code changes ([tx_index -> new_code]) - typically 0 or 1
    # 代码更改 ([tx_index -> new_code]) - 通常为 0 或 1
    code_changes: List[CodeChange, MAX_TXS]

# Block-level structure (simple list of account changes)
# 区块级别结构(帐户更改的简单列表)
class BlockAccessList(Container):
    """
    Efficient Block Access List
    """
    # 高效的区块访问列表
    account_changes: List[AccountChanges, MAX_ACCOUNTS]

BlockAccessList 包含在区块执行期间访问的所有地址:

  • 具有状态更改的地址(存储、余额、nonce 或代码)
  • 没有状态更改的访问地址(例如,STATICCALL 目标、BALANCE 操作码目标)

没有状态更改的地址必须仍然包含在空更改列表中,以便进行并行 IO。

排序要求:

  • 地址:字典序(按字节)
  • 存储键:每个帐户内按字典序
  • 交易索引:每个更改列表中按升序

存储写入包括:

  • 值更改(执行后的值与执行前的值不同)
  • 归零的槽(存在执行前的值,执行后的值为零)

存储读取:

  • 通过 SLOAD 访问但未写入的槽
  • 写入但值未更改的槽(使用相同值的 SSTORE)
  • 预状态中但未写入的槽

读取和写入(具有更改的值)的槽仅出现在 storage_changes 中。

余额更改记录以下交易后的余额 (uint128):

  • 交易发送者(gas + value)
  • 接收者
  • Coinbase(奖励 + 费用)
  • SELFDESTRUCT 受益人

零值转移:未记录在 balance_changes 中,但必须包含具有空 AccountChanges 的地址。

代码更改跟踪已部署/修改合约的交易后运行时字节码。

Nonce 更改记录执行成功 CREATE/CREATE2 的合约的交易后 nonce。

排除(可静态推断):

  • EOA nonce 增量
  • 新合约(始终为 nonce 1)
  • 失败的 CREATE/CREATE2
  • EIP-7702 委托

重要的实现细节

边缘情况

  • SELFDESTRUCT:受益人记录为余额更改
  • 已访问但未更改:包含空更改(EXTCODEHASH、EXTCODESIZE、BALANCE 目标)
  • 零值转移:包含地址,从 balance_changes 中省略
  • 失败的交易:从 BAL 中排除
  • Gas 退款:记录最终余额
  • 区块奖励:包含在 coinbase 余额中
  • STATICCALL/只读操作码:包含空更改的目标

具体示例

示例区块:

  1. Alice (0xaaaa…) 向 Bob (0xbbbb…) 发送 1 ETH,检查 0x2222… 的余额
  2. Charlie (0xcccc…) 调用 factory (0xffff…),在 0xdddd… 部署合约

生成的 BAL:

BlockAccessList(
    account_changes=[
        AccountChanges(
            address=0xaaaa...,  # Alice
            # Alice
            storage_changes=[],
            storage_reads=[],
            balance_changes=[BalanceChange(tx_index=0, post_balance=0x00000000000000029a241a)],  # 50 ETH remaining
            # 剩余 50 ETH
            nonce_changes=[],  # EOA nonce changes are not recorded
            # 不记录 EOA nonce 更改
            code_changes=[]
        ),
        AccountChanges(
            address=0xbbbb...,  # Bob
            # Bob
            storage_changes=[],
            storage_reads=[],
            balance_changes=[BalanceChange(tx_index=0, post_balance=0x0000000000000003b9aca00)],  # 11 ETH
            # 11 ETH
            nonce_changes=[],
            code_changes=[]
        ),
        AccountChanges(
            address=0xcccc...,  # Charlie (transaction sender)
            # Charlie (交易发送者)
            storage_changes=[],
            storage_reads=[],
            balance_changes=[BalanceChange(tx_index=1, post_balance=0x0000000000000001bc16d67)],  # After gas
            # 扣除 gas 后
            nonce_changes=[],  # EOA nonce changes are not recorded
            # 不记录 EOA nonce 更改
            code_changes=[]
        ),
        AccountChanges(
            address=0xdddd...,  # Deployed contract
            # 已部署的合约
            storage_changes=[],
            storage_reads=[],
            balance_changes=[],
            nonce_changes=[],  # New contracts start with nonce 1 (not recorded)
            # 新合约从 nonce 1 开始(未记录)
            code_changes=[CodeChange(tx_index=1, new_code=b'\x60\x80\x60\x40...')]
        ),
        AccountChanges(
            address=0xeeee...,  # Coinbase
            # Coinbase
            storage_changes=[],
            storage_reads=[],
            balance_changes=[
                BalanceChange(tx_index=0, post_balance=0x000000000000000005f5e1),  # After tx 0
                # 交易 0 后
                BalanceChange(tx_index=1, post_balance=0x00000000000000000bebc2)   # After tx 1 + reward
                # 交易 1 + 奖励后
            ],
            nonce_changes=[],
            code_changes=[]
        ),
        AccountChanges(
            address=0xffff...,  # Factory contract that performed CREATE
            # 执行 CREATE 的 Factory 合约
            storage_changes=[
                SlotChanges(
                    slot=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01',
                    changes=[
                        StorageChange(tx_index=1, new_value=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd\xdd')
                    ]
                )
            ],
            storage_reads=[],
            balance_changes=[],
            nonce_changes=[NonceChange(tx_index=1, new_nonce=5)],  # After CREATE
            # 执行 CREATE 后
            code_changes=[]
        ),
        AccountChanges(
            address=0x1111...,  # Contract accessed via STATICCALL or BALANCE opcode
            # 通过 STATICCALL 或 BALANCE 操作码访问的合约
            storage_changes=[],
            storage_reads=[SlotRead(slot=b'\x00...\x05')],  # Read slot 5
            # 读取槽 5
            balance_changes=[],
            nonce_changes=[],
            code_changes=[]
        ),
        AccountChanges(
            address=0x2222...,  # Address whose balance was checked via BALANCE opcode
            # 通过 BALANCE 操作码检查余额的地址
            storage_changes=[],
            storage_reads=[],
            balance_changes=[],  # Just checked via BALANCE opcode
            # 仅通过 BALANCE 操作码检查
            nonce_changes=[],
            code_changes=[]
        )
    ]
)

SSZ 编码和压缩:约 400-500 字节。

状态转换函数

修改状态转换函数以验证区块级访问列表:

def state_transition(block):
    account_data = {}  # address -> {storage_writes, storage_reads, balance_changes, nonce_changes, code_changes}
    # address -> {storage_writes, storage_reads, balance_changes, nonce_changes, code_changes}
    balance_touched = set()
    
    for tx_index, tx in enumerate(block.transactions):
        # Get pre and post states for this transaction
        # 获取此交易的交易前和交易后状态
        pre_state, post_state = execute_transaction_with_tracing(tx)
        
        # Process all touched addresses
        # 处理所有已访问的地址
        # All touched addresses must be included (even without changes)
        # 必须包含所有已访问的地址(即使没有更改)
        all_addresses = set(pre_state.keys()) | set(post_state.keys())
        
        for addr in all_addresses:
            if addr not in account_data:
                account_data[addr] = {
                    'storage_writes': {},  # slot -> [(tx_index, new_value)]
                    # slot -> [(tx_index, new_value)]
                    'storage_reads': set(),  # set of slots
                    # 槽的集合
                    'balance_changes': [],  # [(tx_index, post_balance)]
                    # [(tx_index, post_balance)]
                    'nonce_changes': [],  # [(tx_index, new_nonce)]
                    # [(tx_index, new_nonce)]
                    'code_changes': []  # [(tx_index, new_code)]
                    # [(tx_index, new_code)]
                }
            
            pre_info = pre_state.get(addr, {})
            post_info = post_state.get(addr, {})
            
            # Process storage changes
            # 处理存储更改
            pre_storage = pre_info.get('storage', {})
            post_storage = post_info.get('storage', {})
            all_slots = set(pre_storage.keys()) | set(post_storage.keys())
            
            for slot in all_slots:
                pre_val = pre_storage.get(slot)
                post_val = post_storage.get(slot)
                
                if post_val is not None:
                    # Check if value actually changed
                    # 检查值是否实际更改
                    if pre_val != post_val:
                        # Changed write - include in storage_writes
                        # 已更改的写入 - 包含在 storage_writes 中
                        if slot not in account_data[addr]['storage_writes']:
                            account_data[addr]['storage_writes'][slot] = []
                        account_data[addr]['storage_writes'][slot].append((tx_index, post_val))
                    else:
                        # Unchanged write - include as read
                        # 未更改的写入 - 作为读取包含
                        account_data[addr]['storage_reads'].add(slot)
                elif pre_val is not None and slot not in post_storage:
                    # Zeroed slot
                    # 归零的槽
                    if slot not in account_data[addr]['storage_writes']:
                        account_data[addr]['storage_writes'][slot] = []
                    account_data[addr]['storage_writes'][slot].append((tx_index, '0x' + '00' * 32))
                elif pre_val is not None:
                    # Read-only
                    # 只读
                    account_data[addr]['storage_reads'].add(slot)
            
            # Balance changes (only non-zero)
            # 余额更改(仅非零)
            pre_balance = int(pre_info.get('balance', '0x0'), 16)
            post_balance = int(post_info.get('balance', '0x0'), 16)
            if pre_balance != post_balance:
                balance_touched.add(addr)
                account_data[addr]['balance_changes'].append((tx_index, post_balance))
            
            # Code changes
            # 代码更改
            pre_code = pre_info.get('code', '')
            post_code = post_info.get('code', '')
            if post_code and post_code != pre_code and post_code not in ('', '0x'):
                account_data[addr]['code_changes'].append((tx_index, bytes.fromhex(post_code[2:])))
            
            # Nonce changes (contracts with CREATE/CREATE2)
            # Nonce 更改(具有 CREATE/CREATE2 的合约)
            if pre_info.get('code') and pre_info['code'] not in ('', '0x', '0x0'):
                pre_nonce = int(pre_info.get('nonce', '0x0'), 16)
                post_nonce = int(post_info.get('nonce', '0x0'), 16)
                if post_nonce > pre_nonce:
                    account_data[addr]['nonce_changes'].append((tx_index, post_nonce))
    
    # Coinbase balance (block rewards)
    # Coinbase 余额(区块奖励)
    coinbase_addr = block.coinbase
    coinbase_balance = get_balance(coinbase_addr)
    if coinbase_addr in balance_touched or coinbase_balance > 0:
        if coinbase_addr not in account_data:
            account_data[coinbase_addr] = {
                'storage_writes': {}, 'storage_reads': set(),
                'balance_changes': [], 'nonce_changes': [], 'code_changes': []
            }
        if not account_data[coinbase_addr]['balance_changes'] or \
           account_data[coinbase_addr]['balance_changes'][-1][0] < len(block.transactions) - 1:
            account_data[coinbase_addr]['balance_changes'].append(
                (len(block.transactions) - 1, coinbase_balance)
            )
    
    # Build the BAL from collected data
    # 从收集的数据构建 BAL
    computed_bal = build_block_access_list(account_data)
    
    # Validate block data
    # 验证区块数据
    assert block.bal_hash == compute_bal_hash(computed_bal)

def build_block_access_list(account_data):
    account_changes_list = []
    
    for addr, data in account_data.items():
        storage_changes = [
            SlotChanges(slot=slot, changes=[StorageChange(tx_index=idx, new_value=val) 
                                           for idx, val in sorted(changes)])
            for slot, changes in data['storage_writes'].items()
        ]
        
        # Include pure reads and unchanged writes (excluded from storage_writes)
        # 包含纯读取和未更改的写入(从 storage_writes 中排除)
        storage_reads = [SlotRead(slot=slot) for slot in data['storage_reads'] 
                        if slot not in data['storage_writes']]
        
        account_changes_list.append(AccountChanges(
            address=addr,
            storage_changes=sorted(storage_changes, key=lambda x: x.slot),
            storage_reads=sorted(storage_reads, key=lambda x: x.slot),
            balance_changes=[BalanceChange(tx_index=idx, post_balance=bal) 
                           for idx, bal in sorted(data['balance_changes'])],
            nonce_changes=[NonceChange(tx_index=idx, new_nonce=nonce) 
                         for idx, nonce in sorted(data['nonce_changes'])],
            code_changes=[CodeChange(tx_index=idx, new_code=code) 
                        for idx, code in sorted(data['code_changes'])]
        ))
    
    return BlockAccessList(account_changes=sorted(account_changes_list, key=lambda x: x.address))

BAL 必须完整且准确。缺少或虚假的条目会使区块无效。

客户端必须通过将执行收集的访问(根据 EIP-2929)与 BAL 进行比较来验证。

如果任何交易超过声明的状态,客户端可以立即失效。

理由

BAL 设计选择

选择此设计变体的主要原因有以下几个:

  1. 大小与并行化:BAL 包含所有访问的地址(即使未更改),以实现完整的并行执行。省略读取值可减少大小,同时保持并行化优势。

  2. 写入的存储值:执行后的值允许在同步期间进行状态重构,而无需针对状态根的单独证明。

  3. 余额和 nonce 跟踪:对于并行执行至关重要。Nonce 跟踪处理 CREATE/CREATE2 边缘情况,其中合约在不作为交易发送者的情况下增加 nonce。

  4. 开销分析:历史数据显示平均 BAL 大小约为 40 KiB,余额差异约为 9.6 KiB - 对于性能提升来说是合理的。

  5. 交易独立性:60-80% 的交易访问不相交的存储槽,从而实现有效的并行化。

  6. SSZ 编码:为轻客户端启用高效的 Merkle 证明。SSZ BAL 作为 RLP 区块中的不透明字节嵌入,以实现兼容性。

区块大小注意事项

区块大小影响(历史分析):

  • 平均:约 40 KiB(压缩后)
  • 余额差异:约 9.6 KiB
  • 最坏情况(36m gas):约 0.93 MiB
  • 最坏情况余额差异:约 0.12 MiB

小于当前最坏情况的 calldata 区块。

此处 完成了一项实证分析。

异步验证

BAL 验证与并行 IO 和 EVM 操作同时进行,而不会延迟区块处理。

向后兼容性

此提案需要对区块结构进行更改,这些更改不向后兼容,并且需要硬分叉。

安全注意事项

验证开销

验证访问列表和余额差异会增加验证开销,但对于防止接受无效区块至关重要。

区块大小

增加的区块大小会影响广播,但开销(平均约 40 KiB)对于性能提升来说是合理的。

版权

CC0 下放弃版权和相关权利。

Citation

Please cite this document as:

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