Arbitrum Nitro 是怎样扩容的以及如何使用它

Arbitrum 一个区块链上的区块链,我们要研究一下它

你听说过Arbitrum Nitro吗?新的WAVM可以实现Plasma,以一种超级高效的方式用于智能合约! 它使侧链拥有了以太坊主链的安全保证。到目前为止,Arbitrum已经是最成功的第二层之一,新的Nitro是它的一个重要升级。

让我们从头开始...

什么是默克尔树?

Merkle 树是这种扩容技术工作的基础。Merkle树的根是根hash。它是由所有叶子节点的原始值的哈希值创建而来。现在,两个叶子的哈希值组合起来创建一个新的哈希值, 一直向上组合,直到生成只有一个根哈希值的树。现在,一个Merkle证明是你向只知道根哈希值的人证明某个值实际上是这棵树的叶子之一的方法。

这里有一篇Merkle 树指南,如果你想更深入地了解这个话题,可以看看。

智能合约的状态

在以太坊中,一棵Merkle树是状态树,它包含所有的状态,如用户ETH余额,也包含合约存储。这使得我们可以在智能合约状态上创建Merkle证明!

所以可以用Merkle证明机制来证明一个智能合约具有某种状态。记住这一点稍后待用。

Plasma是如何工作的?

Plasma 使用智能合约和Merkle证明的组合。这些结合在一起,通过将这些交易从以太坊主区块链上转移到Plasma链上,实现快速和低廉的交易。与普通侧链相比,你不能在这里运行任何智能合约。

在Plasma中,用户以UTXO的方式在彼此之间发送交易,其中新的余额的结果作为Merkle树根在以太坊智能合约中持续更新。一旦Merkle树根在智能合约中被更新,即使Plasma链运营商是恶意的,它也能给保证用户资金的安全。Merkle树根囊括了许多笔资金交易的结果。如果Plasma运营商提交了一个无效的根,用户可以提出异议,并安全地取回他们的资金。更多细节请看这里

但如前所述,它不能运行智能合约。所以不可能与Plasma进行Uniswap。

Arbitrum: 如何在区块链上运行一个区块链

但这就是Arbitrum的用武之地,它是智能合约版的Plasma!

Yo Dawg Optimism

这里的核心思想其实很简单。就像在Plasma中,有一个运行所有交易的 layer2 链,及偶尔更新Layer1的Merkle根。在Arbitrum中,Merkle 根不是像普通plasma那样用于UTXO交易,而是包含智能合约的全部状态。或者说是所有正在使用的智能合约的全部状态。

是的,这意味着我们可以在Arbitrum上运行任意的智能合约! 简单归纳一下它的工作方式:

  • 将智能合约状态表现为Merkle树
  • 只在Arbitrum链上运行所有交易
  • 持续更新以太坊第一层的状态根
  • Arbitrum链的安全性很低,但通过以太坊上的状态根,则可以实现欺诈证明
  • 当Layer2的验证者提交了一个恶意的状态根并被质疑时,验证者会失去他们的保证金。
  • 欺诈证明的成本很高,但通过交互式机制比Optimism更有效(详见下文)。
  • 运行单个有争议的执行步骤,证明者提交任何需要的状态。

现在你可能意识到,这就是扩容的来源。你只在第1层运行有争议的、有欺诈证明的交易。所以,扩容的优势完全来自于你不会在第1层运行99.9%的交易这一事实。

Arbitrum 详细概述

Arbitrum Nitro背后的大概念是:

  1. 序列化
  2. 核心是Geth
  3. 用于证明的Wasm
  4. 通过交互式欺诈证明进行乐观 Rollup

Arbitrrum Sequencing

为了实际运行交易,我们需要原生 Geth、Geth与Wasm和Merkle证明。该架构看起来像这样。

  • 在最上层,你有区块链节点功能。
  • ArbOS处理L2功能,如批量解包和桥接。
  • 核心Geth EVM 执行合约,可以是原生合约或使用 WASM 的合约。

Geth at core

交易是如何被包含的?

新的交易可以通过三种方式被添加:

  1. 定序器(Sequencer)的正常打包包含

  2. 通过定序器,从L1的消息中加入

  3. 来自L1的消息被强制包含在L2上

1. 定序器的正常打包包含

在正常情况下,目前仍然中心化的定序器将把新的消息添加到收件箱中。这是通过调用addSequencerL2Batch完成的。函数检查发送者是否是保存的定序器,只有他被允许调用这个函数。

function addSequencerL2Batch(
    uint256 sequenceNumber,
    bytes calldata data,
    uint256 afterDelayedMessagesRead,
    IGasRefunder gasRefunder,
    uint256 prevMessageCount,
    uint256 newMessageCount
) external override refundsGas(gasRefunder) {
    if (
        !isBatchPoster[msg.sender]
        && msg.sender != address(rollup)
    ) revert NotBatchPoster();

    [...]
    addSequencerL2BatchImpl(
        dataHash_,
        afterDelayedMessagesRead_,
        0,
        prevMessageCount_,
        newMessageCount_
    );
    [...]
}

然后在addSequencerL2BatchImpl里面调用桥接(bridge),将信息排队到收件箱。

bridge.enqueueSequencerMessage(
    dataHash,
    afterDelayedMessagesRead,
    prevMessageCount,
    newMessageCount
);

然后在桥接中调用 enqueueSequencerMessage,它只是向收件箱数组添加一个新的哈希值。

bytes32[] public sequencerInboxAccs;

function enqueueSequencerMessage(
    bytes32 dataHash,
    uint256 afterDelayedMessagesRead,
    uint256 prevMessageCount,
    uint256 newMessageCount
)
    external
    onlySequencerInbox
    returns (
        uint256 seqMessageIndex,
        bytes32 beforeAcc,
        bytes32 delayedAcc,
        bytes32 acc
    )
{
    [...]
    acc = keccak256(abi.encodePacked(beforeAcc, dataHash, delayedAcc));
    sequencerInboxAccs.push(acc);
}

2. 通过定序器,从L1的消息中加入

消息也可以由任何人直接使用L1中的调用来添加。例如,当从L1到L2进行存款时,就很有用。

最终这将在在deliverToBridge内调用桥接的enqueueDelayedMessage

bytes32[] public delayedInboxAccs;

function enqueueDelayedMessage(
    uint8 kind,
    address sender,
    bytes32 messageDataHash
) external payable returns (uint256) {
    [...]
    delayedInboxAccs.push(
        Messages.accumulateInboxMessage(
            prevAcc,
            messageHash
        )
    );
    [...]
}
function deliverToBridge(
    uint8 kind,
    address sender,
    bytes32 messageDataHash
) internal returns (uint256) {
   return
       bridge.enqueueDelayedMessage{value: msg.value}(
           kind,
           AddressAliasHelper.applyL1ToL2Alias(sender),
           messageDataHash
       );
}

3. 来自L1的消息被强制包含在L2上

第二种情况有一个问题。定序器可以从延迟的收件箱中获取消息并进行处理,但他也可以简单地忽略它们。这情况下,消息可能永远不会在L2中。而由于定序器仍然是中心化的,所以有第三个备份选项,叫做forceInclusion(强制包含)

任何人都可以调用这个函数,如果在最小时间范围内定序器停止发布消息,它允许其他人继续发布消息。

那么,为什么会有延迟,为什么不允许用户总是立即强制包含交易呢?如果定序器有优先权,他可以给用户提供关于交易的软确认,带来更好的用户体验。如果有持续的强制包含,定序器就不能预先向用户确认将发生什么。为什么呢?好吧,一个强行加入的交易可能会使定序器计划发布的交易无效。

function forceInclusion(
    uint256 _totalDelayedMessagesRead,
    uint8 kind,
    uint64[2] calldata l1BlockAndTime,
    uint256 baseFeeL1,
    address sender,
    bytes32 messageDataHash
) external {
    [...]

    if (l1BlockAndTime[0] + maxTimeVariation.delayBlocks >= block.number)
        revert ForceIncludeBlockTooSoon();
    if (l1BlockAndTime[1] + maxTimeVariation.delaySeconds >= block.timestamp)
        revert ForceIncludeTimeTooSoon();

    [...]

    addSequencerL2BatchImpl(
            dataHash,
            __totalDelayedMessagesRead,
            0,
            prevSeqMsgCount,
            newSeqMsgCount
        );
    [...]
}

欺诈证明是如何工作的?

让我们详细探讨一下Arbitrum Nitro的欺诈证明是如何工作的。

1. WAVM

Arbitrum Nitro的新功能是WAVM。他们基本上重新使用Geth 以太坊节点代码,并将其编译为Wasm(或者说是Wasm的一个略微修改的版本)。Wasm是Web Assembly的缩写,是一个允许运行代码的环境,与平台无关。所以类似于EVM,但没有Gas。它也是一个网络范围的标准,所以它有更多其他语言的支持和更好的性能。因此,将用Go编写的Geth代码编译到Wasm中确实是可能的。

这个Wasm的执行对我们有什么帮助?

数学证明Meme

我们可以用它运行证明! 因为它是一个受控的执行环境,我们可以在Solidity智能合约内复制它的执行。这就是运行欺诈证明的要求。

Wasm与原生编译的代码相比,执行速度还是比较慢。但这里是Nitro的魅力所在。同样的Geth代码在证明时将被编译成Wasm,但在执行时则被编译成本地代码。这样,我们就可以得到两全其美的结果:以本地性能运行链,但仍然能够执行证明。

2. 欺诈证明

欺诈性备忘录

现在让我们来看看这些欺诈证明是如何详细工作的。我们需要什么?

  1. 我们需要一种机制来获得执行的前状态和后状态。
  2. 我们需要能够在Solidity合约中运行 WAVM执行( WAVM execution)。
  3. 我们需要一个交互式的机制来决定哪一个执行步骤需要证明。

最后一步是可选的,但如果我们只需要对单个的执行进行证明,则是一种性能改进。然而,它确实需要挑战者和被挑战节点之间的一些额外的交互步骤。我们不会去讨论这个细节,但你可以在这里阅读更多的内容。当然也可以直接在源代码中阅读。

但我们现在将详细介绍其他两个部分。

3. 获取一个执行的前状态和后状态

在交互式挑战过程中,最终挑战者会指向某单个执行的分歧。这个单一的执行有一个已知的执行前和执行后状态的Merkle根哈希值。执行后的根哈希值是被挑战的,所以最后我们会把它和我们自己执行得到的结果进行比较。执行前的哈希值没有受到挑战,因此是可信的。

它将被用于初始化WAVM 机器(Machine)

struct Machine {
    MachineStatus status;
    ValueStack valueStack;
    ValueStack internalStack;
    StackFrameWindow frameStack;
    bytes32 globalStateHash;
    uint32 moduleIdx;
    uint32 functionIdx;
    uint32 functionPc;
    bytes32 modulesRoot;
}

挑战者将用所有的数据初始化这个机器Machine。

在合约中,我们只需要再次检查这些数据是否代表了存储的Merkle根哈希值。

require(mach.hash() == beforeHash, "MACHINE_BEFORE_HASH")

现在我们可以信任Module(模块)根,用它来验证模块的数据。

一个模块被定义为:

struct Module {
    bytes32 globalsMerkleRoot;
    ModuleMemory moduleMemory;
    bytes32 tablesMerkleRoot;
    bytes32 functionsMerkleRoot;
    uint32 internalsOffset;
}

这里面持有的数据是WAVM机器数据的进一步Merkle根哈希值的形式。而挑战者也初始化了这些数据。

合约又只是验证它是否匹配之前的模块modulesRoot:

(mod, offset) = Deserialize.module(proof, offset);
(modProof, offset) = Deserialize.merkleProof(proof, offset);
require(
    modProof.computeRootFromModule(mach.moduleIdx, mod) == mach.modulesRoot,
    "MODULES_ROOT"
);

最后我们再对Instruction(指令)数据做同样的处理。

struct Instruction {
    uint16 opcode;
    uint256 argumentData;
}

并且将通过函数MerkleRoot进行验证:

MerkleProof memory instProof;
MerkleProof memory funcProof;
(inst, offset) = Deserialize.instruction(proof, offset);
(instProof, offset) = Deserialize.merkleProof(proof, offset);
(funcProof, offset) = Deserialize.merkleProof(proof, offset);
bytes32 codeHash = instProof.computeRootFromInstruction(mach.functionPc, inst);
bytes32 recomputedRoot = funcProof.computeRootFromFunction(
    mach.functionIdx,
    codeHash
);
require(recomputedRoot == mod.functionsMerkleRoot, "BAD_FUNCTIONS_ROOT");

所以现在我们有一个初始化的WAVM机器,剩下的就是执行某个有分歧的的操作。现在这取决于我们需要运行的确切指令。

以一个简单加法为例,这是很简单的。

uint32 b = mach.valueStack.pop().assumeI32();
uint32 a = mach.valueStack.pop().assumeI32();
[...]
return (a + b, false);

堆栈

基本上就是这样了。从机器堆栈中取前两个值,然后把它们加在一起。

让我们来看看另一条指令,一个本地获取指令:

function executeLocalGet(
    Machine memory mach,
    Module memory,
    Instruction calldata inst,
    bytes calldata proof
) internal pure {
    StackFrame memory frame = mach.frameStack.peek();
    Value memory val = merkleProveGetValue(frame.localsMerkleRoot, inst.argumentData, proof);
    mach.valueStack.push(val);
}

这个StackFrame来自WAVM的初始化,在这里我们可以找到localsMerkleRoot。

struct StackFrame {
    Value returnPc;
    bytes32 localsMerkleRoot;
    uint32 callerModule;
    uint32 callerModuleInternals;
}

并通过Merkle证明,我们可以检索到该值并将其推送到堆栈。

最后,我们检查这个计算步骤产生的最终哈希值是否等于存储的哈希值。

require(
    afterHash != selection.oldSegments[selection.challengePosition + 1],
    "SAME_OSP_END"
);

只有当它不匹配时,证明才有效,我们继续。现在挑战者赢了,一个新的后状态将被接受。

如何在Arbitrum上开发

Arbitrum完全支持Solidity,所以你可以照搬你的合约,只需注意一些问题:

  • blockhash(x)返回一个加密不安全的伪随机哈希值.
  • block.coinbase返回0
  • block.difficulty返回常数2500000000000000
  • block.number/block.timestamp返回L1区块的 估计值
  • msg.sender的工作方式与以太坊上正常的L2到L2交易相同;对于L1到L2的 “retryable ticket(可重试票据) ”交易,它将返回触发消息的L1合约的L2地址别名。更多内容见可重试票据地址别名

如何使用 Arbitrum 网络

这就是两个重要的Aribtrum网络。你可以使用MetaMask等支持的钱包的wallet_addEthereumChain功能指定添加网络,要不然用户需要手动添加网络。

const params = [{
  "chainId": "42161", // testnet: "421611"
  "chainName": "Arbitrum",
  "rpcUrls": [
    "https://arb1.arbitrum.io/rpc"
    // rinkeby: "https://rinkeby.arbitrum.io/rpc"
    // goerli: "https://goerli-rollup.arbitrum.io/rpc"
  ],
  "nativeCurrency": {
    "name": "Ether",
    "symbol": "ETH",
    "decimals": 18
  },
  "blockExplorerUrls": [
    "https://explorer.arbitrum.io"
    // rinkeby: "https://rinkeby-explorer.arbitrum.io"
    // goerli: "https://goerli-rollup-explorer.arbitrum.io"
  ]
}]

try {
    await ethereum.request({
        method: 'wallet_addEthereumChain',
        params,
    })
} catch (error) {
    // something failed, e.g., user denied request
}

要在Arbitrum获得资金,可使用 https://bridge.arbitrum.io/ 提供的桥。

如何部署到Arbitrum网络中

现在你可以将Arbitrum主网添加到Truffle或Hardhat中,如下所示:

{
    arbitrum_mainnet: {
        provider: function () {
          return new HDWalletProvider(
            mnemonic,
            "https://arbitrum-mainnet.infura.io/v3/"
                + infuraKey,
            0,
            1
          );
        },
    },
    arbitrum_rinkeby: {
        provider: function () {
          return new HDWalletProvider(
            mnemonic,
            "https://rinkeby.arbitrum.io/rpc",
            0,
            1
          );
        },
    },
    arbitrum_goerli: {
        provider: function () {
          return new HDWalletProvider(
            mnemonic,
            "https://goerli-rollup.arbitrum.io/rpc",
            0,
            1
          );
        }
    }
}

推荐的一个好的做法是用Hardhat编写测试,用常规的本地配置,这样你可以快速运行测试,并有console.log/stacktraces 功能可用。

如果需要,可以在Infura设置中激活Arbitrum。

点赞 0
收藏 3
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO