简单来说,Arbitrum 在 Layer 2 实现了 AVM 虚拟机,在虚拟机上再模拟 EVM 执行环境。

推荐阅读:《链闻精选 | 读懂以太坊扩容热门选手 Arbitrum

原文标题:《L2 – 深入理解 Arbitrum》
撰文:Star Li

Arbitrum 是 Layer2 Rollup 的一种方案。和 Optimism 类似,状态的终局性采用「挑战」(challenge) 机制进行保证。Optimism 的挑战方法是将某个交易完全在 Layer1 模拟执行,判断交易执行后的状态是否正确。这种方法需要在 Layer 1 模拟 EVM 的执行环境,相对复杂。Arbitrum 的挑战相对轻便一些,在 Layer1 执行某个操作(AVM),确定该操作执行是否正确。Arbitrum 介绍文档中提到,整个挑战需要大概 500 字节的数据和 9w 左右的 gas。为了这种轻便的挑战机制,Arbitrum 实现了 AVM 虚拟机,并在 AVM 虚拟机中实现了 EVM 的执行。AVM 虚拟机的优势在于底层结构方便状态证明。

Arbitrum 的开发者 文档 详细介绍了 Arbitrum 架构和设计。对 AVM 以及 L1/L2 交互细节感兴趣的小伙伴可以耐心地查看「Inside Arbitrum」章节。

整体框架

Arbitrum 的开发者文档给出了各个模块关系:

深入理解以太坊二层方案 Arbitrum 技术架构

Arbitrum 的系统主要由三部分组成(图中的右部分,从下到上):EthBridge,AVM 执行环境和 ArbOS。EthBridge 主要实现了 inbox/outbox 管理以及 Rollup 协议。EthBridge 实现在 Layer1。ArbOS 在 AVM 虚拟机上执行 EVM。简单的说,Arbitrum 在 Layer2 实现了 AVM 虚拟机,在虚拟机上再模拟 EVM 执行环境。用 AVM 再模拟 EVM 的原因是 AVM 的状态更好表达,便于 Layer1 进行挑战。

这个模块关系图太过笼统,再细分一下:

深入理解以太坊二层方案 Arbitrum 技术架构

EthBridge 主要实现了三部分功能:inbox,outbox 以及 Rollup 协议。inbox 中「存放」交易信息,这些交易信息会「同步」到 ArbOS 并执行。outbox 中「存放」从 L2 到 L1 的交易,主要是 withdrawl 交易。Rollup 协议主要是 L2 的状态保存以及挑战。特别注意的是,Arbitrum 的所有的交易都是先提交到 L1,再到 ArbOS 执行。ArbOS 除了对外的一些接口外,主要实现了 EVM 模拟器。整个模拟器实现在 AVM 之上。整个 EVM 模拟器采用 mini 语言实现,Arbitrum 实现了 AVM 上的 mini 语言编译器。简单的说,Arbitrum 定义了新的硬件(machine)和指令集,并实现了一种上层语言 mini。通过 mini 语言,Arbitrum 实现了 EVM 模拟器,可以执行相应交易。

AVM State

因为所有的交易都是在 AVM 执行,交易的执行状态可以用 AVM 状态表示。AVM 相关实现的代码在 arbitrum/packages/arb-avm-cpp 中。

深入理解以太坊二层方案 Arbitrum 技术架构

AVM 的状态由 PC,Stack,Register 等状态组成。AVM 的状态是这些状态的 hash 值拼接后的 hash 结果。

AVM 使用 c++实现,AVM 表示的逻辑实现在 MachineStateKeys 类的 machineHash 函数(machinestate.cpp)中。AVM 的特别之处就是除了执行外,还能较方便的表达(证明)执行状态。深入理解 AVM 的基本数据结构,AVM 的基本的数据类型包括:

 using value =   std::variant<Tuple, uint256_t, CodePointStub, HashPreImage, Buffer>;

 enum ValueTypes { NUM, CODEPT, HASH_PRE_IMAGE, TUPLE, BUFFER = 12, CODE_POINT_STUB = 13 };   
  • uint256_t – 整数类型

  • CodePoint – 当前代码指令表示

  • Tuple – 元组,由 8 个 Value 组成。元组中的某个元素依然可以是元组

  • Buffer – 数组,最长为 2^64

  • HashPreImage – 固定的 hash 类型,hashValue = hash(value, prevHashValue)

每种数据类型除了数据表示外,还能非常方便地计算其 hash 值作为状态。详细看看 CodePoint 和 Tuple 基本数据类型。

CodePoint

CodePoint 类型将多个操作「捆绑」在一起,每个 CodePoint 除了记录当前的 Operation 外,还包括前一个 CodePoint 的 hash 信息。这样所有的 Operation 可以串连起来,当前的 CodePoint 除了能表达当前的 Operation 外,还能明确 Operation 的依赖关系。CodePoint 的类型定义在:packages/arb-avm-cpp/avm_values/include/avm_values/codepoint.hpp。

     struct CodePoint {  
         Operation op;  
         uint256_t nextHash;  

         CodePoint(Operation op_, uint256_t nextHash_)  
             : op(op_), nextHash(nextHash_) {}  

         bool isError() const {  
             return nextHash == 0 && op == Operation{static_cast(0)};  
         }  
     };

Tuple

Tuple 类型由 RawTuple 实现。RawTuple 是由一组 value 组成。Tuple 限制最多 8 个 value。

     struct RawTuple {  
         HashPreImage cachedPreImage;  
         std::vector data;  
         bool deferredHashing = true;  

         RawTuple() : cachedPreImage({}, 0), deferredHashing(true) {}  
     };

Tuple 的类型定义在:packages/arb-avm-cpp/avm_values/include/avm_values/tuple.hpp。

在理解了基础类型的基础上,DataStack 可以由一系列 Tuple 实现:

深入理解以太坊二层方案 Arbitrum 技术架构

总结一下,AVM 中的 PC,Stack,Register 等等的状态都能通过 hash 结果表示。AVM 整个状态由这些 hash 值的拼接数据的 hash 表示。

Rollup Challenge

在提交到 L1 的状态有分歧时,挑战双方(Asserter 和 Challenger)先将状态分割,找出「分歧点」。明确分歧点后,挑战双方都可提供执行环境,L1 执行相关操作确定之前提交的状态是否正确。L1 的挑战处理逻辑实现在 arb-bridge-eth/contracts/challenge/Challenge.sol。整个挑战机制有超时机制保证,为了突出核心流程,简化流程如下图所示:

深入理解以太坊二层方案 Arbitrum 技术架构

挑战者通过 initializeChallenge 函数发起挑战。接下来挑战者 (Challenger) 和应战者 (Asserter) 通过 bisectExecution 确定不可再分割的「分歧点」。在确定分歧点后,挑战者通过 oneStepProveExecution 函数确定 Assert 之前提交的状态是否正确。

initializeChallenge

function initializeChallenge(  
             IOneStepProof[] calldata_executors,  
             address_resultReceiver,  
             bytes32_executionHash,  
             uint256_maxMessageCount,  
             address_asserter,  
             address_challenger,  
             uint256_asserterTimeLeft,  
             uint256_challengerTimeLeft,  
             IBridge_bridge  
 ) external override {  
             ...  
            asserter =_asserter;  
             challenger =_challenger;  
             ...  
             turn = Turn.Challenger;  
             challengeState =_executionHash;  
             ...  
    }

initializeChallenge 确定挑战者和应战者,并确定需要挑战的状态(存储在 challengeState)。challengeState 是由一个和多个 bisectionChunk 状态 hash 组成的 merkle 树树根:

深入理解以太坊二层方案 Arbitrum 技术架构

整个执行过程可以分割成多个小过程,每个小过程 (bisection) 由起始和结束的 gas 和状态来表示。

turn 用来记录交互顺序。turn = Turn.Challenger 表明在初始化挑战后,首先由 Challenger 发起分歧点分割。

bisectExecution

bisectExecution 挑选之前分割片段,并如可能将片段进行再次分割:

bisectExecution 的函数定义如下:

function bisectExecution(  
             bytes32[] calldata_merkleNodes,                                                              
             uint256_merkleRoute,                                                                         
             uint256_challengedSegmentStart,                                                              
             uint256_challengedSegmentLength,                                                             
             bytes32_oldEndHash,  
             uint256_gasUsedBefore,  
             bytes32_assertionRest,                                                                       
             bytes32[] calldata_chainHashes                                                               
        ) external onlyOnTurn {

_chainHashes 是再次分割点的状态。如果需要再次分割,需要满足分割点的个数规定:

uint256 private constant EXECUTION_BISECTION_DEGREE = 400;

        require(
            _chainHashes.length ==                                                                  
                 bisectionDegree(_challengedSegmentLength, EXECUTION_BISECTION_DEGREE) + 1,          
             "CUT_COUNT"
 );

简单的说,每次分割,必须分割成 400 份。

_oldEndHash 是用来验证状态这次分割的分割片段是上一次分割中的某个。需要检查分割的有效性:

 require(_chainHashes[_chainHashes.length - 1] !=_oldEndHash, "SAME_END");                  

         require(
            _chainHashes[0] == ChallengeLib.assertionHash(_gasUsedBefore,_assertionRest),          
             "segment pre-fields"                                                                    
         );  
         require(_chainHashes[0] != UNREACHABLE_ASSERTION, "UNREACHABLE_START");                     

         require(
            _gasUsedBefore <_challengedSegmentStart.add(_challengedSegmentLength),                 
             "invalid segment length"                                                                
         );  

起始状态正确。这次分割不能超出上次分割范围,并且最后一个状态和上一个分割的结束状态不一样。

 bytes32 bisectionHash =                                                                     
             ChallengeLib.bisectionChunkHash(                                                        
                _challengedSegmentStart,                                                            
                _challengedSegmentLength,
                _chainHashes[0],
                _oldEndHash
             );
         verifySegmentProof(bisectionHash,_merkleNodes,_merkleRoute);

通过 merkle 树的路径检查确定起始状态和结束状态是上一次某个分割。

updateBisectionRoot(_chainHashes,_challengedSegmentStart,_challengedSegmentLength);

更新细分分割对应的 challengeState。

深入理解以太坊二层方案 Arbitrum 技术架构

oneStepProveExecution

当不能分割后,挑战者提供初始状态(证明),并由 L1 进行相应的计算。计算的结果应该和提供的 _oldEndHash 不一致。不一致说明挑战者成功证明了之前的计算结果不对。

(uint64 gasUsed, uint256 totalMessagesRead, bytes32[4] memory proofFields) = executors[prover].executeStep(
         bridge,
         _initialMessagesRead,
          [_initialSendAcc,_initialLogAcc],
         _executionProof,
         _bufferProof
);

通过 executeStep 计算出正确的结束状态。executeStep 实现在 packages/arb-bridge-eth/contracts/arch/OneStepProofCommon.sol 中。核心是 executeOp 函数,针对当前的 context 读取 op,执行并更新状态。感兴趣的小伙伴可以自行查看。

rootHash = ChallengeLib.bisectionChunkHash(  
           _challengedSegmentStart,  
           _challengedSegmentLength,  
            oneStepProofExecutionBefore(  
                       _initialMessagesRead,  
                       _initialSendAcc,  
                       _initialLogAcc,  
                       _initialState,  
                         proofFields  
                     ),  
               _oldEndHash  
                 );  
             }  

verifySegmentProof(rootHash,_merkleNodes,_merkleRoute);

确定初始状态和结束状态是上一次挑战状态中的某个分割。初始状态由提供的证明(proof)计算获得。

require(  
  _oldEndHash !=  
               oneStepProofExecutionAfter(  
                           _initialSendAcc,  
                           _initialLogAcc,  
                           _initialState,  
                             gasUsed,  
                             totalMessagesRead,  
                             proofFields  
                ),  
          "WRONG_END"  
        );

确认 _oldEndHash 和计算获得结束状态不一样。不一样才说明之前提交的结束状态是错误的。

_currentWin();

计算完成后,确定胜利方。

总结

Arbitrum 是 Layer2 Rollup 的一种方案。采用挑战机制确定 Rollup 状态的终局性。为了引入轻便挑战机制,Arbitrum 定义了 AVM,一种可以方便证明执行状态的虚拟机,并设计了 mini 语言和编译器。在 AVM 上模拟了 EVM 的执行环境,兼容 EVM。挑战时将执行过程进行 400 分分割,由 L1 执行少量指令确定状态是否正确。