文章深入探讨了以太坊 EIP-7928 提出的区块级访问列表(BALs),旨在通过构建者提供的存储访问清单实现并行 I/O 和 EVM 执行,从而加速 L1 验证。文中详细对比了访问、执行和状态三种 BALs 设计方案在带宽开销、执行效率及状态恢复方面的权衡。
非常感谢 Francesco、Jochem、Vitalik、Ignacio、Gary、Dankrad、Carl、Ansgar 和 Tim 的反馈和审阅。

TL;DR:区块构建者将访问列表和状态差异包含在区块中,以便验证者更快地进行验证 -> 扩展 L1!
在扩展以太坊 Layer 1(尤其是执行层)的新努力中,核心主题之一是**区块级访问列表 (BALs) - EIP-7928**。
BALs 是区块构建者必须在每个区块中包含的结构化列表,其中指定了单个交易将访问哪些存储槽位。如果这些列表不准确或不完整,区块将变为无效。因此,以太坊的共识机制可以强制执行严格的合规性,要求构建者提供正确的 BALs。
验证者通过加速区块验证从 BALs 中受益匪浅。通过确切知道将访问哪些账户和存储槽位,验证者可以对磁盘读取 (IO) 和执行 (EVM) 应用简单的并行化策略。这可以带来更快的区块验证,并为未来提高 Gas 限制打开大门。
BALs 的一个关键设计目标是在平均和最坏情况下保持紧凑的容量大小。带宽已经是节点和验证者的一个瓶颈,因此 BALs 不给网络增加不必要的负载至关重要。BALs 将被放入区块体中,BAL 的哈希值存储在区块头中。
目前,以太坊客户端依赖于它们自己的乐观并行化策略。这些策略通常在平均区块上表现良好,但在最坏情况下表现挣扎,导致显著的性能差异。
应该包含什么内容?除了存储位置 (address, storage key) 之外,我们还可以包含:
对于存储值,我们可以区分:
我们的目标是硬件无关,还是想针对某些常用的硬件规范进行优化?
在下文中,我们将重点关注 BALs 的三种主要变体:访问型、执行型和状态型。
访问型 BALs 将交易映射到 (address, storage key) 元组。
parallel IO + serial EVM。为了提高效率,使用 SSZ 的轻量级 BAL 结构可能如下所示:
## 类型别名
Address = ByteVector(20)
StorageKey = ByteVector(32)
TxIndex = uint16
## 常量;选择用于支持 630m 区块 Gas 限制
MAX_TXS = 30_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
## 容器
class SlotAccess(Container):
slot: StorageKey
tx_indices: List[TxIndex, MAX_TXS] # 访问此槽位(读取或写入)的交易(按索引排列)
class AccountAccess(Container):
address: Address
accesses: List[SlotAccess, MAX_SLOTS]
## 顶级区块字段
BlockAccessWrites = List[AccountAccess, MAX_ACCOUNTS]
BlockAccessReads = List[AccountAccess, MAX_ACCOUNTS]
外层列表是在区块期间访问的去重地址列表。对于每个地址,都有一个被访问的存储键列表。对于每个存储键,都有一个访问该键的有顺序的交易索引 List[TxIndex]。
例如,区块号为 21_935_797 的 BAL 如下所示:
[\
('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',\
[\
('0xa63b...c2', [0]),\
('0x8b3e...a7', [0]),\
('0xfb19...a8',\
[1, 2, 3, 4, 84, 85, 91]\
),\
# ... 更多条目\
]\
),\
# ... 更多条目\
]
执行型 BALs 将交易映射到 (address, storage key, value) 元组,并包含余额差异。
parallel IO + parallel EVM。使用 SSZ 的高效结构:
## 类型别名
Address = ByteVector(20)
StorageKey = ByteVector(32)
StorageValue = ByteVector(32)
TxIndex = uint16
Nonce = uint64
## 常量;选择用于支持 630m 区块 Gas 限制
MAX_TXS = 30_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
MAX_CODE_SIZE = 24576 # 最大合约字节码大小(以字节为单位)
## SSZ 容器
class PerTxAccess(Container):
tx_index: TxIndex
value_after: StorageValue # 交易内最后一次访问后的状态值
class SlotAccess(Container):
slot: StorageKey
accesses: List[PerTxAccess, MAX_TXS] # 读取操作为空
class AccountAccess(Container):
address: Address
accesses: List[SlotAccess, MAX_SLOTS]
code: Union[ByteVector(MAX_CODE_SIZE), None] # 合约字节码的可选字段
BlockAccessList = List[AccountAccess, MAX_ACCOUNTS]
## 区块前的 Nonce 结构
class AccountNonce(Container):
address: Address # 账户地址
nonce_before: Nonce # 区块执行前的 Nonce 值
NonceDiffs = List[AccountNonce, MAX_TXS]
该结构与访问版本相同,只是增加了 StorageValue 来表示每个交易最后一次访问后的值。
SlotAccess.accesses:空的 SlotAccess.accesses 表示一次读取。只有写入操作由 (StorageKey, List[TxIndex], StorageValue) 元组组成,这显著减小了对象的容量。max(parallel IO, parallel EVM)。为了进行同步(参见治愈阶段 (healing phase)),在更新状态的同时追赶链的状态需要写入操作的状态差异(交易后的值)。我们不需要直接接收带有证明的新状态值,而是可以使用区块内的状态差异来治愈状态,并通过将最终导出的状态根与头区块的状态根进行比较来验证该过程。
对于合约部署,code 必须包含新部署合约的运行时字节码。NonceDiffs 结构必须记录区块中所有 CREATE 和 CREATE2 部署者账户以及已部署合约的交易前 Nonce 值。
需要余额差异来正确处理依赖于账户余额的执行。这些差异包括涉及价值转移的交易所触及的每个地址,以及余额增量、交易发送者、接收者和区块的 coinbase。
## 类型别名
Address = ByteVector(20)
TxIndex = uint64
BalanceDelta = ByteVector(12) # 有符号,补码编码
## 常量
MAX_TXS = 30_000
MAX_ACCOUNTS = 70_000 # 630m / 9300(对带值的非空账户进行调用的开销)
## 容器
class BalanceChange(Container):
tx_index: TxIndex
delta: BalanceDelta # 有符号整数,编码为 12 字节向量
class AccountBalanceDiff(Container):
address: Address
changes: List[BalanceChange, MAX_TXS]
BalanceDiffs = List[AccountBalanceDiff, MAX_ACCOUNTS]
这种结构将执行与状态完全解耦,允许验证者在执行期间绕过任何磁盘或 trie 查找,仅依赖于区块中提供的数据。pre_accesses 列表提供了区块开始前所有被访问槽位的初始值,而 tx_accesses 追踪每个交易的访问模式和访问后的值,从而实现细粒度的并行执行和验证。
max(parallel IO, parallel EVM)。一个高效的 SSZ 对象:
## 类型别名
Address = ByteVector(20)
StorageKey = ByteVector(32)
StorageValue = ByteVector(32)
TxIndex = uint16
## 常量
MAX_TXS = 30_000
MAX_SLOTS = 300_000
MAX_ACCOUNTS = 300_000
## 子容器
class PerTxAccess(Container):
tx_index: TxIndex
value_after: StorageValue
class SlotAccess(Container):
slot: StorageKey
accesses: List[PerTxAccess, MAX_TXS]
class AccountAccess(Container):
address: Address
accesses: List[SlotAccess, MAX_SLOTS]
class SlotPreValue(Container):
slot: StorageKey
value_before: StorageValue
class AccountPreAccess(Container):
address: Address
slots: List[SlotPreValue, MAX_SLOTS]
## 统一的顶级容器
class BlockAccessList(Container):
pre_accesses: List[AccountPreAccess, MAX_ACCOUNTS]
tx_accesses: List[AccountAccess, MAX_ACCOUNTS]
状态型 BAL 的另一种变体是排除读取值,仅包含写入操作的执行前和执行后值。在该模型中,pre_accesses 和 tx_accesses 仅包含被写入的存储槽位,以及它们相应的 value_before(来自状态)和 value_after(来自交易结果)。
这减小了容量,同时仍然能够实现完整的状态重构,因为写入槽位定义了唯一的持久更改。读取访问隐含地假设通过传统的状态查找或在客户端缓存来解决。
最坏情况下的交易消耗整个区块 Gas 限制(截至 2025 年 4 月为 3600 万),通过将它们包含在 EIP-2930 访问列表中,尽可能多地访问不同合约内的存储槽位。
因此,(36_000_000 - 21_000) // 1900 得到了我们可以进行的最大地址 (20 字节) + 存储键 (32 字节) 读取次数,从而导致 18_947 次存储读取,大小约为 0.93 MiB。
对 1,000 个区块进行采样,平均 BAL 大小约为 57 KiB(SSZ 编码)。平均而言,该时间段内的区块每个区块包含约 1,181 个存储键和 202 个合约。
为每个写入条目包含一个 32 字节的值不会增加最坏情况下的 BAL 大小。对于读取 18_947 个存储负载,大小保持在 0.93 MiB。
如果单个交易向多个地址发送极小值(1 wei),则会出现最坏情况下的余额差异:
21,000 的基础开销和 9,300 的调用 Gas,我们在一个交易中最多可以得到 3,868 个被调用地址。包括该交易的 tx.from 和 tx.to 以及区块的 coinbase,我们得到 3,871 个地址。对于 20 字节的地址和 12 字节的余额增量,我们得到的余额差异大小为 0.12 MiB。由于包含另外 32 字节的读取和写入,最坏情况下的 BAL 大小增加到约 1.51 MiB。

EIP-7928 中指定的当前设计采用了带有交易后值的执行型 BAL 模型。这种变体提供了一个极具吸引力的权衡:它既支持 I/O 和 EVM 并行化,又包含足够的同步状态差异,且没有与交易前状态快照相关的额外带宽成本。
- 原文链接: ethresear.ch/t/block-lev...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码