原文作者:OP Labs 研发人员 Norswap
原文编译:DeFi 之道
这是一篇有关 Optimism Bedrock 以及 Arbitrum Nitro 之间设计差异的分析文章。
这一切都源于我对 Nitro 白皮书的阅读,以及我对 Bedrock 设计的感性认识。
这变得非常技术性, 如果你想关注并感到困惑,我建议你参考一下 Bedrock 概述以及我关于 Cannon 故障证明系统的演示文稿,当然还有 Nitro 白皮书。
准备好了之后,让我们开始吧!
首先,Nitro 白皮书很棒,读起来令人愉快, 我建议所有感兴趣的人都去看看。
说到这里,我的印象是 Bedrock 和 Nitro 大致使用了相同的架构,但有一些较小的差异。
白皮书大体上证实了这一点。 尽管如此,还是有很多的不同之处,包括一些我没想到的。这就是这篇文章要讲的东西。
(A)固定与可变区块时间
最有趣和最重要的事情之一是, Nitro 将像当前版本的 Optimism 一样工作,每笔交易一个区块,并且区块之间的时间可变。
我们放弃了这一点,因为它背离了以太坊的工作方式,也是开发人员的痛点。而 Bedrock 将有“真正”的区块,并且固定时间为 2 秒。
不规则的区块时间使很多常见的合约变得不稳定,因为它们是使用区块而不是时间戳来表示时间。这尤其包括源自 Sushiswap 的分配 LP 奖励的 Masterchef 合约。
我不确定为什么这些合约用区块而不是时间戳来表示时间!以太坊矿工在操纵时间戳方面有一些回旋余地,但默认情况下,客户端不会构建距离 wallclock (Geth 为 15 秒)太远的区块,所以没有问题。
无论如何,在 Optimism 上,这导致 StargateFinance 奖励比其他链提前几个月用完,因为他们没有考虑到这种特殊性!
“每笔交易一个区块”模型还有其他的问题。首先,存储链的开销很大(每笔交易一个区块头)。其次,这意味着状态根需要在每次交易后更新。
更新状态根是一项非常昂贵的操作,其成本要在多笔 tx 中进行分摊。
(B) Geth 作为库或作为执行引擎
Nitro 使用 Geth “作为一个库”,通过钩子(hooks)对其进行了最低限度的修改,以调用适当的功能。
在 Bedrock 中,一个经过最少修改的 Geth 作为“执行引擎”独立运行,它从 rollup 节点接收指令,就像执行层从 Eth2 中的共识层接收指令一样。 我们甚至使用完全相同的 API!
这有一些重要的影响。 首先,我们能够使用除 Geth 之外的其他客户端,在它们之上应用类似的最小差异。 这不仅仅是理论,我们已经准备好了 Erigon。
其次,这让我们可以重用整个 Geth(或其他客户端)堆栈,包括在网络层,这可以实现对等发现和状态同步等功能,而无需进行任何额外的开发工作。
(B) 状态存储
Nitro 将一些状态(“ArbOS 的状态”)保存在一个特殊帐户中(它本身存储在 Arbitrum 的链状态中),使用特殊的内存布局将密钥映射到存储槽。
(这纯粹是架构,对用户没有影响。)
从这个意义上说,Bedrock 并没有太多的状态,它只有很少的状态存储在普通 EVM 合约中(公平地说,你可以使用 EVM 实现 ArbOS 状态布局,但我认为他们并不是这样做的)。
在确定/执行下一个 L2 块时,一个 Bedrock 副本会查看:
L2 链头部的区块头;
从 L1 读取的数据;
L2 链上 EVM 合约中的一些数据,目前只有 L1 费用参数;
在 Bedrock 中,节点可能会崩溃并立即优雅地重启。它们不需要维护额外的数据库,因为所有必要的信息都可以在 L1 和 L2 区块中找到。我认为 Nitro 的工作原理是一样的(架构使这成为可能)。
但很明显, Nitro 比 Bedrock 做了更多的记账工作。
(C) L1 到 L2 的消息包含延迟
Nitro 会延迟 10 分钟处理 L1 到 L2 的消息(我们称之为“存款交易”或简称“存款”)。在 Bedrock 上,通常应具有几个区块的小确认深度(可能是 10 个 L1 区块,所以大约是 2 分钟)。
我们也有一个称为“排序器漂移”(sequencer drift)的参数,它允许 L2 区块的时间戳在其 L1 原点之前漂移(L1 区块标志着 L1 区块范围的结束,批次和存款是从中派生的)。
我们仍然需要确定最终的数值,但我们也倾向于 10 分钟,这意味着最坏的情况是 10 分钟。然而,此参数旨在确保在与 L1 的连接暂时丢失期间 L2 链的活性。
然而,通常在确认深度后会立即包含存款。
Nitro 的白皮书中提到,这 10 分钟的延迟是为了避免 L1 上的存款因重组而消失。这让我对白皮书没有谈到的一个方面感到好奇,那就是:L2 链如何处理 L1 的重组。我认为答案是它没有处理。
这并非不合理:合并后,L1 的最终性延迟大约是 12分钟 。因此,如果存款延迟 10/12 分钟是可接受的,那么这个设计就是可行的。
因为 Bedrock 更接近 L1,我们需要在需要时通过重组 L2 来处理 L1 重组。确认深度应避免这种情况过于频繁地发生。
另一个小的区别是,如果 Nitro 排序器在 10 分钟后不包含存款,你可以通过 L1 合约调用“强制包含”它。
在 Bedrock 上,这不是必需的:拥有一个 L2 区块而不包括其 L1 起源的存款是无效的。
并且由于 L2 只能比原点提前 10 分钟(排序器漂移),因此 10 分钟后不纳入存款的一条链是无效的,它将被验证器拒绝,并受到故障证明机制的挑战。
(D) L1-to-L2 消息重试机制
Nitro 为 L1 到 L2 的消息实施了“可重试票证”(retryable tickets)机制。 假设你正在跨链,tx 的 L1 部分可以工作(锁定你的代币),但 L2 部分可能会失败。 因此,你需要能够重试 L2 部分(可能需要更多的 gas),否则你已经丢失了代币。
Nitro 在节点的 ArbOS 部分实现了这一点。 在 Bedrock 中,这一切都是在 Solidity 本身中完成的。
如果你使用我们的 L1 跨域 messenger 合约向 L2 发送 tx,该 tx 会到达我们的 L2 跨域 messenger,后者将记录其哈希值,使其可重试。 Nitro 的工作方式相同,只是在节点中实现。
我们还通过我们的 L1 Optimism Portal 合约,公开了一种较低 level 的存款方式。
这并没有为你提供 L2 跨域 messenger 重试机制的安全网,但另一方面,这意味着你可以在 Solidity 中实现自己的应用程序特定重试机制。 这很酷!
(E) L2费用算法
在 Bedrock 以及 Nitro 这两个系统上,费用都有 L2 部分(执行 gas,类似于以太坊)以及 L1 部分(L1 calldata 的成本)。 对于 L2 费用,Nitro 使用了一个定制系统,而 Bedrock 重复使用了 EIP-1559。 Nitro 必须这样做,因为他们有上述提到的 1 tx/区块 系统。
我们仍然需要调整 EIP-1559 参数,以使其在 2 秒的出块时间内正常工作。 今天,Optimism 只收取低且固定的 L2 费用, 我认为我们可能也会出现价格飙升,但在实践中从未发生过。
重用 EIP-1559 的一个优点是,它应该使钱包和其他工具计算费用稍微容易一些。
而 Nitro 的 gas 计量公式非常优雅,他们似乎已经对此进行了大量思考。
(F) L1 费用算法
那 L1 费用如何呢?这里的区别会更大一些。 Bedrock 使用向后查看的 L1 基础费用数据。这些数据非常新鲜,因为它通过与存款相同的机制传递(即几乎是即时的)。
由于仍然存在 L1 费用飙升的风险,所以我们收取预期费用的一个小倍数。
有趣的事实:这个倍数(自启动链以来我们已经多次降低)是所有当前排序器收入的来源!使用 EIP-4844 后,这将缩小,收入将来自 MEV 提取。
Nitro 做的事情要复杂得多。我并没有声称了解它的所有复杂性,但基本要点是他们有一个控制系统,可以从 L1 实际支付的费用中获得反馈。
这意味着使用此数据将交易从 L1 发送回 L2。如果排序器支付不足,它可以开始向用户收取更少的费用。如果它多付了钱,它可以开始向用户收取更多费用。
顺便说一句,你可能想知道为什么我们需要将费用数据从 L1 传输到 L2。这是因为我们希望费用计划成为协议的一部分,并接受故障证明的挑战。否则,流氓排序器可通过设置任意高的费用来拒绝链!
最后,交易批次在两个系统中都被压缩。 Nitro 根据对交易压缩程度的估计收取 L1 费用。Bedrock 目前没这样做,但我们有这样做的计划。
原因在于,不这样做,会加剧在 L2 存储中缓存数据的不正当动机,从而导致有问题的状态增长。
(G) 故障证明指令集
故障/欺诈证明! Nitro的工作方式与 Cannon(我们目前正在实施的位于 Bedrock 之上的防故障系统)的工作方式有相当多的差异。
Bedrock 编译为 MIPS 指令集架构 (ISA),Nitro 编译为 WASM。由于编译为他们称为 WAVM 的 WASM 子集,他们似乎对输出进行了更多的转换。
例如,他们通过库调用替换浮点 (FP) 操作。我怀疑他们不想在链上解释器中实现粗糙的 FP 操作。我们也这样做,但 Go 编译器会替我们处理!
另一个例子:与大多数只有跳转的 ISA 不同,WASM 具有适当的(可能嵌套的)控制流(if-else、while 等)。从 WASM 到 WAVM 的转换消除了这一点以返回跳转,这可能也是为了解释器的简单性。
他们还将 Go、C 和 Rust 混合编译为 WAVM(在不同的“模块”中),而我们只编译 Go。显然 WAVM 允许“语言的内存管理不受干扰”,我将其解释为每个 WAVM 模块都有自己的堆。
我很好奇是:他们是如何处理并发和垃圾收集的。我们能够在 minigeth(我们精简的 geth)中相当容易地避免并发,所以这部分可能很简单(本文末尾将详细介绍 Bedrock 和 Nitro 如何使用 geth)。
然而,我们对 MIPS 所做的唯一转换之一是修补垃圾收集调用。这是因为垃圾收集在 Go 中使用了并发,而并发和故障证明不能很好地结合在一起。 Nitro 也是做了同样的事吗?
(H) 二分博弈结构
Bedrock 故障证明将用于验证发布到 L1 的状态根(实际上是输出根)的有效性的 minigeth 运行。此类状态根不经常发布,并且包括许多区块/批次的验证。
Cannon 中的二分游戏是在这个(长期)运行的执行轨迹上进行的。
另一方面,在 Nitro 中,状态根与发布到 L1 的每组批次 (RBlock) 一起发布。
Nitro 中的二分游戏分为两部分。首先找到挑战者和防御者不同意的第一个状态根。然后,在验证器运行中找到他们不同意的第一个 WAVM 指令(它只验证单个 tx )。
权衡之处是在 Nitro 执行期间进行更多的哈希运算(参见上面的(A)部分),但在故障证明期间进行更少的哈希运算:在执行跟踪的二分游戏中的每个步骤,都需要提交内存 Merkle 根。
像这样的故障证明结构也减少了对验证器内存膨胀的担忧,其可能会超过当前运行 MIPS 的 4G 内存限制。
这不是一个很难解决的问题,但我们需要在 Bedrock 中小心,而验证单笔交易可能永远不会接近这个限制。
(i)原像预言机(Preimage oracle)
用于故障证明的验证器软件需要从 L1 和 L2 读取数据。因为它最终将在 L1 上“运行”(尽管只有一条指令),所以需要通过 L1 访问 L2 本身 - 通过发布到 L1 的状态根和区块哈希。
你如何从状态或链中读取(无论是 L1 还是 L2)?
Merkle 根节点是其子节点的哈希,因此如果你可以请求原像,则可以遍历整个状态树。同样,你可以通过请求区块头的原像来向后遍历整个链。 (每个区块头都包含其父区块的哈希值。)
在链上执行时,这些原像可以预先提供给 WAVM/MIPS 解释器。 (链下执行时,可以直接读取L2状态!)
(请注意,你只需要访问一个这样的原像,因为在链上你只执行一条指令!)
这就是你在 Nitro 和 Bedrock 上阅读 L2 的方式。
但是,你需要为 L1 做类似的事情。因为交易批次存储在 L1 调用数据中,无法从 L1 智能合约访问。
Nitro 将其批次的哈希存储在 L1 合约中(这就是为什么他们的“Sequencer Inbox”是一个合约,而不是像 Bedrock 那样的 EOA)。所以他们至少需要这样做,我不知道为什么没有提到。
在 Bedrock 中,我们甚至不存储批次哈希(从而节省了一些 gas)。相反,我们使用 L1 区块头返回 L1 链,然后沿着交易 Merkle 根向下查找 calldata 中的批次。
(同样,在链上,最多需要提供一个原像。)
第 4.1 节的结尾,提醒我们 Arbitrum 发明了“哈希预言机技巧”。不安全不应该成为忘记 Arbitrum 团队贡献的理由!
(J) 大原像(Large preimages)
Nitro 白皮书还告诉我们,L2 原像(Preimage)的固定上限是 110 kb,但没有引用 L1 的数字。
在 Cannon 中,我们有一个称为“大原像问题”的问题,因为要反转的潜在原像之一是收据原像,其中包含 Solidity 事件发出的所有数据(EVM 级别的“日志”)。
在收据中,所有日志数据连接在一起。这意味着攻击者可以发出大量日志,并创建一个非常大的原像。
我们需要读取日志,因为我们使用它们来存储存款( L2-to-L1 消息)。这并不是绝对必要的:Nitro 通过存储消息的哈希来避免这个问题(它比这更复杂,但最终结果是相同的)。
我们不存储哈希,因为计算和存储它的成本很高,存储要消耗大约 20k gas ,每计算 32 个字节要消耗 6 gas。平均一笔交易大约是 500 字节,因此一批 200 笔交易的哈希成本大约为 20k gas 。以 2000美元的 ETH 和 40 gwei basefee 计算,额外的哈希和存储成本为 3.2$。以 5000 美元的 ETH 和 100 gwei 计算,成本即 20 美元。
我们目前解决大原像问题的计划,是使用简单的 zk-proof 来证明原像中某些字节的值(因为这是一条指令在实践中需要访问的全部内容)。
(K) 批次和状态根
Nitro 将批次和状态根紧密相连。 他们在包含状态根的 RBlock 中发布一组批次。
另一方面,Bedrock 将其批次与状态根分开发布。 关键优势是再次降低了发布批次的成本(无需与合约交互或存储数据)。 这让我们可以更频繁地发布批次,并减少状态根的频率。
另一个影响是,使用 Nitro,如果 RBlock 受到挑战,它包含的交易将不会在新链上重放(新的正确状态根)。
在 Bedrock 中,我们目前正在讨论在成功挑战状态根的情况下该怎么做:在新的状态根上重放旧 tx,还是完全回滚? (当前的实现意味着完全回滚,但在推出故障证明之前可能会发生更改。)
(L) 其他杂项
影响较小的差异:
(i) Nitro 允许排序器发布的单笔交易可以是“垃圾”(无效签名等)。为了尽量减少对 Geth 的更改,我们总是丢弃包含任何垃圾交易的批次。
排序器总是能够提前找到那些,所以挥之不去的垃圾交易要么是不当行为要么是bug。排序器运行与故障证明相同的代码,因此它们对无效内容的定义应该相同。
(ii) Nitro 引入了预编译合约,尤其是用于 L2 到 L1 的消息传递。我们目前不使用任何预编译,而是更喜欢它们“预部署”,即存在于创世区块特殊地址的实际 EVM 合约。
事实证明,我们可以在 EVM 中做我们需要的事情,这使得节点逻辑稍微简单一些。不过,我们并不坚决反对预编译,也许我们会在某个时候需要用到预编译。
(iii) Nitro 故障证明使用了 d向剖析( d-way dissection)。概念验证 Cannon 实现使用了二分法,但我们也可能会转向 d向剖析。
Nitro 白皮书中有一个非常好的公式,它解释了基于固定成本和可变成本的 d 的最优值。然而,我希望他们在实践中包括了如何估算这些成本的具体例子!
结尾
没有什么宏大的结论!或者更确切地说:请自己总结出结论:)