该EIP提出了区块级访问列表(Block-Level Access Lists, BALs),记录区块执行期间所有账户和存储位置的访问及其执行后值。BALs旨在通过在区块头中包含其哈希值并在执行负载中传输列表,实现并行磁盘读取、并行事务验证、并行状态根计算和无执行状态更新,从而提高以太坊的可扩展性和效率。
本 EIP 引入了区块级访问列表(Block-Level Access Lists, BALs),记录了区块执行期间访问的所有账户和存储位置,以及它们执行后的值。BALs 能够实现并行磁盘读取、并行交易验证、并行状态根计算和无需执行的状态更新。
在不提前知道将访问哪些地址和存储槽的情况下,交易执行无法并行化。虽然 EIP-2930 引入了可选的交易访问列表,但它们并未强制执行。
本提案在区块级别强制执行访问列表,从而实现:
并行 IO + 并行 EVM我们在区块头中引入了一个新字段 block_access_list_hash,其中包含 RLP 编码的区块访问列表的 Keccak-256 哈希值。当没有状态更改时,此字段是空 RLP 列表 0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347 的哈希值,即 keccak256(rlp.encode([]))。
class Header:
# Existing fields
...
block_access_list_hash: Hash32 = keccak256(rlp.encode(block_access_list))
BlockAccessList 不包含在区块体中。EL 单独存储 BALs,并通过 Engine API 将它们作为 ExecutionPayload 中的一个字段进行传输。BAL 被 RLP 编码为 AccountChanges 列表。当没有状态更改时,此字段是空 RLP 列表 0xc0,即 rlp.encode([])。
BALs 使用 RLP 编码,遵循以下模式:address -> field -> block_access_index -> change。
## Type aliases for RLP encoding
Address = bytes20 # 20-byte Ethereum address
StorageKey = uint256 # Storage slot key
StorageValue = uint256 # Storage value
Bytecode = bytes # Variable-length contract bytecode
BlockAccessIndex = uint16 # Block access index (0 for pre-execution, 1..n for transactions, n+1 for post-execution)
Balance = uint256 # Post-transaction balance in wei
Nonce = uint64 # Account nonce
## Core change structures (RLP encoded as lists)
## StorageChange: [block_access_index, new_value]
StorageChange = [BlockAccessIndex, StorageValue]
## BalanceChange: [block_access_index, post_balance]
BalanceChange = [BlockAccessIndex, Balance]
## NonceChange: [block_access_index, new_nonce]
NonceChange = [BlockAccessIndex, Nonce]
## CodeChange: [block_access_index, new_code]
CodeChange = [BlockAccessIndex, Bytecode]
## SlotChanges: [slot, [changes]]
## All changes to a single storage slot
SlotChanges = [StorageKey, List[StorageChange]]
## AccountChanges: [address, storage_changes, storage_reads, balance_changes, nonce_changes, code_changes]
## All changes for a single account, grouped by field type
AccountChanges = [
Address, # address
List[SlotChanges], # storage_changes (slot -> [block_access_index -> new_value])
List[StorageKey], # storage_reads (read-only storage keys)
List[BalanceChange], # balance_changes ([block_access_index -> post_balance])
List[NonceChange], # nonce_changes ([block_access_index -> new_nonce])
List[CodeChange] # code_changes ([block_access_index -> new_code])
]
## BlockAccessList: List of AccountChanges
BlockAccessList = List[AccountChanges]
BlockAccessList 是区块执行期间访问的所有地址的集合。
它必须包含:
未发生状态更改但被访问的地址,包括:
BALANCE、EXTCODESIZE、EXTCODECOPY、EXTCODEHASH 操作码的目标CALL、CALLCODE、DELEGATECALL、STATICCALL 的目标(即使它们回滚;请参阅状态访问前的 Gas 验证以了解包含条件)CREATE/CREATE2 的目标地址,如果目标账户被访问0x0)SELFDESTRUCT 的受益人地址SYSTEM_ADDRESS (0xfffffffffffffffffffffffffffffffffffffffe) 不得包含,除非它本身经历状态访问未发生状态更改的地址必须仍以空更改列表的形式存在。
EIP-2930 访问列表中的条目不得自动包含。只有在执行期间实际接触或更改的地址和存储槽才会被记录。
区块访问列表受区块 Gas 限制而非固定最大项目数的约束。约束定义如下:
bal_items <= block_gas_limit // ITEM_COST
其中:
bal_items = storage_keys + addressesITEM_COST = 2000storage_keys 是所有账户中存储键的总数,addresses 是区块中访问的唯一地址总数。根据 EIP-7981,向 BAL 添加项目的最便宜方式是冷 SLOAD,成本为 COLD_SLOAD_COST (2100,如 EIP-2929 中定义)。ITEM_COST 被刻意设置低于此最小值,以创建大约 block_gas_limit / 42000 个额外项目的缓冲区,该缓冲区吸收来自系统合约执行(例如,EIP-2935、EIP-4788、EIP-7002、EIP-7251)和提款接收方(EIP-4895)的 BAL 条目,这些条目不消耗区块 Gas。
状态访问操作码执行 Gas 验证分两个阶段:
前状态验证必须在任何状态访问发生之前通过。如果前状态验证失败,则目标资源(地址或存储槽)永远不会被访问,并且不得包含在 BAL 中。
一旦前状态验证通过,目标就会被访问并包含在 BAL 中。然后计算后状态成本;它们的顺序是实现定义的,因为目标已经已被访问。
下表指定了除基本操作码成本之外的前状态验证成本(Gas 常量如 EIP-2929 中定义):
| 指令 | 前状态验证 |
|---|---|
BALANCE |
access_cost |
SELFBALANCE |
无(访问当前合约,始终为热) |
EXTCODESIZE |
access_cost |
EXTCODEHASH |
access_cost |
EXTCODECOPY |
access_cost + memory_expansion |
CALL |
access_cost + memory_expansion + GAS_CALL_VALUE (如果 value > 0) |
CALLCODE |
access_cost + memory_expansion + GAS_CALL_VALUE (如果 value > 0) |
DELEGATECALL |
access_cost + memory_expansion |
STATICCALL |
access_cost + memory_expansion |
CREATE |
memory_expansion + INITCODE_WORD_COST + GAS_CREATE |
CREATE2 |
memory_expansion + INITCODE_WORD_COST + GAS_KECCAK256_WORD + GAS_CREATE |
SLOAD |
access_cost |
SSTORE |
超过 GAS_CALL_STIPEND 可用 |
SELFDESTRUCT |
GAS_SELF_DESTRUCT + access_cost |
其中:
access_cost:对于账户访问操作码:如果是冷访问则为 COLD_ACCOUNT_ACCESS_COST,如果是热访问则为 WARM_STORAGE_READ_COST。对于存储访问操作码(SLOAD):如果是冷访问则为 COLD_SLOAD_COST,如果是热访问则为 WARM_STORAGE_READ_COST。memory_expansion:扩展输入/输出区域内存的 Gas 成本后状态成本(例如,对空账户调用的 GAS_NEW_ACCOUNT,如果受益人不存在则为 GAS_SELF_DESTRUCT_NEW_ACCOUNT)不影响 BAL 的包含,因为目标已经已被访问。
当调用目标具有 EIP-7702 委托时,会访问目标以解析委托。如果存在委托,则委托地址在访问之前需要进行自己的 access_cost 检查。如果此检查失败,委托地址不得出现在 BAL 中,但原始调用目标将被包含(因为已访问以解析委托)。
注意:委托账户不能为空,因此在解析委托时 GAS_NEW_ACCOUNT 永远不适用。
SSTORE 会隐式读取当前的存储值以进行 Gas 计算。GAS_CALL_STIPEND 检查可防止在调用津贴内操作时发生此状态访问。如果 SSTORE 未通过此检查,则存储槽不得出现在 storage_reads 或 storage_changes 中。
必须应用以下排序规则:
必须满足以下唯一性约束:
BlockAccessList 中恰好出现一次。storage_changes 中最多出现一次。storage_reads 中最多出现一次。storage_changes 和 storage_reads 中同时出现。block_access_index 必须在每个更改列表(balance_changes、nonce_changes、code_changes 和每个槽位的 StorageChange 列表)中最多出现一次。BlockAccessIndex 值必须按以下方式分配:
0 用于预执行系统合约调用。1 … n 用于交易(按区块顺序)。n + 1 用于后执行系统合约调用。写入包括:
读取包括:
SLOAD 访问但未写入的槽位。SSTORE 中后值等于前值,也称为“无操作写入”)。注意:实现必须检查交易前的值,以正确区分实际写入和无操作写入。
balance_changes)记录以下账户的交易后余额 (uint256):
value > 0 时)。value > 0 时)。value > 0 时)。对于未更改的账户余额:
如果账户余额在交易期间发生更改,但其交易后余额等于其交易前余额,则不得将该更改记录在 balance_changes 中。发送方和接收方地址必须包含在 AccountChanges 中。
以下特殊情况要求在没有其他状态更改时包含带有空更改的地址:
SELFDESTRUCT 调用零值区块奖励接收方不得在区块访问列表中触发余额更改,也不得导致接收方地址作为读取(例如,无更改)被包含。零值区块奖励接收方必须仅在奖励大于零的区块中包含余额更改。
跟踪已部署或修改合约的交易后运行时字节码,以及 EIP-7702 中定义的成功委托的委托指示符。
记录以下账户的交易后 nonce:
CREATE 或 CREATE2 的合约。AccountChanges 中,不带 nonce 或代码更改。但是,如果账户在交易前有正余额,则必须记录余额更改为零。自毁合约中被修改或读取的存储键必须包含为 storage_reads 条目。EXTCODEHASH、EXTCODESIZE、BALANCE、STATICCALL 等的目标)。balance_changes 中省略。storage_reads 中。block_access_index = 0。block_access_index = len(transactions) + 1。accessed_addresses(根据 EIP-2929)后授权失败,它必须仍以空更改集包含;如果在授权方加载之前授权失败,它不得包含。委托目标不得在委托创建或修改期间包含,并且必须仅在它实际作为执行目标加载时包含(例如,通过 CALL/CALLCODE/DELEGATECALL/STATICCALL 在授权执行下)。MAX_WITHDRAWAL_REQUESTS_PER_BLOCK 个队列数据槽(从槽 4 开始),这些槽将显示为 storage_reads。MAX_CONSOLIDATION_REQUESTS_PER_BLOCK 个队列数据槽(从槽 4 开始),这些槽将显示为 storage_reads。Engine API 通过新的结构和方法进行扩展,以支持区块级访问列表:
ExecutionPayloadV4 扩展了 ExecutionPayloadV3,增加了:
blockAccessList: RLP 编码的区块访问列表engine_newPayloadV5 验证执行负载:
ExecutionPayloadV4 结构blockAccessList 匹配INVALIDengine_getPayloadV6 构建执行负载:
ExecutionPayloadV4 结构blockAccessList 字段区块处理流程:
处理区块时:
block_access_list_hash = keccak256(blockAccessList) 并将其包含在区块头中执行层通过 Engine API 向共识层提供 RLP 编码的 blockAccessList。共识层随后计算用于 ExecutionPayload 中存储的 SSZ hash_tree_root。
历史 BALs 的检索方法:
engine_getPayloadBodiesByHashV2:返回包含交易、提款和 blockAccessList 的 ExecutionPayloadBodyV2 对象engine_getPayloadBodiesByRangeV2:返回包含交易、提款和 blockAccessList 的 ExecutionPayloadBodyV2 对象blockAccessList 字段包含 RLP 编码的 BAL,或者对于阿姆斯特丹之前的区块或数据已被修剪时为 null。
EL 必须至少保留 BALs 弱主观性周期 (=3533 epochs) 的持续时间,以支持离线时间少于 WSP 后的重新执行同步。
状态转换函数必须验证提供的 BAL 与实际状态访问相匹配。
实现说明:BAL 本身不需要进入状态转换函数。实现可以通过在执行期间生成一个虚拟 BAL,对其进行哈希,并与头部中的 block_access_list_hash 进行比较来验证。这是 execution-specs 参考实现中采用的方法。
def validate_block(execution_payload, block_header):
# 1. Compute hash from received BAL and set in header
block_header.block_access_list_hash = keccak(execution_payload.blockAccessList)
# 2. Execute block and collect actual accesses
actual_bal = execute_and_collect_accesses(execution_payload)
# 3. Verify actual execution matches provided BAL
# If this fails, the block is invalid (the hash in the header would be wrong)
assert rlp.encode(actual_bal) == execution_payload.blockAccessList
def execute_and_collect_accesses(block):
"""Execute block and collect all state accesses into BAL format"""
accesses = {}
# Pre-execution system contracts (block_access_index = 0)
track_system_contracts_pre(block, accesses, block_access_index=0)
# Execute transactions (block_access_index = 1..n)
for i, tx in enumerate(block.transactions):
execute_transaction(tx)
track_state_changes(tx, accesses, block_access_index=i+1)
# Withdrawals and post-execution (block_access_index = len(txs) + 1)
post_index = len(block.transactions) + 1
for withdrawal in block.withdrawals:
apply_withdrawal(withdrawal)
track_balance_change(withdrawal.address, accesses, post_index)
track_system_contracts_post(block, accesses, post_index)
# Convert to BAL format and sort
return build_bal(accesses)
def track_state_changes(tx, accesses, block_access_index):
"""Track all state changes from a transaction"""
for addr in get_touched_addresses(tx):
if addr not in accesses:
accesses[addr] = {
'storage_writes': {}, # slot -> [(index, value)]
'storage_reads': set(),
'balance_changes': [],
'nonce_changes': [],
'code_changes': []
}
# Track storage changes
for slot, value in get_storage_writes(addr).items():
if slot not in accesses[addr]['storage_writes']:
accesses[addr]['storage_writes'][slot] = []
accesses[addr]['storage_writes'][slot].append((block_access_index, value))
# Track reads (slots accessed but not written)
for slot in get_storage_reads(addr):
if slot not in accesses[addr]['storage_writes']:
accesses[addr]['storage_reads'].add(slot)
# Track balance, nonce, code changes
if balance_changed(addr):
accesses[addr]['balance_changes'].append((block_access_index, get_balance(addr)))
if nonce_changed(addr):
accesses[addr]['nonce_changes'].append((block_access_index, get_nonce(addr)))
if code_changed(addr):
accesses[addr]['code_changes'].append((block_access_index, get_code(addr)))
def build_bal(accesses):
"""Convert collected accesses to BAL format"""
bal = []
for addr in sorted(accesses.keys()): # Sort addresses lexicographically
data = accesses[addr]
# Format storage changes: [slot, [[index, value], ...]]
storage_changes = [[slot, sorted(changes)]
for slot, changes in sorted(data['storage_writes'].items())]
# Account entry: [address, storage_changes, reads, balance_changes, nonce_changes, code_changes]
bal.append([
addr,
storage_changes,
sorted(list(data['storage_reads'])),
sorted(data['balance_changes']),
sorted(data['nonce_changes']),
sorted(data['code_changes'])
])
return bal
BAL 必须是完整且准确的。缺少或虚假的条目会使区块无效。虚假条目可以通过验证 BAL 索引来检测,这些索引不得高于 len(transactions) + 1。
如果任何交易超过声明的状态,客户端可以立即使其无效。
客户端必须将 BALs 与区块分开存储,并通过 Engine API 提供它们。
示例区块:
预执行:
0x0000F90827F1C53a10cb7A02335B175320002935) 存储父哈希交易:
后执行:
注意:预执行系统合约使用 block_access_index = 0。后执行提款使用 block_access_index = 3 (len(transactions) + 1)。
生成的 BAL (RLP 结构):
[
# Addresses are sorted lexicographically
[ # AccountChanges for 0x0000F90827F1C53a10cb7A02335B175320002935 (Block hash contract)
0x0000F90827F1C53a10cb7A02335B175320002935,
[ # storage_changes
[b'\x00...\x0f\xa0', [[0, b'...']]] # slot, [[block_access_index, parent_hash]]
],
[], # storage_reads
[], # balance_changes
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0x2222... (Address checked by Alice)
0x2222...,
[], # storage_changes
[], # storage_reads
[], # balance_changes (no change, just checked)
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xaaaa... (Alice - sender tx 0)
0xaaaa...,
[], # storage_changes
[], # storage_reads
[[1, 0x...29a241a]], # balance_changes: [[block_access_index, post_balance]]
[[1, 10]], # nonce_changes: [[block_access_index, new_nonce]]
[] # code_changes
],
[ # AccountChanges for 0xabcd... (Eve - withdrawal recipient)
0xabcd...,
[], # storage_changes
[], # storage_reads
[[3, 0x...5f5e100]], # balance_changes: 100 ETH withdrawal
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xbbbb... (Bob - recipient tx 0)
0xbbbb...,
[], # storage_changes
[], # storage_reads
[[1, 0x...b9aca00]], # balance_changes: +1 ETH
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xcccc... (Charlie - sender tx 1)
0xcccc...,
[], # storage_changes
[], # storage_reads
[[2, 0x...bc16d67]], # balance_changes: after gas
[[2, 5]], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xdddd... (Deployed contract)
0xdddd...,
[], # storage_changes
[], # storage_reads
[], # balance_changes
[[2, 1]], # nonce_changes: new contract nonce
[[2, b'\x60\x80\x60\x40...']] # code_changes: deployed bytecode
],
[ # AccountChanges for 0xeeee... (COINBASE)
0xeeee...,
[], # storage_changes
[], # storage_reads
[[1, 0x...05f5e1], [2, 0x...0bebc2]], # balance_changes: after tx fees
[], # nonce_changes
[] # code_changes
],
[ # AccountChanges for 0xffff... (Factory contract)
0xffff...,
[ # storage_changes
[b'\x00...\x01', [[2, b'\x00...\xdd\xdd...']]] # slot 1, deployed address
],
[], # storage_reads
[], # balance_changes
[[2, 5]], # nonce_changes: after CREATE
[] # code_changes
]
]
RLP 编码和压缩后:约 400-500 字节。
选择此设计变体有几个关键原因:
大小与并行化:BALs 包含所有访问的地址(即使未更改),以实现完全的并行 IO 和执行。
写入的存储值:执行后值使得在同步期间无需针对状态根的单独证明即可重建状态。
开销分析:历史数据显示平均 BAL 大小约为 70 KiB。
交易独立性:60-80% 的交易访问不相交的存储槽,从而实现有效的并行化。剩余的 20-40% 可以通过交易后状态差异进行并行化。
RLP 编码:以太坊原生编码格式,保持与现有基础设施的兼容性。
平均 BAL 大小:约 72.4 KiB(压缩后)
小于当前最差情况下的 calldata 区块。
已在此处进行经验分析:这里。针对 6000 万 Gas 限制的更新分析可在此处找到:这里。
BAL 验证与并行 IO 和 EVM 操作同时进行,而不会延迟区块处理。
本提案要求对区块结构和 Engine API 进行更改,这些更改不具备向后兼容性,并且需要硬分叉。
验证访问列表和余额差异会增加验证开销,但对于防止接受无效区块至关重要。
区块大小增加会影响传播,但开销(平均约 70 KiB)对于性能提升来说是合理的。
由于 storage_reads 条目未映射到特定的交易索引,因此它们的有效性只能在执行所有交易后才能确认。恶意提议者可以利用这一点,声明从未访问过的幻影存储读取,迫使客户端进行不必要的 I/O 预取和大量数据下载,而区块在完成之前仍无法拒绝。
为了缓解这种情况,客户端应该在交易边界强制执行 Gas 预算可行性检查。令:
R_remaining = 尚未访问的已声明存储读取数量G_remaining = 剩余区块 Gas必须满足以下不变量:
G_remaining >= R_remaining * 2000
其中 2000 是存储读取的最小 Gas 成本(通过 EIP-2930 访问列表:1900 预付 + 100 热读取)。如果此检查失败,则可以立即拒绝该区块为无效,因为剩余的 Gas 不足以访问声明的读取。此检查应该定期执行(例如,每 8 笔交易),以实现早期拒绝,而不会影响并行执行。
通过 CC0 放弃版权及相关权利。
- 原文链接: github.com/nerolation/EI...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!