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 |
Table of Contents
摘要
本 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/只读操作码:包含空更改的目标
具体示例
示例区块:
- Alice (0xaaaa…) 向 Bob (0xbbbb…) 发送 1 ETH,检查 0x2222… 的余额
- 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 设计选择
选择此设计变体的主要原因有以下几个:
-
大小与并行化:BAL 包含所有访问的地址(即使未更改),以实现完整的并行执行。省略读取值可减少大小,同时保持并行化优势。
-
写入的存储值:执行后的值允许在同步期间进行状态重构,而无需针对状态根的单独证明。
-
余额和 nonce 跟踪:对于并行执行至关重要。Nonce 跟踪处理 CREATE/CREATE2 边缘情况,其中合约在不作为交易发送者的情况下增加 nonce。
-
开销分析:历史数据显示平均 BAL 大小约为 40 KiB,余额差异约为 9.6 KiB - 对于性能提升来说是合理的。
-
交易独立性:60-80% 的交易访问不相交的存储槽,从而实现有效的并行化。
-
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.