Optimism Bedrock vs Arbitrum Nitro

作者:norswap来源:norswap2023-05-24

Optimism Bedrock vs Arbitrum Nitro

这是有关 Optimism Bedrock?和 Arbitrum Nitro? 之间差异化的专业性分析。

这一切都来自我阅读 Nitro 白皮书以及我对 Bedrock 设计的了解。实际上,这起初是一条 Twitter 推文,但它变得太大了。

这是篇非常技术的文章。如果您读起来感到困惑,我建议参考 Bedrock 概述以及我们的 Cannon 故障证明系统的演示,当然还有 Nitro 白皮书。

说清这个问题后,让我们深入探讨!

首先,白皮书很棒,阅读起来很愉快。我建议所有感兴趣的人查看一下。 

在进行这项工作之前,我的印象是 Bedrock 和 Nitro 大致共享相同的架构,但存在一些较小的差异。

这篇论文在很大程度上证实了这一点。尽管如此,仍然存在相当多的差异,包括一些我没有预料到的差异。这就是这个主题的内容!

(A) 固定 vs 可变 出块时间

一个有趣和很有影响的事实是 Nitro 将像 Optimism 当前版本一样工作,即每笔交易一个区块,且区块之间的时间不确定。

我们(Optimism) 放弃了这种方式,因为它与以太坊的工作方式不同,并且对开发人员来说是一个痛点。Bedrock 将具有固定块时间为 2 秒的“真实”区块。

不规则的出块时间会使一些常见的合约变得奇怪,因为它们使用区块而不是时间戳来计时。这其中包括 Masterchef 合约:它被用来分发由 Sushiswap 发起的 LP 奖励。

我不确定为什么这些合约使用区块来计时而不是时间戳!以太坊矿工在操作时间戳时有一些余地,但客户端在默认情况下不会在距离实际时间太远的区块上构建(在Geth中为15秒),因此没有问题。 

无论如何,在 Optimism 上,这导致了 StargateFinance 激励提前几个月耗尽,因为他们没有考虑到这种特殊性!

“每笔交易一个区块”的模型还存在其他问题。首先,它会为存储链(每个交易一个块头)增加很多开销。其次,这意味着每个单独交易后都需要更新状态根。更新状态根是一项非常昂贵的操作,而当多个交易一起执行时,其成本会分摊。

(B) Geth 作为库还是执行引擎

Nitro 使用 Geth "作为库",并进行最小限度的修改以调用适当的功能。在 Bedrock 中,一个经过最小化修改的 Geth 作为 "执行引擎 "独立运行,它从 rollup 节点接收指令,就像执行层从 Eth2 的共识层接收指令一样。甚至使用完全相同的 API!!

这有一些重要的结果。首先,我们能够使用 Geth 以外的其他客户端,在它们上面应用类似的最小差异。这不仅仅是理论,我们已经有了 Erigon 的基本准备。

其次,这让我们可以重用整个 Geth(或其他客户端)堆栈,包括在网络层,这使得像对等体发现和状态同步这样的事情得以实现,几乎不需要任何额外的开发工作。

(C) 状态存储

Nitro 将一些状态("ArbOS 的状态")保存在一个特殊的账户内(本身存储在 Arbitrum 的链状态内),使用特殊的内存布 局将键映射到存储槽。

(这纯粹是架构,没有用户影响。)

Bedrock 在那个意义上实际上没有太多状态,而且它所具有的少量状态存储在普通的 EVM 合约中(公平地说,您可以使用 EVM 实现 ArbOS 状态布局,但我认为他们没有这样做)。 

在确定/执行下一个 Layer2 块时,Bedrock 副本会查看:

  • Layer2 链头的头部
  • 从 Layer1 读取的数据
  • Layer2 链上 EVM 合约中的一些数据,目前只有 Layer1 费用参数

​在 Bedrock 中节点可以崩溃并立即重启。他们不需要维护额外的数据库,因为所有必要的信息都可以在 L1 和 L2 块中找到。我认为 Nitro 也是这样工作的(这种架构使这种情况成为可能)。 

然而,显然 Nitro 做了比 Bedrock 更多的额外的状态管理的工作。

(D) L1 到 L2 的消息包含延迟

Nitro 处理 L1 到 L2 的信息(我们称之为 "存款交易 "或只是 "存款")有 10 分钟的延迟。在 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 上,这不是必需的:没有包括其 L1 起源的存款的 L2 块是无效的。由于 L2 只能比起源快 10 分钟(sequencer drift),因此在 10 分钟后没有包括存款的链是无效的,将被验证者拒绝,并可通过故障证明机制进行挑战。

(E) L1 到 L2 消息重试机制

Nitro 为 L1 到 L2 消息实现了“可重试票”系统。假设你正在桥接,tx 的 L1 部分可能有效(锁定你的代币),但 L2 部分可能失败。因此,您需要能够重试 L2 部分(可能需要更多的 gas),否则您将失去代币。

Nitro 将其实现在节点的 ArbOS 部分中。在 Bedrock 中,这一切都是在 Solidity 本身中完成的。

如果你使用我们的 L1 CrossDomainMessenger 合约向 L2 发送一个交易,该交易会落在我们的 L2 CrossDomainMessenger 中,它将记录其哈希值,使其可以重试。Nitro 的工作方式与此相同,只是在节点中实现。

我们还提供了一种更低级别的存款方式,通过我们的 L1 Optimism Portal 合约。 

这并没有给你提供 L2 CrossDomainMessenger 重试机制的安全网络,但从另一个角度看,这意味着你可以在 Solidity 中实现你自己的特定应用重试机制。非常酷!

扩展阅读:由于存款交易是在 L2 上执行的,因此桥要等到很久以后才能知道它是否成功,同时资金可能会丢失。如果智能合约没有实现在没有 L1 调用值的情况下重试调用的方法,则通过桥发送同时具有 L1 和 L2 调用值的交易的智能合约可能会损失其资金。

Nitro 实施了一个“可重试票”系统:如果存入交易失败,ArbOS 会创建一个可重试票,任何为其燃料提供资金的人都可以重试(“兑换”)。如果交易附加了一些看涨价值,它就会被 ArbOS 托管。1 周后,未兑换的票证将过期并被删除。可以重试失败的重试,并且可以更新过期的票证。创建可重试时,如果票被删除,可以提供受益人地址以接收托管资金。受益人也可以取消机票。赎回总是与请求它的交易放在同一个区块中。Bedrock 使用围绕核心存款合约的 CrossDomainMessenger 包装器来重试失败的存款交易。L2 上失败的事务可以通过relayMessage随时调用该函数来重放。

(F) L2 费用算法

在两个系统中,费用分为 L2 部分(执行 gas,类似于以太坊)和 L1 部分(L1 calldata 的成本)。Nitro 使用了定制系统的 L2 费用,而 Bedrock 则重用了 EIP-1559。Nitro 必须这样做,因为他们有前面提到的每个区块仅支持一个交易的系统。

我们仍然需要调整 EIP-1559 参数,使其与 2 秒区块时间良好配合。今天,Optimism 收取低廉且固定的 L2 费用(L1 费用无论如何占价格的 99%)。我认为我们也可能有高峰期定价,但实际上从未发生过。

重用 EIP-1559 的一个优点是,它应该使钱包和其他工具计算费用更容易一些。 

Nitro 的 gas-metering 公式相当简洁,而且他们似乎已经对此进行了深入的思考。

(G) L1 费用算法

那么 L1 费用呢?这是一个更大的区别。

Bedrock 使用向后看的 L1 基础费用数据。这些数据非常新鲜,因为它们是通过与存款相同的机制提供的(即几乎是即时的)。 

因为仍然存在 L1 费用会飙升的风险,我们会收取预期成本的一个小倍数。 

有趣的事实是,这个倍数(自从我们推出该链以来已经多次降低)是当前所有序列化程序收入的来源!通过 EIP-4844,这将会缩小,并且收入将来自(保留用户体验的)MEV 提取。

Nitro 做的事情要复杂得多。我不敢说我完全理解其中所有的复杂性,但基本意思是,他们有一个控制系统,可以从 L1 实际支付的费用中获得反馈。

这意味着将交易从 L1 发送回 L2,同时携带这些数据。如果排序者付款不足,它可以开始向用户收取较少的费用。如果它付款过多,则可以开始向用户收取更多的费用。

顺便说一句,你可能会想知道为什么我们需要将费用数据从 L1 传输到 L2。这是因为我们希望费用方案成为协议的一部分,并且可以通过错误证明进行挑战。否则,不良的排序者可以通过设置任意高的费用来对链进行 DoS 攻击!

最后,两个系统都会对交易批次进行压缩。Nitro 根据交易的压缩程度估算 L1 费用。Bedrock 目前没有这样做,但我们计划引入。 不这样做会加剧将数据缓存到 L2 存储中的恶性激励,导致状态增长问题。

扩展阅读: 

由于存款交易在 L1 上提交但在 L2 上执行,因此必须有某种机制来支付 L2 上使用的天然气。

Bedrock 提出(但尚未实施)的一种解决方案是使用网桥发送直接付款。虽然这是理想的,但它意味着每个调用者都被标记为payable对于许多现有项目来说是不可能的。它实施的替代方案是通过燃烧气体来支付:一个 while 循环燃烧的 ETH 数量等于 C=G⋅bL2 的,在哪里 bL2 是 L2 的基础费用,并且 G 是请求的气体量。处理存款所需的气体被记入贷方,如果该数量大于请求的气体,则不会燃烧任何气体。L2 基础费用在 L1 上是未知的,因此使用类似 EIP-1559 的算法对其进行弱估计。

Nitro 实施直接支付解决方案。用户可以通过桥接发送资金来支付,如果不可能,也可以预先为 L2 地址提供资金。不可能直接在 L1 上支付 L2 上使用的气体。

(H) 故障证明指令集

故障/欺诈证明! Nitro 和 Bedrock 目前正在实现的故障证明系统 Cannon 之间存在相当多的差异。 

​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 也做同样的事情吗?

(I) 二分法游戏结构

Bedrock 故障证明将在运行验证状态根(实际上是发布到 L1 的输出根)的 minigeth 上运行。这样的状态根并不频繁发布,因此涵盖了许多块/批次的验证。 

Cannon 中的二分游戏是在这个(长)运行的执行跟踪上进行的。 

另一方面,在 Nitro 中,每组批次(RBlock)发布到 L1 时都会发布状态根。 

​Nitro 中的二分游戏分为两个部分。首先找到挑战者和防御者意见不一致的第一个状态根。然后,在验证器运行中找到他们意见不一致的第一个 WAVM 指令(验证单个交易)。 

权衡是在 Nitro 执行期间进行更多的散列(见(A)以上),但在故障证明期间进行更少的散列:执行跟踪中的每个步骤都需要提交内存的默克尔根。

这样的故障证明结构也减少了内存在验证器中膨胀的担忧,可能会超过我们目前对运行 MIPS 的 4G 内存限制。这并不是一个难以解决的问题,但我们在 Bedrock 中需要小心,因为在这里验证单个交易几乎不可能接近限制。

(J) Preimage 预言机

用于故障证明的验证器软件需要从 L1 和 L2 读取数据。因为它最终将在 L1 上“运行”(尽管只有一个指令),所以L2本身需要通过发布到 L1 的状态根和区块哈希来访问 L1。 

如何从状态或链(无论是 L1 还是 L2)读取数据?

默克尔根节点是其子节点的哈希,因此如果您可以请求一个 Preimage,您可以遍历整个状态树。类似地,您可以通过请求区块头的 Preimage 向后遍历整个链。 (每个块头都包含其父项的哈希。) 

​在链上执行时,这些 Preimage 可以提前预提供给 WAVM/MIPS 解释器。 (在链外执行时,您可以直接读取 L2 状态!) 

(请注意,您只需要访问一个这样的 Preimage,因为在链上,您只会执行一个指令!) 

所以这就是您从 L2 中读取数据的方法,无论是在 Nitro 还是 Bedrock 中。 

但是,对于 L1,您需要执行类似的操作。因为交易批次存储在 L1 calldata 中,从 L1 智能合约无法访问。

Nitro 将其批处理的哈希存储在 L1 合同中(这就是为什么它们的 “Sequencer Inbox” 是合同而不是Bedrock 的 EOA)。因此,他们至少需要这样做,我不确定为什么没有提到。在 Bedrock 中,我们甚至不存储批处理哈希(这导致一些 Gas 费用节约)。相反,我们使用 L1 块头回溯 L1 链,然后通过交易 Merkle 根向下遍历以在 calldata 中找到批处理。(同样,在链上,最多只需要提供单个 Preimage)第4.1节以一段话结束,提醒我们 Arbitrum发明了“哈希预言机技巧”。归功于此。不安全不应该成为忘记 Arbitrum 团队的贡献的理由!

(K) 大Preimage(预图像)

该论文还告诉我们,L2 预图像的固定上限为 110kb,但没有引用 L1 的数字。在 Cannon 中,我们有一个称为“大预图像问题”的问题,因为倒置的潜在预图像之一是收据预图像,其中包含 Solidity事 件(EVM级别上的“日志”)发出的所有数据。

在收据中,所有日志数据都串联在一起。这意味着攻击者可以发出大量日志,并创建一个非常大的预图像。

我们需要读取日志,因为我们使用它们来存储存款(L2到L1的消息)。这并非严格必要:Nitro 通过存储消息的哈希来避免这个问题(这比这更复杂,但最终结果相同)。 

我们不存储哈希,因为计算和存储它的成本很高。存储大约需要 20k 燃气,每 32 个字节需要 6 个燃气来计算。平均交易约为 500 个字节,使 200 个交易的批处理也需要花费 20k 燃气进行哈希。在 2k美元的ETH和 40 gwei 基本费率下,额外的哈希和存储成本为 3.2 美元。在 5k 美元的 ETH 和 100 gwei 下,这是 20 美元。

我们目前解决大预图像问题的计划是使用简单的 zk-proof 来证明预图像中一些字节的值(因为实际上指令所需要访问的只有这些)。

(L) 批处理和状态根

Nitro 将批处理与状态根紧密联系在一起。他们在 RBlock 中发布了一组批次,其中还包含状态根。Bedrock 另一方面将其批处理与状态根分开发布。关键优点再次是减少发布批次的成本(无需与合同交互或存储数据)。这使我们可以更频繁地发布批次,更少地发布状态根。 

另一个后果是,如果挑战 RBlock,则其中包含的事务将不会在新链(新的正确状态根)上重播。在 Bedrock 中,我们目前正在讨论在成功挑战状态根的情况下该怎么做:在新状态根上重播旧事务,还是完全回滚?(当前实现意味着完全回滚,但在故障证明推出之前很可能会更改。)

(M) 其他

较小的差异:

(i) Nitro 允许由序列器发布的单个交易是“垃圾”的(无效的签名等)。为了尽量减少对 Geth 的更改,我们总是放弃包含任何垃圾的批次。 

序列器始终能够提前找到它们,因此延迟的垃圾表明存在不端行为或错误。序列器运行与故障证明相同的代码,因此他们对无效内容的定义应该是相同的。

(ii) Nitro 引入了预编译合同,特别是用于 L2 到 L1 消息传递。我们目前不使用任何预编译,更喜欢它们是“预部署的”,即从创世块开始存在于特殊地址的实际 EVM 合同。 

事实证明,我们可以在 EVM 中很好地完成所需的工作,这使得节点逻辑稍微简单了一些。我们并不是一定反对预编译的方式,也许我们在某些时候需要一个。

(iii) Nitro 的故障证明进行了 d 向分解。概念验证的 Cannon 实现使用了二分法,但我们可能也会转移到 d 向分解。

该论文中有一个非常好的公式,根据固定和可变成本解释了 d 的最佳值。但是,我希望他们包括如何在实践中估算这些成本的具体示例!

结语

没有大结论!或者说:你自己总结吧:)如果您喜欢本文,请关注作者的Twitter,以获取更多类似的信息并收到新文章的通知。