quentangle

Posted on May 10, 2022Read on Mirror.xyz

Optimism智能合约详解

代码层面解释卷叠(Rollup)是如何工作的

翻译:团长(https://twitter.com/quentangle_

Optimism是一个建立在以太坊之上的optimistic rollup。什么是optimistic rollup?它在代码层面上是如何工作的?这篇文章做出解释。

我们还将介绍为什么rollup需要链间通信以及这种通信是如何实现的。我们将看到实现rollup最重要功能的实际代码片断。

以下是这篇文章的大纲

  • 什么是乐观的rollup?
  • Optimism合约的简要说明
  • L1-L2桥的代码
  • rollup交易的代码
  • 争端的代码

什么是optimistic rollup?

首先,什么是rollup?它是使Ethereum更有效率的方法之一,通常被称为L2解决方案。有3种L2解决方案类型:状态通道(state channels)、plasma和rollup。我很快会有一篇关于 “L2解决方案的分类”的文章,将详细介绍这个问题。下面是关于什么是rollup,特别是optimistic rollup的一个简短总结。

以太坊上有一个智能合约(称为RollupL1),ETH可以在上面存款/提款。当你的钱在RollupL1中时,你可以认为它是在L2中。L2的钱比L1的钱转移得更快,因为L2的交易(txns)更有效率。这一点是如何实现的呢?

有一个存在于以太坊之外的程序(称之为RollupL2)。它可以更快地处理txns,因为它不需要通过以太坊缓慢而昂贵的共识机制。它可以处理一堆txns,将它们合并(卷起来)成一个批次,并将该批次提交给RollupL1

RollupL2可以是另一个存在于更快的区块链上的智能合约,也可以是一个传统的web2服务器。每种方法都有在延迟和去中心化方面的优缺点。

通过在链外处理txns,可以从2个方面节省:

  1. 数据压缩:一个批次所占用的空间比单独的txns堆叠在一起要少。见这里了解原因。
  2. 只需要通过Ethereum缓慢而昂贵的共识一次。

还有一个节约轴:不需要在以太坊的每个txn之后计算新的状态。直接在以太坊上提交一个txn,以太坊需要计算账户的新状态,这是很昂贵的。通过将这项工作卸载(offloading)到L2,可以避免在Ethereum上进行这种昂贵的计算。

但是,RollupL1是否应该完全相信RollupL2提交给它的新状态?它应该验证吗?如果它验证,它浪费了同样的计算,所以失去了rollup的意义。

Optimistic rollups通过无条件信任blind trust来解决这个问题:它们只是信任新提交的状态而不做任何验证(它们非常乐观optimistic✨)。但是,他们将新提交的批次锁定一个星期(称为 “挑战窗口期challenge window”)。任何人都可以在这个挑战窗口期间提交数学证明,如果在状态更新中发现了欺诈就可以获得奖励。如果该批次交易在这一周内没有争议,就被认为是最终结果。

奖励的资金来自提交批量交易的人的存款。如果你也想提交一个批量交易,需要先进行存款。

这就是optimistic rollups的简要工作流程。ZK-rollup的工作方式不同(阅读我的L2文章)。

Optimism合约的简要说明

Optimism合约在高层次上需要3个功能:

  1. 在L1和L2之间移动资金的双向桥梁
  2. 处理交易并将其滚动到一个批次
  3. 处理纠纷/防止无效的状态更新

以下是实现上述功能的Optimism智能合约的图示:

from Optimism docs

现在让我们来看看最重要部分的实际代码。

L1-L2桥的代码

桥的工作原理是锁定L1的资金,并在L2上铸造等值资金。提取资金时,桥要烧掉L2的资金并释放锁定的L1资金。

下面是存入资金的功能:

/**
 * @title L1Stand
ardBridge
 * @dev L1 ETH和ERC20桥是一个合约,它存储了存入的L1资金和L2上使用的标准代币。
 *      它同步一个相应的L2桥,通知它存款的情况 并监听其新完成的提款。
 *
 */
contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {
    function depositETHTo(
        address _to,
        uint32 _l2Gas,
        bytes calldata _data
    ) external payable {
        _initiateETHDeposit(msg.sender, _to, _l2Gas, _data);
    }

    /**
     * @dev 通过存储ETH和通知L2 ETH网关的存款,执行存款逻辑。
     * @param _from 从L1的账户中提取存款。
     * @param _to 将存款交给L2的账户。
     * @param _l2Gas 在L2上完成存款所需的gas limit。
     * @param _data  转发到L2的可选数据。这个数据的提供仅仅是为了方便外部合约。
     *        除了强制执行最大长度外,这些合约对其内容没有提供任何保证。
     */
    function _initiateETHDeposit(
        address _from,
        address _to,
        uint32 _l2Gas,
        bytes memory _data
    ) internal {
        // Construct calldata for finalizeDeposit call
        bytes memory message = abi.encodeWithSelector(
            IL2ERC20Bridge.finalizeDeposit.selector,
            address(0),
            Lib_PredeployAddresses.OVM_ETH,
            _from,
            _to,
            msg.value,
            _data
        );
        // Send calldata into L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);
        // slither-disable-next-line reentrancy-events
        emit ETHDepositInitiated(_from, _to, msg.value, _data);
    }
    
    // ... other functions (OMITTED)
}

该函数是存在于以太坊上的L1StandardBridge合约的一部分。它非常简单:接受ETH(通过payable关键字自动完成),将函数的所有参数编码为一个消息,并将消息发送到跨域信使。

跨域信使在L1和L2之间广播消息。我们将在稍后介绍它。

在L2上有一个相应的函数用于监听这些消息。L2StandardBridge合约就是这样做的。这个合约在一个单独的L2区块链上(比Ethereum快)。

/**
 * @title L2StandardBridge
 * @dev L2标准桥是一个合约,它与L1标准桥一起工作,使ETH和ERC20在L1和L2之间转换。
 *      当监听到有新的代币存入L1标准桥时,该合约就会去mint新代币。
 *      该合约还充当打算提款的代币的销毁器burner,通知L1桥释放L1资金。
 */
contract L2StandardBridge is IL2ERC20Bridge, CrossDomainEnabled {
    function finalizeDeposit(
        address _l1Token,
        address _l2Token,
        address _from,
        address _to,
        uint256 _amount,
        bytes calldata _data
    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {
        // 检查目标代币是否符合要求,并验证L1上存放的代币与L2上存放的代币相匹配。
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()
        ) {
            // 存款最终完成后,我们会将相同数量的代币存入L2的账户。
            // slither-disable-next-line reentrancy-events
            IL2StandardERC20(_l2Token).mint(_to, _amount);
            // slither-disable-next-line reentrancy-events
            emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);
        } else {
            // ... handle error (OMITTED)
        }
    }
    
    // ... other functions (OMITTED)
}

该函数运行一些检查和铸造新的代币。值得提到的是,这个桥可以移动任意的ERC-20代币,而不仅仅是ETH(ETH只是被包裹在一个ERC-20接口中)。

有相应的功能用于将资金从L2转移到L1。也是用一个X域的信使来完成。为了简洁起见,我将跳过它们。

跨域信息传递

L1和L2之间的通信是通过一个x域信使合约进行的(每个链上都有一个副本)。在内部这个合约存储消息,并依靠 “中继器relayers”来通知另一个链(L1或L2)有新消息。

没有原生的L1 ↔ L2通信。每一方都有onNewMessage这样的函数,中继器应该使用传统的web2 HTTP来调用它们。

例如,这里是L1→L2事务如何在L1上存储/排队:

/**
 * @title CanonicalTransactionChain
 * @dev Canonical Transaction Chain(CTC)合约是一个必须应用于卷叠状态交易的附加日志。
 *      它通过将交易写入链存储容器的'CTC:batches'实例来定义卷叠交易的顺序。
 *      CTC还允许任何账户'排队enqueue'一个二级交易,这将需要Sequencer最终会将其追加到卷叠状态中。
 */
contract CanonicalTransactionChain is ICanonicalTransactionChain, Lib_AddressResolver {
    uint40 private _nextQueueIndex; // index of the first queue element not yet included
    Lib_OVMCodec.QueueElement[] queueElements;
    
    /**
     * 添加交易到队列中
     * @param _target 交易发送到的目标合约
     * @param _gasLimit 排队交易的Gas limit
     * @param _data 交易数据
     */
    function enqueue(
        address _target,
        uint256 _gasLimit,
        bytes memory _data
    ) external {
        // ...a bunch of unimportant stuff omitted

        bytes32 transactionHash = keccak256(abi.encode(sender, _target, _gasLimit, _data));

        queueElements.push(
            Lib_OVMCodec.QueueElement({
                transactionHash: transactionHash,
                timestamp: uint40(block.timestamp),
                blockNumber: uint40(block.number)
            })
        );
        uint256 queueIndex = queueElements.length - 1;
        emit TransactionEnqueued(sender, _target, _gasLimit, _data, queueIndex, block.timestamp);
    }
}

中继器Relayers会通知L2,队列中有一个新的消息。

Rolling up的代码

Optimism上有一个排序器sequencer,其工作是接受L2交易,检查其有效性,并将状态更新作为一个待定区块应用到其本地状态。这些待处理区块会定期大批量地提交给以太坊(L1)进行最终处理。

在以太坊上接受这些批次的功能是appendSequencerBatch,这是L1上CanonicalTransactionChain合约的一部分。在内部,appendSequencerBatch使用下面的函数来处理批次。

/**
 * @title CanonicalTransactionChain
 * @dev Canonical Transaction Chain(CTC)合约是一个必须应用于卷叠状态交易的附加日志。
 *      它通过将交易写入链存储容器的'CTC:batches'实例来定义卷叠交易的顺序。
 *      CTC还允许任何账户'排队enqueue'一个二级交易,这将需要Sequencer最终会将其追加到卷叠状态中。
 */
contract CanonicalTransactionChain is ICanonicalTransactionChain, Lib_AddressResolver {
    /**
     * 将一个批处理插入到批处理链中。
     * @param _transactionRoot 这个批次的交易树的根。
     * @param _batchSize 批次中元素的数量。
     * @param _numQueuedTransactions 该批次中的队列交易数量。
     * @param _timestamp 最新的批次时间戳。
     * @param _blockNumber 最新的批处理块编号。
     */
    function _appendBatch(
        bytes32 _transactionRoot,
        uint256 _batchSize,
        uint256 _numQueuedTransactions,
        uint40 _timestamp,
        uint40 _blockNumber
    ) internal {
        IChainStorageContainer batchesRef = batches();
        (uint40 totalElements, uint40 nextQueueIndex, , ) = _getBatchExtraData();

        Lib_OVMCodec.ChainBatchHeader memory header = Lib_OVMCodec.ChainBatchHeader({
            batchIndex: batchesRef.length(),
            batchRoot: _transactionRoot,
            batchSize: _batchSize,
            prevTotalElements: totalElements,
            extraData: hex""
        });

        emit TransactionBatchAppended(
            header.batchIndex,
            header.batchRoot,
            header.batchSize,
            header.prevTotalElements,
            header.extraData
        );

        bytes32 batchHeaderHash = Lib_OVMCodec.hashBatchHeader(header);
        bytes27 latestBatchContext = _makeBatchExtraData(
            totalElements + uint40(header.batchSize),
            nextQueueIndex + uint40(_numQueuedTransactions),
            _timestamp,
            _blockNumber
        );

        // slither-disable-next-line reentrancy-no-eth, reentrancy-events
        batchesRef.push(batchHeaderHash, latestBatchContext);
    }
}
  • batchesRef是一个用于数据存储的辅助合约。是存储批量交易的地方。
  • 该函数首先计算批次头,然后计算其哈希值。
  • 然后计算出批处理的上下文。批次头和上下文只是关于批次的额外信息。
  • 然后,它将哈希值和上下文存储在存储器(batchesRef)中。

在后面哈希值和上下文将被用来验证争端。

现在,将交易打包成一个批次并提交的排序器,排序器角色是中心化的— 由Optimism组织控制。但他们有计划在未来将这个角色去中心化。你也可以不通过排序器直接向CanonicalTransactionChain提交你自己的批次,但这将是更昂贵的,因为提交批次的固定成本完全由你支付,而不是在许多不同的交易中摊销。

处理纠纷的代码

简单说,争端的工作方式是提交一个状态更新无效的证明,并根据存储的状态更新(存储的批次元数据:哈希和上下文)验证这个证明。

负责处理纠纷的合约是OVMFraudVerifier。该合约是OVM — Optimism虚拟机(类似于EVM — Ethereum虚拟机)的一部分。以下是处理纠纷的主要函数:

  • finalizeFraudVerification检查_postStateRoot(由验证者提交)是否与排序器提交的root不一致。
  • 如果不一致,那么我们就在_cancelStateTransition中删除该批次,并削减排序者的存款(为了成为一个排序器,你需要锁定一个存款。当你提交一个欺诈性的批次时,你的押金就会被削减,这些钱就会给验证者,作为保持整个机制运行的激励)。

原文地址:https://medium.com/better-programming/optimism-smart-contract-breakdown-18f87a7b1823

Optimism