感谢 Jochem Brouwer、Sina Mahmoodi、Thomas Thiery、Rahul Guha、Carlos Perez 和 Guillaume Ballet 的审阅和反馈。
TL;DR:
- 这篇文章从广阔的视角介绍了涉及 L1 snarkification 的一个众所周知的最坏情况的解决方案:字节码分块。
- 即将到来的 Layer-1 扩容和其他协议变更会加剧这种最坏情况。虽然这些变更必须继续,但重要的是开始思考如何应对未来的 SNARKification 挑战。
- 随着时间的推移和以太坊的发展,核心开发人员和研究人员对于重大协议变更所带来的风险的意见越来越多样化,因此深入探索每个潜在解决方案的细微差别非常重要。
- 本文档的目标是:
- 调查该问题的潜在解决方案,从最简单到最复杂,突出每种解决方案的不同权衡。
- 提高认识,鼓励更多人考虑它并提出更好的解决方案。
- 这并不需要立即采取行动(例如“Glamsterdam”)——即使是看似简单的解决方案也需要彻底的分析来确定最佳的前进道路。
本文档探讨了区块证明最坏情况的解决方案:执行轨迹中需要合约字节码。这个问题由来已久,并提出了各种解决方案。随着时间的推移,协议变更在用户体验影响和风险方面的评估会有所不同,因此更详细地了解其利弊将大有裨益。
背景与动机
本部分为不熟悉此问题的读者提供介绍。如果您已经熟悉此问题,请跳过此部分。
为什么要进行区块执行证明?
Vitalik最近发表的一篇文章解释了为什么以太坊需要更高的执行吞吐量。提高 Gas 上限会线性增加区块验证负载,需要证明者提供更多的计算和存储资源,这与以太坊去中心化和无需信任等核心价值观相冲突。
执行证明将这种关系从线性变为亚线性,通过将工作量转移给区块提议者,允许更安全地提高 Gas 限额,而不会影响证明者。这是朝着更安全地提高执行吞吐量迈出的重要一步,尽管它并不能解决所有扩展问题,例如状态增长。
区块证明硬件和证明效率
虽然区块证明将工作分担给了区块构建者,但对证明者的最低硬件要求却存在限制。区块证明遵循“N 选 1”的假设(一个诚实的证明者即可确保区块的活性和抗审查性)。与任何“N 选 1”的假设一样,以太坊社区应该在某个时候决定需要多少诚实且独立的证明者才能满足这一假设。
此硬件预算限制了网络可以设置的最大 Gas 限额,因此,最大限度地降低 Gas 限额在最坏情况下的使用率至关重要。缩小平均情况和最坏情况之间的差距可以提高网络效率,从而在给定 Gas 限额的情况下降低证明者预算。
证明器面临的两个主要瓶颈是合约代码(字节码)证明和错误定价的操作码/预编译。本文档重点讨论前者。
为什么合约代码是瓶颈
目前,每个帐户字节码都在帐户 Merkle Patricia Trie (MPT) 中的代码哈希( keccak(bytecode) )字段中提交,因此验证字节码的片段需要提供全部内容,即只能从完整的字节码中计算出哈希值。
这对于区块证明来说效率低下,因为交易通常只使用合约代码的一小部分。例如,ERC-20 代币转账不涉及铸造代码。类似地,像EXTCODESIZE这样返回合约大小的操作码,需要完整的字节码来计算长度,因为合约大小并不直接存储在树中。
例如,在当前 36M 的 Gas 限制下,最坏的情况是,一个区块中包含一笔交易,该交易使用 36M Gas 反复执行EXTCODESIZE指令,目标合约大小上限为 24KiB。在当前 36M 的 Gas 限制下,证明者将被迫哈希约 338MiB 的数据(使用EIP-2930计算,约等于36M/2500*24KiB )。
这并非一个非常复杂的理论攻击——几个月前,我在主网中进行了一次 9 倍降级攻击,以检查其对 Ethproofs 证明器的影响。我花费了 25 美元( 攻击交易,攻击区块),而证明器在完成证明的过程中花费的时间比平均时间长 3 到 5 倍,或者在此过程中崩溃。
还要注意, EXTCODESIZE攻击向量并非唯一——任何触及合约单个字节码的类似CALL的指令都会迫使证明者将整个字节码包含在见证中。使用EXTCODESIZE只是触发攻击的最简单方法。
不同 gas 限制的最坏情况如下:
- 36M gas = ~338MiB 数据到 keccak。
- 60M gas = ~563MiB 数据到 keccak。
- 100M gas = ~939MiB 数据到 keccak。
- 300M gas = ~2.8GiB 数据到 keccak。
请注意,如果协议将 24KiB 最大合约大小增加到 256KiB,则我们必须将每种情况乘以约 10 倍。
我正在与@kevaundray合作,让所有 zkVM 都能够重现这种(以及其他)最坏情况,并将其作为跨不同 Gas 限制和最大合约规模的区块测试。这样做可以让 zkVM 团队准确地了解这些极端情况如何影响他们的架构,从而确保工程和协议设计都以具体、可复现的指标为指导。
解决方案
在区块证明的背景下,解决字节码问题有两种方法:协议外和协议内。
请注意,没有完美的解决方案;每种解决方案都会增加复杂性,但同时也会带来好处。本文档探讨了多种可能性,最终决定权留给未来的 ACD。解决方案按复杂性升序排列。
协议外解决方法
侵入性最小的方法是不对协议进行任何更改,但这不仅仅是一个解决方案,而是一种变通方法。Reth 的Ress 客户端是部分无状态的,只存储字节码,而不存储其他状态(例如随机数、余额、存储槽)。假设验证者拥有合约字节码,区块证明者只需证明代码哈希的正确性,而无需证明字节码数据本身。
该解决方案的优点:
- 没有核心协议变化(这是一个很大的好处),仅支持网络上的证明分发。
缺点:
- 客户端并非无状态,而是部分无状态,需要约 10GiB 的数据。随着区块链的发展,这一需求将持续增长,并且随着 Gas 限额或最大合约规模的增加,增长速度也将加快。对于大多数验证者来说,这个空间仍然相对较低,但可能会排除其他功耗极低的设备(例如智能手机)。
- 区块验证者可以在加入区块链之前下载所有合约字节码,但如果没有额外的信任假设,他们无法确定数据是否完整——他们可能需要从其他网络节点获取缺失的合约字节码。此获取过程不受时间限制,从而在The Block验证流程中引入了网络依赖性。
- 这使得该解决方案不适用于对区块证明有严格截止期限的网络证明者。对于非证明者(例如,仅验证链的节点)来说,这不是问题。
- 证明和字节码服务都依赖于网络节点的诚实性——与完整的协议解决方案相比,区块提议者必须在The Block中包含证明。
尽管上述列表有很多缺点,但考虑到它不需要任何协议更改,我们不能对该解决方案要求更多。
节点可以通过从可信来源下载所有合约字节码来缓解这些缺陷,确保不会因为字节码丢失而导致后续网络调用。通过在协议层引入一个仅可追加的代码哈希 Merkle 树,可以消除这种信任假设。该树的根节点允许客户端验证数据,并帮助离线节点同步丢失的数据。
协议内选项
解决这个问题的方法是将合约的字节码拆分成多个块,并对其进行默克尔化。这样可以只证明执行轨迹中涉及的代码块,这比完整的合约代码所需的数据要少。
任何关于代码分块的解决方案都有两个正交的维度:
- 字节码如何分成块?
- 块是如何进行默克尔化的?
字节码如何分成块?
主要有两种代码分块策略:
- 31字节代码分块器:它使用1字节前缀将字节码划分为31字节的块,以辅助
JUMPDEST分析。详细说明可在此处找到。 - 32 字节代码分块器:它将字节码划分为 32 字节的块,并在字节码前面添加一个表,该表可以有效地编码哪些代码块包含无效的跳转目标
0x5B。例如,如果没有PUSHN立即数包含0x5B,则任何跳转都是有效的;因此,该表将为空——更详细的解释可以在这里找到。 - 动态分块: 先前的研究探索了动态大小分块,其中代码跳转只能定位到块的开头。由于
JUMPDEST指令标记了有效的跳转目标,因此它们可以有效地确定块的起始位置。这可以避免使用额外的机制来检测无效跳转。然而,它存在一些潜在问题,需要通过其他机制来解决,例如,没有任何JUMPDEST大型字节码。
请注意,这些代码分块器解决了遗留合约可能存在的一个问题。即将推出的 EOF 合约不存在这个问题,因为只有包含有效跳转的代码才能被部署。不久前,我做了一个分析,比较了这两种策略。
以上是两个建议的分块器,但还可以有更多变体。
- 使用效率:块越大意味着在见证中提供的块越少,但会增加浪费的字节,因为并非所有块中的字节都在执行跟踪中使用。
- 两个分块器都提出了 32 字节的代码块,这可能是因为它们的大小与存储槽的大小相同。
- 存储效率:分块会产生多少存储开销?
- 31字节代码分块器:1/31~=3.2% 开销
- 32 字节代码分块器:动态的,不久前进行的主网分析报告显示约为 0.1%,最坏情况为 3.1%(即每个块都包含无效跳转)。
- 复杂性:规范和实现的复杂性
- 31字节代码分块器:非常简单
- 32 字节代码分块器:定义表格式、位置ETC更加复杂。
块如何进行默克尔化?
给定一个分块的字节码,我们需要确定如何对块进行默克尔化,以便可以为执行跟踪中访问的部分字节码创建证明。
为了高效地支持EXTCODE*操作码,我们还必须包含现有的codeHash和新的codeSize字段。实现起来非常简单,因此我将只关注主要难点:对代码块进行默克尔化。
在哪里对代码块进行默克尔化?
以下是一些关于它们将被默克尔化的策略:
- 在当前 MPT 内。
- 新的统一状态树内部
- 在 EIP 中,提出了用于帐户和存储数据的新统一树(例如二叉树),合约代码块也将存储在这里。
- 优点:
- 引入了考虑新树的自然设计。
- 现有代码的代码分块是树转换过程的一部分。
- 缺点:
- 当前的提议不会对相同的块进行重复数据删除,但这种情况可能会改变。
- 与许多其他变化捆绑在一起,它可能会增加共识错误的风险。
- 在专门用于代码块的新树内
- 引入了一个新的仅用于代码块的树。现有的 MPT 保持不变。
- 优点:
- 或者,它允许灵活地设计新树(例如,不同的元数或哈希函数)。
- 重复数据删除是可选的,取决于密钥定义。
- 缺点:
- The Block中有一个额外的树根。
- 与 MPT 不同的树设计意味着更多的规范复杂性和实施错误的风险。
如何处理新的和现有的字节码的默克尔化?
创建新合约时对代码进行默克尔化的过程很简单,因为EIP-4762已经概述了按存储的代码块收取 gas 费的结构,即按树中插入的代码块向用户收费。
主要挑战在于对现有代码进行默克尔化,至少需要 15GiB 的去重数据。这无法仅在分叉激活时进行操作来实现。虽然新的统一状态树方案可以在树转换过程中完成这项工作,但其他两种策略必须决定如何执行——以下是一些思路:
- 按需转换(感谢 Dankrad 提到这个变体 - 优点/缺点由我自己决定)
- 只有当交易与未分块的合约交互时,代码才会被分块。交易发送者需要支付代码分块的费用。
- 优点:
- 对于协议来说非常容易,因为它在使用之前不必转换现有的主网字节码。
- 代码被默克尔化的目标树不会被可能永远不会再使用的合约所污染,因此它间接地进行了一些修剪。
- 缺点:
- 合约的代码分块由与其交互的第一个交易支付。这可能是一个用户体验问题,因为谁来支付是不可预测的。从用户的角度来看,等待其他人先支付可能比发送自己的交易更好。
- 在分叉激活时以及后续区块生成过程中,我们可能会看到 Gas 用量大幅飙升,这仅与代码分块有关。这也会影响基础费用,直到系统稳定下来。
- 这种方法可能会留下永远未转换的字节码。因此,EL 客户端必须保留转换后的代码,因为最终可能还会用到它。这也意味着The Block证明者必须证明代码分块,因此转换所需的 Gas 成本可能会很高。
- 多块转换
- 类似于新的树转换( EIP-7748 ),我们将定义一个流程,在每个块上迁移待处理的代码,直到所有代码都被分块。
- 优点:
- 由于用户无需为代码分块付费,因此不存在用户体验 (UX) 问题。
- 没有汽油价格飙升或基本费用干扰。
- 与单个区块激活相比,The Block块转换可以在任何错误发生时立即检测到共识级别上的错误。
- 缺点:
- 它增加了仅执行一次的任务的规范的复杂性,并且可能存在许多实现错误,从而增加了风险。
- 离线转换
- 按需+(有界)线下混合
- 仅对过去一年内访问过的合约请求离线转换,以最大程度地减少工作量。对于剩余未分块的合约,第一笔交易即可覆盖转换成本。
- 优点:
- 用户体验问题极少,因为访问过去一年的合同的用户无需付费。只有访问更早交易的用户才需要付费。
- 最小的天然气峰值或基本费用干扰。
- 减少 EL 客户端在离线转换中的工作量。
- 缺点:
- 尽管需要转换的字节码较少,但后台转换仍然不透明,因此共识失败的风险仍然存在。
- 分叉激活时间戳估计仍然至关重要,或者可能需要手动进行第二次分叉激活。
- 它具有相同的情况,即总是有未转换的代码并且必须证明代码分块。
接下来会发生什么?
解决了未分块的字节码问题之后,剩下的瓶颈就是错误定价的操作码/预编译(例如, MODEXP和配对——一些这方面的工作计划在 Fusaka( EIP-7883 )中进行),以及状态访问效率。研究人员和 zkVM 工程师正在分析前者。
后者意味着使用所有可用的 gas 来访问状态。负载取决于底层树的元数和哈希函数,这会影响见证所需的哈希量。假设保持当前 MPT 不变,如果我们尽可能多地访问账户:
- 36M gas = ~407k keccak 排列。
- 60M gas = ~678k keccak 排列。
- 100M gas = ~1.13m keccak 排列。
- 3亿 gas = ~339万 keccak 排列。
( keccak perms = gas/2500*tree_depth*15*32/keccak_rate = gas/2500*8*15*32/136 )
请注意,上述计算使用了 MPT 等 16 元树中主网的预期深度(即~8);然而,实际树结构中的特定分支可以达到更大的深度,从而提供了利用的机会。
如果上述预测对于考虑的最小证明器硬件预算来说是一个问题,我们可能需要考虑状态树的改变或状态访问的 gas 模型。



