Optimism 的 rollup 是如何运行的

在 Paradigm,我们与我们投资组合中的公司密切合作。这项工作包括深入研究他们的协议设计和实施。

我们最近讨论了 Optimistic Rollup (OR) 的机制,它是扩展以太坊同时保留其蓬勃发展的开发者生态系统的主要解决方案。在这篇文章中,我们深入探讨了Optimism,该公司发明了第一个与 EVM 兼容的 Optimistic Rollup 协议(披露:Paradigm 是 Optimism 的投资者)。

本文适用于所有熟悉 Optimistic Rollup 作为一种机制并想了解 Optimism 解决方案如何工作并评估所提出系统的性能和安全性的人。

我们解释了每个设计决策背后的动机,然后继续剖析 Optimism 的系统,以及每个分析组件的相应代码的链接。

一. 软件重用在 Optimism 中的重要性

以太坊围绕其开发者生态系统开发了一条护城河。开发人员堆栈包括:

  • Solidity / Vyper:两种主要的智能合约编程语言,围绕它们构建了大型工具链(例如Ethers、Hardhat、dapp、slither )。
  • 以太坊虚拟机:迄今为止最流行的区块链虚拟机,其内部结构比任何其他区块链虚拟机都更容易理解。
  • Go-ethereum:占主导地位的以太坊协议实现,占网络节点的 75% 以上。它经过了广泛的测试和模糊测试(甚至在golang 本身中发现了错误!),许多人称之为:“ lindy ”。

由于 Optimism 将以太坊作为其第 1 层,如果我们可以重用所有现有工具,而几乎/不需要修改,那就太好了。这将改善开发人员的体验,因为开发人员不需要学习新的技术堆栈。上述 DevEx 论点已经多次阐述,但我想强调软件重用的另一个含义:安全性。

区块链安全至关重要。当你处理别人的钱时,你不能出错。通过对现有工具进行“手术”,而不是重新发明轮子,我们可以保留软件在我们干预之前所具有的大部分安全属性。然后,审核就变成了检查与原始差异的简单问题,而不是重新检查可能有 100k+ 行代码的代码库。

结果,Optimism 为堆栈的每个部分创建了“乐观”变体。我们现在将一一进行:

二. Optimism 的虚拟机

Optimistic Rollups 依靠使用欺诈证明来防止发生无效的状态转换。这需要在以太坊上执行 Optimism 交易。简单来说,如果对修改 Alice 的 ETH 余额的交易的结果存在争议,Alice 将尝试在以太坊上重放该确切交易以证明那里的正确结果。但是,某些 EVM 操作码在 L1 和 L2 上的行为将不会相同,如果它们依赖于一直在变化的系统范围参数,例如加载或存储状态,或获取当前时间戳。

因此,解决有关 L1 上的 L2 争议的第一步是一种机制,该机制保证可以重现在 L1 上执行 L2 事务时存在的任何“上下文”(理想情况下没有太多开销)。

目标:一个沙盒环境,保证 L1 和 L2 之间的确定性智能合约执行。

Optimism 的解决方案是Optimistic Virtual Machine。OVM 是通过将上下文相关的 EVM 操作码替换为其 OVM 对应物来实现的。

一个简单的例子是:

  • L2 事务调用TIMESTAMP操作码,返回例如 1610889676
  • 一小时后,交易(无论出于何种原因)在争议期间必须在以太坊 L1 上重播
  • 如果要在 EVM 中正常执行该事务,TIMESTAMP操作码将返回 1610889676 + 3600。我们不希望这样!
  • 在 OVM 中,TIMESTAMP操作码被替换ovmTIMESTAMP为在 L2 上执行事务时显示正确值的操作码 ovm{OPCODE}所有与上下文相关的 EVM 操作码在核心 OVM 智能合约中都有对应的ExecutionManager. 合约执行通过 EM 的主要入口点runfunction开始。这些操作码也被修改为具有可插入的状态数据库以与之交互,原因我们将在欺诈证明部分深入探讨。

Optimism's 不允许某些在 OVM 中“没有意义”的操作码,这是SafetyChecker一种智能合约,它有效地充当静态分析器,返回 1 或 0,具体取决于合约是否“OVM 安全”。

我们建议您参阅附录以获取每个修改/禁止操作码的完整说明。

Optimism 的汇总如下所示:

标有问号的区域将在欺诈证明部分涵盖,但在此之前,我们必须涵盖一些额外的基础。

三. Optimistic solidity

现在我们有了沙箱 OVM,我们需要将智能合约编译为 OVM 字节码。以下是我们的一些选择:

  • 创建一种新的智能合约语言,可以编译成 OVM:一种新的智能合约语言是一个容易被忽视的想法,因为它需要从头开始重新做所有事情,我们已经同意我们不会在这里这样做。
  • Transpile EVM bytecode to OVM bytecode:尝试过但由于复杂而放弃。
  • 通过修改编译器以生成 OVM 字节码来支持 Solidity 和 Vyper。

目前使用的方法是第3种。乐观主义分叉了 solc 并更改了约 500 行(有一点帮助)。

Solidity 编译器的工作原理是将 Solidity 转换为 Yul,然后转换为 EVM 指令,最后转换为字节码。Optimism 所做的更改既简单又优雅:对于每个操作码,在编译为 EVM 程序集之后,如果需要,尝试在其 ovm 变体中“重写”它(或者如果它被禁止则抛出错误)。

这有点做作解释,所以让我们通过比较这个简单合约的 EVM 和 OVM 字节码来使用一个示例:

$ solc C.sol --bin-runtime --optimize --optimize-runs 200
6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60336035565b005b60008054600101905556fea264697066735822122001fa42ea2b3ac80487c9556a210c5bbbbc1b849ea597dd6c99fafbc988e2a9a164736f6c634300060c0033

我们可以反汇编这段代码并深入研究操作码2以查看发生了什么(括号中的程序计数器):

...
[025] 35 CALLDATALOAD
...
[030] 63 PUSH4 0xc2985578 // id("foo()")
[035] 14 EQ
[036] 60 PUSH1 0x2d // int: 45
[038] 57 JUMPI // jump to PC 45
...
[045] 60 PUSH1 0x33
[047] 60 PUSH1 0x35 // int: 53
[049] 56 JUMP // jump  to PC 53
...
[053] 60 PUSH1 0x00
[055] 80 DUP1
[056] 54 SLOAD // load the 0th storage slot
[057] 60 PUSH1 0x01
[059] 01 ADD // add 1 to it
[060] 90 SWAP1
[061] 55 SSTORE // store it back
[062] 56 JUMP
...

这个程序集的意思是,如果 calldata 和foo()3的函数选择器之间存在匹配,那么SLOAD存储变量 at 0x00,添加0x01到它并SSTORE返回。听起来差不多!

这在 OVM 4中看起来如何?

$ osolc C.sol --bin-runtime --optimize --optimize-runs 200
60806040523480156100195760008061001661006e565b50505b50600436106100345760003560e01c8063c298557814610042575b60008061003f61006e565b50505b61004a61004c565b005b6001600080828261005b6100d9565b019250508190610069610134565b505050565b632a2a7adb598160e01b8152600481016020815285602082015260005b868110156100a657808601518282016040015260200161008b565b506020828760640184336000905af158601d01573d60011458600c01573d6000803e3d621234565260ea61109c52505050565b6303daa959598160e01b8152836004820152602081602483336000905af158601d01573d60011458600c01573d6000803e3d621234565260ea61109c528051935060005b60408110156100695760008282015260200161011d565b6322bd64c0598160e01b8152836004820152846024820152600081604483336000905af158601d01573d60011458600c01573d6000803e3d621234565260ea61109c5260008152602061011d56

这要大得多,让我们再次拆解它,看看有什么变化:

...
[036] 35 CALLDATALOAD
...
[041] 63 PUSH4 0xc2985578 // id("foo()")
[046] 14 EQ
[047] 61 PUSH2 0x0042
[050] 57 JUMPI // jump to PC 66
...
[066] 61 PUSH2 0x004a
[069] 61 PUSH2 0x004c // int: 76
[072] 56 JUMP // jump to PC 76

匹配函数选择器和之前是一样的,我们看看之后的情况:

...
[076] 60 PUSH1 0x01 // Push 1 to the stack (to be used for the addition later)
[078] 60 PUSH1 0x00
[080] 80 DUP1
[081] 82 DUP3
[082] 82 DUP3
[083] 61 PUSH2 0x005b
[086] 61 PUSH2 0x00d9 (int: 217)
[089] 56 JUMP // jump to PC 217
...
[217] 63 PUSH4 0x03daa959       // <---|  id("ovmSLOAD(bytes32)")
[222] 59 MSIZE                  //     |                                       
[223] 81 DUP2                   //     |                                       
[224] 60 PUSH1 0xe0             //     |                                       
[226] 1b SHL                    //     |                                       
[227] 81 DUP2                   //     |                                       
[228] 52 MSTORE                 //     |                                       
[229] 83 DUP4                   //     |                                       
[230] 60 PUSH1 0x04             //     | CALL to the CALLER's ovmSLOAD
[232] 82 DUP3                   //     |                                       
[233] 01 ADD                    //     |                                       
[234] 52 MSTORE                 //     |                                       
[235] 60 PUSH1 0x20             //     |                                       
[237] 81 DUP2                   //     |  
[238] 60 PUSH1 0x24             //     |                                     
[240] 83 DUP4                   //     |                                       
[241] 33 CALLER                 //     |                                       
[242] 60 PUSH1 0x00             //     |                                       
[244] 90 SWAP1                  //     |                                       
[245] 5a GAS                    //     |                                       
[246] f1 CALL                   // <---|

[247] 58 PC                     // <---|  
[248] 60 PUSH1 0x1d             //     |                                       
[250] 01 ADD                    //     |                                       
[251] 57 JUMPI                  //     |                                       
[252] 3d RETURNDATASIZE         //     |                                       
[253] 60 PUSH1 0x01             //     |                                       
[255] 14 EQ                     //     |                                       
[256] 58 PC                     //     |                                       
[257] 60 PUSH1 0x0c             //     |                                       
[259] 01 ADD                    //     |                                       
[260] 57 JUMPI                  //     |  Handle the returned data             
[261] 3d RETURNDATASIZE         //     |                                       
[262] 60 PUSH1 0x00             //     |                                       
[264] 80 DUP1                   //     |                                       
[265] 3e RETURNDATACOPY         //     |                                       
[266] 3d RETURNDATASIZE         //     |                                       
[267] 62 PUSH3 0x123456         //     |                                       
[271] 52 MSTORE                 //     |                                       
[272] 60 PUSH1 0xea             //     |                                       
[274] 61 PUSH2 0x109c           //     |                                       
[277] 52 MSTORE                 // <---|                                                            

这里发生了很多事情。然而,它的要点是,SLOAD字节码不是做一个 ,而是建立堆栈来制作一个CALL. 调用的接收者通过CALLER操作码被压入堆栈。每个调用都来自 EM,因此在实践中,CALLER调用 EM 是一种有效的方式。调用的数据以 for 的选择器开始ovmSLOAD(bytes32),然后是它的参数(在这种情况下,只是一个 32 字节的字)。之后,返回的数据被处理并添加到内存中。

继续:

...
[297] 82 DUP3
[298] 01 ADD // Adds the 3rd item on the stack to the ovmSLOAD value
[299] 52 MSTORE
[308] 63 PUSH4 0x22bd64c0  // <---| id("ovmSSTORE(bytes32,bytes32)")
[313] 59 MSIZE             //     |                                                           
[314] 81 DUP2              //     |                                                            
[315] 60 PUSH1 0xe0        //     |                                                                  
[317] 1b SHL               //     |                                                           
[318] 81 DUP2              //     |                                                            
[319] 52 MSTORE            //     |                                                              
[320] 83 DUP4              //     |                                                            
[321] 60 PUSH1 0x04        //     |                                                                  
[323] 82 DUP3              //     |                                                            
[324] 01 ADD               //     |  CALL to the CALLER's ovmSSTORE
[325] 52 MSTORE            //     |  (RETURNDATA handling is omited
[326] 84 DUP5              //     |   because it is identical to ovmSSLOAD)
[327] 60 PUSH1 0x24        //     |                                                                  
[329] 82 DUP3              //     |                                                            
[330] 01 ADD               //     |                                                           
[331] 52 MSTORE            //     |                                                              
[332] 60 PUSH1 0x00        //     |                                                                  
[334] 81 DUP2              //     |                                                            
[335] 60 PUSH1 0x44        //     |                                                                  
[337] 83 DUP4              //     |                                                            
[338] 33 CALLER            //     |                                                              
[339] 60 PUSH1 0x00        //     |                                                                  
[341] 90 SWAP1             //     |                                                             
[342] 5a GAS               //     |                                                           
[343] f1 CALL              // <---|                                                            
...

SLOAD与重新连接到外部调用的方式类似,ovmSLOAD重新SSTORE连接到进行外部调用ovmSSTORE。调用的数据不同,因为ovmSSTORE需要 2 个参数,即存储槽和要存储的值。这是一个并排的比较:

ovmSLOAD
   [217] 63 PUSH4 0x03daa959
   [222] 59 MSIZE           
   [223] 81 DUP2            
   [224] 60 PUSH1 0xe0      
   [226] 1b SHL             
   [227] 81 DUP2            
   [228] 52 MSTORE          
   [229] 83 DUP4            
   [230] 60 PUSH1 0x04      
   [232] 82 DUP3            
   [233] 01 ADD             
   [234] 52 MSTORE          
   [235] 60 PUSH1 0x20      
   [237] 81 DUP2            
   [238] 60 PUSH1 0x24      
   [240] 83 DUP4            
   [241] 33 CALLER          
   [242] 60 PUSH1 0x00      
   [244] 90 SWAP1           
   [245] 5a GAS             
   [246] f1 CALL  
ovmSSTORE
   [308] 63 PUSH4 0x22bd64c0
   [313] 59 MSIZE           
   [314] 81 DUP2            
   [315] 60 PUSH1 0xe0      
   [317] 1b SHL             
   [318] 81 DUP2            
   [319] 52 MSTORE          
   [320] 83 DUP4            
   [321] 60 PUSH1 0x04      
   [323] 82 DUP3            
   [324] 01 ADD             
   [325] 52 MSTORE          
   [326] 84 DUP5            
   [327] 60 PUSH1 0x24      
   [329] 82 DUP3            
   [330] 01 ADD             
   [331] 52 MSTORE          
   [332] 60 PUSH1 0x00      
   [334] 81 DUP2            
   [335] 60 PUSH1 0x44      
   [337] 83 DUP4            
   [338] 33 CALLER          
   [339] 60 PUSH1 0x00      
   [341] 90 SWAP1           
   [342] 5a GAS             
   [343] f1 CALL            

实际上,我们不是先调用 aSLOAD再调用 a SSTORE,而是调用执行管理器的方法ovmSLOAD,然后调用它的ovmSSTORE方法。

比较 EVM 与 OVM 的执行(我们只展示了SLOAD执行的一部分),我们可以看到通过执行管理器发生的虚拟化。此功能在此处此处实现。

这种虚拟化技术有一个“陷阱”:

合约大小限制更快达到:通常,以太坊合约在字节码大小中可以达到 24KB 。使用 Optimistic Solidity Compiler 编译的合约最终会比原来更大,这意味着必须重构接近 24KB 限制的合约,以便它们的 OVM 大小仍然适合 24KB 限制,因为它们需要在以太坊主网上执行(例如,通过使外部调用库而不是内联库字节码。)合约大小限制保持不变,因为 OVM 合约必须可在以太坊上部署。

四. Optimistic Geth

以太坊最流行的实现是 go-ethereum(又名 geth)。让我们看看通常如何在 go-ethereum 中执行交易。

在每个块上,都会调用状态处理器Process,它会调用ApplyTransaction每个事务。在内部,事务被转换为消息。消息被应用到当前状态,并且新产生的状态最终被存储回数据库中。这个核心数据流在 Optimistic Geth 上保持不变,并进行了一些修改以使事务“OVM 友好”:

修改 1:通过 Sequencer 入口点的 OVM 消息

事务被转换为OVM 消息。由于消息被剥离了它们的签名,消息数据被修改为包括交易签名(以及原始交易的其余字段)。该to字段被替换为“ sequencer entrypoint ”合约的地址。这样做是为了获得紧凑的交易格式,因为它将发布到以太坊,并且我们已经确定我们的压缩越好,我们的扩展优势就越好。

修改 2:通过执行管理器的 OVM 沙盒

为了通过 OVM 沙箱运行事务,它们_必须_被发送到执行管理器的run 函数。不再要求用户仅提交符合该限制的事务,而是将所有消息修改为在内部发送到执行管理器。这里发生的事情很简单:消息的to字段被执行管理器的地址替换,消息的原始数据被打包为 run 的参数。

由于这可能有点不直观,我们整理了一个存储库来给出一个具体的例子:https ://github.com/gakonst/optimism-tx-format 。

修改 3:拦截对状态管理器的调用

StateManager 是一个特殊的合约,它在 Optimistic Geth 上不存在。它仅在欺诈证明期间部署。细心的读者会注意到,在打包参数以进行run调用时,Optimism 的 geth 还打包了一个硬编码的状态管理器地址。这就是最终被用作任何ovmSSTORE或ovmSLOAD(或类似)呼叫的最终目的地的原因。在 L2 上运行时,任何针对 State Manager 合约的消息都会被拦截,并且它们会直接与 Geth 的 StateDB 对话(或什么也不做)。

对于寻找整体代码更改的人来说,最好的方法是搜索UsingOVM并比较与 geth 1.9.10的差异。

修改 4:基于 Epoch 的批次而不是块

OVM 没有区块,它只是维护一个有序的交易列表。因此,没有区块气体限制的概念;相反,总的气体消耗是基于时间段的速率限制,称为 epochs 8。在执行交易之前,会检查是否需要启动新的 epoch,并在执行后将其 gas 消耗添加到用于该 epoch 的累积 gas 上。对于 sequencer 提交的交易和“L1 到 L2”交易,每个 epoch 有一个单独的 gas 限制。任何超过epoch的 gas 限制的交易都会提前返回。这意味着操作员可以在一个链上批次中发布多个具有不同时间戳的交易(时间戳由 sequencer 定义,有一些限制,我们在“数据可用性批次”部分解释)。

修改五:汇总同步服务

同步服务是一个与“正常”geth 操作一起运行的新进程。它负责监控以太坊日志,处理它们,并通过geth 的 worker注入相应的 L2 事务以应用于 L2 状态。

五. TOptimistic Rollup

Optimism 的汇总是一个汇总,使用:

  • OVM 作为其运行时/状态转换函数
  • Optimistic Geth 作为具有单个 Seqencer 的 L2 客户端
  • 部署在以太坊上的 Solidity 智能合约用于:
    • 数据可用性
    • 争议解决和欺诈证明9在本节中,我们将深入研究实现数据可用性层的智能合约,并探索端到端的欺诈证明流程。

1. 数据可用性批次

正如我们之前看到的,交易数据被压缩,然后发送到 L2 上的 Sequencer 入口点合约。然后,定序器负责“批量”“汇总”这些交易并在以太坊上发布数据,提供数据可用性,以便即使定序器消失,也可以启动新的定序器以从中断的地方继续。

以太坊上实现该逻辑的智能合约称为规范交易链(CTC)。规范交易链是一个仅附加的日志,代表汇总链的“官方历史”(所有交易,以及以什么顺序)。交易要么由排序器(可以将交易插入链中的优先方)提交给 CTC,要么通过馈入 CTC 的先进先出队列提交给 CTC。为了保持 L1 的抗审查保证,任何人都可以将交易提交到这个队列,在延迟后强制它们被包含到 CTC 中。

CTC 为每批发布的 L2 交易提供数据可用性。可以通过 2 种方式创建批次:

  • 每隔几秒钟,sequencer 就会检查他们收到的新事务,将它们批量汇总,以及所需的任何其他元数据。然后他们通过在以太坊上发布该数据appendSequencerBatch。这是由批处理提交服务自动完成的。
  • 当排序器审查其用户时(即不包括他们提交的交易批次)或当用户想要从 L1 到 L2 进行交易时,用户应该调用enqueueand appendQueueBatch,这“强制”将他们的交易包含在 CTC 中。

这里的一个边缘情况如下:如果定序器已经广播了一个批次,用户可以强制包含一个触及与批次冲突的状态的事务,从而可能使该批次的一些事务无效。为了避免这种情况,引入了时间延迟,在此之后,非序列帐户可以将批次附加到队列中。另一种思考方式是,给定序器一个“宽限期”来包括通过 的交易appendSequencerBatch,否则用户将appendQueueBatch。

鉴于交易大多预计通过排序器提交,因此值得深入研究批处理结构和执行流程:

你可能会注意到它appendSequencerBatch不需要任何参数。批次以紧凑的格式提交,而使用 ABI 编码和解码的效率会低得多。它使用内联汇编对调用数据进行切片并以预期格式解包。

一个批次由以下部分组成:

  • 标题
  • 批处理上下文(>=1,注意:这个上下文和我们上面 OVM 部分提到的消息/事务/全局上下文不同)
  • 交易 (>=1)

批处理的标头指定上下文的数量,因此序列化的批处理看起来像[header, context1, context2, …, tx1, tx2, ... ]

该函数继续做两件事:

  • 验证所有与上下文相关的不变量都适用
  • 从已发布的交易数据中创建一个默克尔树

如果上下文验证通过,则将批次转换为OVM 链批次标头,然后将其存储在 CTC 中。

存储的标头包含批次的 merkle 根,这意味着证明包含交易是一个简单的问题,即提供一个 merkle 证明来验证 CTC 中存储的 merkle 根。

这里的一个自然问题是:这似乎太复杂了!为什么需要上下文?

上下文对于排序器来说是必要的,它可以知道一个排队的事务应该在排序事务之前还是之后执行。让我们看一个例子:

在时间 T1,排序器已收到 2 笔交易,它们将包含在他们的批次中。在 T2 (>T1) 时,用户还对事务进行排队,将其添加到L1 到 L2 事务队列(但不将其添加到批处理中!)。在 T2 时,定序器再收到 1 个事务,并且还有 2 个事务也入队。换句话说,待处理事务的批处理看起来像:

[(sequencer, T1), (sequencer, T1), (queue, T2), (sequencer, T2), (queue, T3), (queue, T4)]

为了在保持序列化格式紧凑的同时保持时间戳(和块号)信息,我们使用“上下文”,即序列器和排队事务之间的共享信息包。上下文必须具有严格增加的块数和时间戳。在一个上下文中,所有排序器事务共享相同的块号和时间戳。对于“队列交易”,时间戳和块号设置为入队调用时的任何值。在这种情况下,该批次事务的上下文将是:

[{ numSequencedTransactions: 2, numSubsequentQueueTransactions: 1, timestamp: T1}, {numSequencedTransactions: 1, numSubsequentQueueTransactions: 2, timestamp: T2}]

2. 状态承诺

在以太坊中,每笔交易都会对状态和全局状态根进行修改。证明账户在某个区块拥有一些 ETH 是通过在区块中提供状态根和证明账户状态与声明值匹配的 merkle 证明来完成的。由于每个块包含多个事务,并且我们只能访问状态根,这意味着我们只能在整个块执行后才能对状态进行声明。

一点历史:

在EIP98和拜占庭硬分叉之前,以太坊交易在每次执行后都会产生中间状态根,通过交易收据提供给用户。TL;DR 是删除它可以提高性能(有一点警告),所以它很快就被采用了。EIP PR658中给出的额外动机解决了这个问题:收据的PostState字段指示对应于 post-tx 执行状态的状态根被替换为布尔状态字段,指示交易的成功状态。

事实证明,警告并非微不足道。EIP98 的基本原理部分写道:

这种变化确实意味着,如果矿工创建了一个状态转换处理不正确的区块,那么就不可能针对该交易制作欺诈证明;相反,欺诈证明必须包含整个区块。

此更改的含义是,如果一个区块有 1000 笔交易,并且您在第 988 笔交易中检测到欺诈,您需要在实际执行您感兴趣的交易之前在前一个区块的状态之上运行 987 笔交易,并且这将使欺诈证明显然非常低效。以太坊本身没有欺诈证明,所以没关系!

另一方面,关于乐观主义的欺诈证明至关重要。之前,我们提到乐观没有障碍。那是个小谎言:乐观有区块,但每个区块都有 1 个交易,我们称这些“微区块” 10。由于每个微区块包含 1 个交易,因此每个区块的状态根实际上是单个交易产生的状态根。万岁!我们重新引入了中间状态根,而无需对协议进行任何重大更改。这当然目前有一个恒定大小的性能开销,因为从技术上讲,微块仍然是块并且包含冗余的附加信息,但是这种冗余可以在将来删除(例如,使所有微块都具有 0x0 作为块哈希,并且只填充 RPC 中的修剪字段要求向后兼容)

我们现在可以介绍状态承诺链(SCC)。SCC 包含一个状态根列表,在乐观的情况下,它对应于将 CTC 中的每个事务应用于前一个状态的结果。如果不是这种情况,则欺诈验证过程允许删除无效的状态根及其所有后续状态,以便可以为这些交易提出正确的状态根。

与 CTC 不同,SCC 没有任何精美的数据表示。它的目的很简单:给定一个状态根列表,它将它们默克化并保存包含在批处理中的中间状态根的默克尔根,以供以后通过appendStateBatch.

3. 欺诈证明

现在我们了解了 OVM 的基本概念以及将其状态锚定在以太坊上的支持功能,让我们深入探讨争议解决,也就是欺诈证明。

定序器做了 3 件事:

  • 接收来自其用户的交易
  • 将这些交易批量汇总并发布到规范交易链中
  • 将交易产生的中间状态根作为状态批次发布到状态承诺链中。

例如,如果在 CTC 中发布了 8 个交易,那么对于每个状态转换 S1 到 S8,将有 8 个状态根在 SCC 中发布。

但是,如果定序器是恶意的,他们可以在 state trie 中将其账户余额设置为 1000 万 ETH,这显然是非法操作,使 state root 以及所有跟随它的 state root 无效。他们会通过发布如下数据来做到这一点:

我们注定要失败吗?我们必须做点什么!

众所周知,Optimistic Rollup 假设存在验证者:对于排序器发布的每个事务,验证者负责下载该事务并将其应用于其本地状态。如果一切都匹配,他们什么也不做,但如果不匹配,那就有问题了!为了解决这个问题,他们会尝试在以太坊上重新执行 T4 以产生 S4。然后,在 S4 之后发布的任何状态根都将被修剪,因为不能保证它对应于有效状态:

从高层次来看,欺诈证明声明是“使用 S3 作为我的起始状态,我想表明在 S3 上应用 T4 会导致 S4 与测序仪发布的不同 (😈)。因此,我希望删除 S4 及其之后的所有内容。”

这是如何实施的?

您在图 1 中看到的是在 L2 中以“简单”执行模式运行的 OVM。在 L1 中运行时,OVM 处于防欺诈模式,并且启用了它的更多组件(执行管理器和安全检查器部署在L1和 L2 上):

  • 欺诈验证者:协调整个欺诈证明验证过程的合同。它调用状态转换工厂来初始化一个新的欺诈证明,如果欺诈证明成功,它会从状态承诺链中删除在争议点之后发布的任何批次。
  • 状态转换器:当使用预状态根创建争议并且交易存在争议时,由欺诈验证器部署。它的职责是调用Execution Manager 11并根据规则忠实地执行链上交易,为有争议的交易生成正确的后状态根。成功执行的欺诈证明将导致状态转换器中的状态后根与状态承诺链中的状态根不匹配。状态转换器可以处于以下 3 种状态中的任何一种:PRE EXECUTION, POST EXECUTION, COMPLETE.
  • 状态管理器:用户提供的任何数据都存储在这里。这是一个“临时”状态管理器,仅用于欺诈证明,并且仅包含有关有争议交易所触及的状态的信息。

以防欺诈模式运行的 OVM 如下所示:

欺诈证明分为几个步骤:

第 1 步:声明您正在争论的状态转换
  • 用户调用欺诈验证者initializeFraudVerification,提供状态前根(以及它包含在状态承诺链中的证明)和有争议的交易(以及它包含在交易链中的证明)。
  • State Transitioner 合约通过 State Transitioner Factory 部署。
  • State Manager 合约通过 State Manager Factory 部署。它不会包含整个 L2 状态,而只会填充交易所需的部分;您可以将其视为“部分状态管理器”。

状态转换程序现在处于PRE EXECUTION阶段。

第二步:上传所有交易前状态

如果我们尝试直接执行有争议的事务,它将立即失败并出现 INVALID_STATE_ACCESS 错误,因为它所触及的所有 L2 状态都没有加载到步骤 1 中新部署的 L1 状态管理器上。OVM 沙箱将检测是否SM 尚未填充某些触摸状态,并强制首先加载所有触摸状态需求。

例如,如果有争议的交易是简单的 ERC20 代币转移,最初的步骤是:

  • 在 L1 12上部署 ERC20 :L2 和 L1 合约的合约字节码必须匹配才能在 L1 和 L2 之间具有相同的执行。我们保证用字节码的“魔术”前缀将其复制到内存中并将其存储在指定的地址。
  • Call proveContractState:这会将 L2 OVM 合约与新部署的 L1 OVM 合约链接在一起(合约已部署并链接,但仍没有加载存储)。链接意味着OVM 地址用作映射中的键,其中值是包含合约账户状态的结构。
  • 调用proveStorageSlot:标准 ERC20 转账将发送方的余额减少一定数量,并将接收方的余额增加相同数量,通常存储在映射中。这将在交易执行之前上传接收方和发送方的余额。对于 ERC20,余额通常存储在映射中,因此keccak256(slot + address)根据 Solidity 的存储布局,键是。

第 3 步:提供所有预状态后,运行事务

然后,用户必须通过调用 State Transitioner's 来触发事务的执行applyTransaction。在这一步中,执行管理器开始使用欺诈证明的状态管理器执行交易。执行完成后,State Transitioner 转换到POST EXECUTION阶段。

第 4 步:提供后状态

在 L1 上执行期间(步骤 3),合约存储槽或帐户状态(例如 nonces)中的值将发生变化,这将导致 State Transitioner 的后状态根发生变化。但是,由于 State Transitioner / State Manager 对不知道整个 L2 状态,它们无法自动计算新的后状态根。

为了避免这种情况,如果存储槽的值或帐户的状态发生更改,则存储槽或帐户会被标记为“已更改”,并且未提交的存储槽或帐户的计数器会增加。我们要求对于每个被更改的项目,用户还提供来自 L2 状态的默克尔证明,表明这确实是观察到的值。每次“提交”存储槽更改时,都会更新合约账户的存储根。在所有更改的存储槽都已提交后,合约的状态也已提交,更新转换者的 post-state root。对于发布的每条状态后数据,计数器相应递减。

因此,预计在事务中涉及的所有合约的状态更改都已提交后,生成的状态后根是正确的。

第 5 步:完成状态转换并最终确定欺诈证明

完成状态转换是一个简单的调用completeTransition,它要求步骤 4 中的所有帐户和存储槽都已提交(通过检查未提交状态的计数器是否等于 0)。

最后,finalizeFraudVerification在欺诈验证器合约上调用它检查状态转换是否完成,如果是,它调用deleteStateBatch它继续从 SCC 中删除(包括)有争议的交易之后的所有状态根批次。CTC 保持不变,因此原始交易以相同的顺序重新执行。

4. 激励与债券

为了保持系统开放和无需许可,SCC 旨在允许任何人成为定序器并发布状态批次。为了避免 SCC 被垃圾数据发送,我们引入了 1 个限制:

排序器必须由新的智能合约债券管理器标记为抵押。您通过存入固定金额获得抵押,您可以延迟 7 天取出。

然而,在抵押之后,恶意的提议者可以反复制造欺诈性的国家根,希望没有人质疑他们,这样他们就可以赚钱了。忽略用户在社交上协调从 rollup 和 evil sequencer 迁移的场景,这里的攻击成本是最小的。

该解决方案在 L2 系统设计中非常标准:如果欺诈被成功证明,X% 的提议者债券将被烧掉13,剩余的 (1-X)%按比例分配给为第 2 步和第 4 步提供数据的每个用户欺诈证明。测序仪的背叛成本现在要高得多,并希望能产生足够的激励来防止他们做出恶意行为,假设他们的行为是理性的14。这也为用户提交数据以证明欺诈提供了很好的激励,即使有争议的状态不会直接影响他们。

5. Nuisance gas

gas 有一个单独的维度,称为“nuisance gas”,用于限制欺诈证明的净 gas 成本。特别是,欺诈证明设置阶段的见证数据(例如 merkle 证明)不会反映在 L2 EVM 气体成本表中。ovmOPCODES有单独的滋扰气体成本,每当触及新的存储槽或帐户时都会收取费用。如果消息尝试使用比消息上下文允许的更多的有害气体,则执行恢复。

六. 回顾与总结

有很多事情发生。总结是,只要有状态转换:

  • 如果他们不同意,有人会提出异议
  • 他们将在以太坊上发布所有相关状态,包括每个状态的一堆默克尔证明
  • 他们将在链上重新执行状态转换
  • 他们将因正确争论而获得奖励,恶意排序者将被削减,无效状态根将被修剪以保证安全

这一切都在 Optimism 的Fraud Prover 服务中实现,该服务与docker compose image中的 optimistic-geth 实例打包在一起。

原文链接:https://research.paradigm.xyz/optimism#data-availability-batches

推荐文章1:https://github.com/ethereum-optimism/optimistic-specs/tree/alpha 推荐文章2: https://medium.com/infinitism/optimistic-time-travel-6680567f1864 推荐文章3: https://ethereum.org/en/developers/tutorials/optimism-std-bridge-annotated-code/

全部评论(0)