海明威说:这个世界很美好,我们应该为之奋斗。我同意后半句 <

Optimism 源码浅析

之前学习 Optimism 的笔记

代码版本:Optimism: 36efcdec48de221d402edf54b653e3403894607d

注解版本:Optimism-annotated

架构概览

Sequencer Syncing Deposits & Accepting a Transaction

图片来自 Optimistic Ethereum Introduction

角色

Optimism 中所有组件目前都由官方运行

L1

CanonicalTransactionChain

功能:

  • 记录批量区块状态
  • 协助中继 L1->L2 交易

事件:

  • TransactionEnqueued
  • SequencerBatchAppended
StateCommitmentChain

功能:

  • 记录批量区块状态
  • 协助 L2->L1 交易的存在性证明

事件:

  • StateBatchAppended

L2

Sequencer

执行两种交易:L2->L2,L1->L2

定序器:敲定两种交易的顺序

batch-submitter
  • 打包批量交易 txBatch 提交到 L1

    • TransactionBatchSubmitter
    • 出于 data availability,把交易按敲定的顺序,序列化后作为 calldata 提交到 L1
  • 打包批量状态 stateBatch 提交到 L1

    • StateBatchSubmitter

参考 NOTICE: TypeScript batch submitter to be deprecated in favor of Go batch submitter #2050,目前 batch-submitter 有 TypeScript 和 Go 两种实现,其中 TypeScript 版本可能会被移除

--

Optimism 目前对交易数据的处理只是简单的序列化,没有压缩

根据文档 Optimism PBC: The Road to Sub-dollar Transactions, Part 2: Compression Edition,Optimism 正在测试使用 zlib 压缩批量交易;另外,后续会采用 EIP-4844 交易格式来降低成本

data-transport-layer

索引 L1 事件,并存储到数据库中

  • TransactionEnqueued
  • SequencerBatchAppended
  • StateBatchAppended

对于 L1->L2 交易,还将建立引用 index <-> queueIndex

Sequencer 向它查询待执行的 L1->L2 交易

relayer

实时监控已过挑战期的 L2->L1 交易,在 L1 上调用合约完成中继

交互

1.用户在 L1 调用 CanonicalTransactionChain.enqueue(),触发事件 TransactionEnqueued

2.data-transport-layer 监听 TransactionEnqueued,解析事件并查出 calldata,写入数据库

3.Sequencerdata-transport-layer 同步 TransactionEnqueued 事件,转为交易并执行

4.或者,用户可通过 L2 RPC 节点,直接向 Sequencer 发送交易

56.batch-submitter 监听 L2 区块,打包 txBatch 提交到 L1 合约 CanonicalTransactionChain.appendSequencerBatch(),触发 TransactionBatchSubmitter 事件

参考 tx-batch-submitter.ts: _getSequencerBatchParams()

789.batch-submitter 监听 L2 区块,打包 stateBatch 提交到 L1 合约 StateCommitmentChain.appendStateBatch(),触发 StateBatchSubmitter 事件;对应的区块进入挑战期

参考 state-batch-submitter.ts: _generateStateCommitmentBatch()

其中,Optimism 目前正在重构第 8 步欺诈证明的实现,详见下文

欺诈证明

Optimistic Rollup 对 状态转移 的保证是在一定时间窗口内,允许挑战者提交欺诈证明,表示对某一状态存在质疑,并开始仲裁。仲裁有两种方式:

  • 非交互式欺诈证明
    • 在 L1 重新执行一个区块
    • Optimism 采用
  • 交互式欺诈证明
    • 在 L2 上由双方进行多轮交互后锁定某条存疑指令后在 L1 仲裁
    • Arbitrum 采用

Optimism 采用 非交互式欺诈证明,需要在 L1 重新执行一个区块,而 L1 执行成本高昂且存在 gasLimit 限制,因此要求直接指定区块内的哪一笔交易存在问题,以此在 L1 上只验证这一笔交易,而非从头执行这个区块。

在 Optimism 实现中,一个 L2 区块只包含一笔交易,使得区块的 stateRoot 实际就是这笔交易的 stateRoot;即每笔交易都会有一个对应的状态被提交到 L1,因此它可以被单独挑战

这个设计类似 Vitalik: Endgame 前期架构

Endgame

--

根据 Next gen fault proofs,Optimism 目前暂停了 非交互式欺诈证明 (用户必须信任 Optimism 运行的节点没有作恶),计划在今年转为 交互式欺诈证明

定序和时间

交易定序

两种交易的顺序,是由 Sequencer 按到达时间确定的

假设存在如下交易:

  • t0: L1->L2 交易 Tx0,假设在 t3 被 data-transport-layer 索引 (经过多个 L1 区块的确认)
  • t1: L2->L2 交易 Tx1
  • t2: L2->L2 交易 Tx2
  • t4: L2->L2 交易 Tx3

那么,这些交易在 L2 的执行时间为 [t1, t2, t3, t4],交易顺序为 [Tx1, Tx2, Tx0, Tx3];每笔交易为一个区块,按出块顺序记为 [B0, B1, B2, B3]

需要说明的是,区块头部中的区块时间,并非交易执行时间,而是更小些,比如 B2.header.timestamp < t3;详见后文

区块时间

相关文档

Sequencer 维护了 LatestL1Timestamp,每 timestampRefreshThreshold 更新一次 LatestL1Timestamp,且对两种交易都设置了 tx.L1Timestamp

其中,timestampRefreshThreshold 配置为 15 秒 (启动参数 ROLLUP_TIMESTAMP_REFRESH浏览器)

func (s *SyncService) applyTransactionToTip(tx *types.Transaction) error {
    // The property that L1 to L2 transactions have the same timestamp as the
    // L1 block that it was included in is removed for better UX.
    ts := s.GetLatestL1Timestamp()
    bn := s.GetLatestL1BlockNumber()

    shouldMalleateTimestamp := !s.verifier && tx.QueueOrigin() == types.QueueOriginL1ToL2
    if tx.L1Timestamp() == 0 || shouldMalleateTimestamp {
        // 两种交易都会进来

        // Get the latest known timestamp
        current := time.Unix(int64(ts), 0)
        // Get the current clocktime
        now := time.Now()

        // If enough time has passed, then assign the
        // transaction to have the timestamp now. Otherwise,
        // use the current timestamp
        if now.Sub(current) > s.timestampRefreshThreshold {
            current = now
        }

        log.Info("Updating latest timestamp", "timestamp", current, "unix", current.Unix())

        // 将两种交易的 L1Timestamp 改为 LatestL1Timestamp 或 当前时间
        tx.SetL1Timestamp(uint64(current.Unix()))
    }

    // 更新 LatestL1Timestamp
    // Store the latest timestamp value
    if tx.L1Timestamp() > ts {
        s.SetLatestL1Timestamp(tx.L1Timestamp())
    }
}

--

tx.L1Timestamp 用途如下:

1.在执行交易前,构造区块头,作为区块时间

// l2geth/miner/worker.go
func (w *worker) commitNewTx(tx *types.Transaction) error {
    // 使用 l1 的区块时间
    header := &types.Header{
        ParentHash: parent.Hash(),
        Number:     new(big.Int).Add(num, common.Big1),
        GasLimit:   w.config.GasFloor,
        Extra:      w.extra,
        Time:       tx.L1Timestamp(),
    }
}

2.在交易执行时,构造 EVM 上下文,作为 opTimestamp 取值

// l2geth/core/evm.go
func NewEVMContext(msg Message, header *types.Header, chain ChainContext, author *common.Address) vm.Context {
    // 参考 [Differences between Ethereum and Optimism](https://community.optimism.io/docs/developers/build/differences/#)
    if rcfg.UsingOVM {
        // When using the OVM, we must:
        // - Set the Time to be the msg.L1Timestamp
        return vm.Context{
            // 使得 opTimestamp 取值是 L1Timestamp
            Time:          new(big.Int).SetUint64(msg.L1Timestamp()),
        }
    }
}

共识算法

Why does ethereum creates a new block,without even a single transaction?

根据上文,多笔交易可能使用相同的区块时间,又因为每一笔交易为一个区块,所以存在某个区块,其区块时间可能等于父区块的区块时间;

这在 PoW 共识算法中是不允许的:

// l2geth/consensus/ethash/consensus.go

// verifyHeader checks whether a header conforms to the consensus rules of the
// stock Ethereum ethash engine.
// See YP section 4.3.4. "Block Header Validity"
func (ethash *Ethash) verifyHeader(chain consensus.ChainReader, header, parent *types.Header, uncle bool, seal bool) error {
    if header.Time <= parent.Time {
        return errOlderBlockTime
    }

    // ...
}

根据 make-genesis.ts,Optimism 共识采用 PoA,但 PoA 对区块时间也有要求,参考 Multiple blocks with same timestamp in clique network. #21184

因此,Optimism 修改了相关代码,在 Clique 共识中跳过了区块时间的检查,比如:

// l2geth/consensus/clique/clique.go

func (c *Clique) verifyHeader(chain consensus.ChainReader, header *types.Header, parents []*types.Header) error {
    if !rcfg.UsingOVM {
        // Don't waste time checking blocks from the future
        if header.Time > uint64(time.Now().Unix()) {
            return consensus.ErrFutureBlock
        }
    }

    // ...
}

func (c *Clique) verifyCascadingFields(chain consensus.ChainReader, header *types.Header, parents []*types.Header) error {
    if !rcfg.UsingOVM {
        if parent.Time+c.config.Period > header.Time {
            return ErrInvalidTimestamp
        }
    }

    // ...
}

区块回滚

根据文档,代码和 issues, Optimism 在开发时考虑了 L1 发生区块回滚的情况,但未完全处理;比如:

// l2geth/rollup/sync_service.go

// When reorg logic is enabled, this should also call `syncBatchesToTip`
func (s *SyncService) sequence() error {
    // ...
}

// This will trigger a reorg in the future
func (s *SyncService) applyHistoricalTransaction(tx *types.Transaction) error {
    // ...
}

理解:

L1 区块回滚分两种情况:1. 用户在 L1 发起的交易被回滚;2. L2 在 L1 发起的交易被回滚

对于前者,典型操作为 L1->L2 的消息传递,data-transport-layer 会等待 L1 上 35 个区块确认 才做索引,回滚概率小

对于后者,Sequencer 和 batch-submitter 目前都是中心化的,不会存在竞争。即使 L1 回滚,此前提交的交易和状态也会被后续区块重新打包,只是 区块高度 和 区块哈希 不同,而 Optimism 不依赖于这两者

消息传递

相关文档

L1->L2

流程概览

1.用户或合约在 L1 调用 CanonicalTransactionChain.enqueue(),触发事件 TransactionEnqueued

参考 CanonicalTransactionChain.enqueue()

2.data-transport-layer 监听 TransactionEnqueued,解析后写入数据库

参考 transaction-enqueued.ts: handleEventsTransactionEnqueued

3.Sequencerdata-transport-layer 查询 TransactionEnqueued 事件,转为交易并执行,挖出对应区块

参考 sync_service.go: SyncService.syncQueueTransactionRange()

调用层次较深:SequencerLoop() -> ... -> syncQueueToTip() -> ... -> syncQueueTransactionRange()

交易处理

L1 合约调用
// packages/contracts/contracts/L1/rollup/CanonicalTransactionChain.sol
contract CanonicalTransactionChain {
    uint40 private _nextQueueIndex; // index of the first queue element not yet included
    Lib_OVMCodec.QueueElement[] queueElements;

    function enqueue(address _target, uint256 _gasLimit, bytes memory _data) external {
        address sender;
        if (msg.sender == tx.origin) {
            sender = msg.sender;
        } else {
            sender = AddressAliasHelper.applyL1ToL2Alias(msg.sender);
        }

        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);
    }
}

用户调用 enqueue(),将导致交易被压入 queueElements 队列;其中,_nextQueueIndex 表示队列中前多少条交易已经由 Sequencer 敲定顺序

注意,如果 msg.sender 是个合约地址,会被 AddressAliasHelper 设置偏移:0x1111000000000000000000000000000000001111

L2 交易执行
// l2geth/rollup/client.go

// enqueueToTransaction turns an Enqueue into a types.Transaction
// so that it can be consumed by the SyncService
func enqueueToTransaction(enqueue *Enqueue) (*types.Transaction, error) {
    nonce := *enqueue.QueueIndex
    target := *enqueue.Target
    gasLimit := *enqueue.GasLimit
    origin := *enqueue.Origin

    blockNumber := new(big.Int).SetUint64(*enqueue.BlockNumber)
    timestamp := *enqueue.Timestamp

    data := *enqueue.Data

    // enqueue transactions have no value
    value := big.NewInt(0)
    tx := types.NewTransaction(nonce, target, value, gasLimit, big.NewInt(0), data)

    // The index does not get a check as it is allowed to be nil in the context
    // of an enqueue transaction that has yet to be included into the CTC
    txMeta := types.NewTransactionMeta(
        blockNumber,
        timestamp,
        &origin,
        types.QueueOriginL1ToL2,

        // 对于未定序的 enqueue 消息,enqueue.Index 为 nil,因此 txMeta.Index 也为 nil
        enqueue.Index,

        enqueue.QueueIndex,
        data,
    )

    tx.SetTransactionMeta(txMeta)

    return tx, nil
}

Sequencer 通过 enqueueToTransaction() 将 L1 上发起的一笔交易的事件数据 enqueue 转为 types.Transaction,各字段设置如下:

  • to, gasLimit, data 设置为 L1 调用的对应字段
    • 参考 function enqueue(address _target, uint256 _gasLimit, bytes memory _data)
  • value, gasPrice 设置为 0
  • nonce 设置为 queueIndex
  • v, r, s 未设置

注意:因为 value, gasPrice 为 0,所以中继到 L2 的交易不消耗 gas

另外,还设置了交易的 meta 字段,用途在下文描述

--

L2 Sequencer 还做了如下特殊处理:

由于 nonce 设置为 queueIndex,而 queueIndex 是全局变量,因此对同一 msg.sender 而言,nonce 可能不连续;因此在执行交易前的检查中,对来自 L1 的交易会跳过 nonce 检查

// l2geth/core/state_transition.go
func (st *StateTransition) preCheck() error {
    // Make sure this transaction's nonce is correct.

    // 如果需要检查 nonce
    if st.msg.CheckNonce() {
        if rcfg.UsingOVM {
            // 如果是 L1->L2 消息,跳过 nonce 检查,直接调用 buyGas()
            if st.msg.QueueOrigin() == types.QueueOriginL1ToL2 {
                return st.buyGas()
            }
        }

        // 正常的检查 nonce 逻辑
        nonce := st.state.GetNonce(st.msg.From())
        if nonce < st.msg.Nonce() {
            return ErrNonceTooHigh
        } else if nonce > st.msg.Nonce() {
            return ErrNonceTooLow
        }
    }
    return st.buyGas()
}

另外,由于无法通过 v, r, s 签名计算交易发起者,因此在通过 EVM 执行交易前,交易转为 Message 时,会从 meta 中取出 L1MessageSender 作为 msg.from

// l2geth/core/types/transaction.go

func (tx *Transaction) AsMessage(s Signer) (Message, error) {
    var err error
    if rcfg.UsingOVM {
        // transaction 转 Message 时,对于 L1->L2 的消息,取 meta.L1MessageSender 作为 Origin
        if tx.meta.QueueOrigin == QueueOriginL1ToL2 && tx.meta.L1MessageSender != nil {
            msg.from = *tx.meta.L1MessageSender
        } else {
            msg.from, err = Sender(s, tx)
        }
    } else {
        msg.from, err = Sender(s, tx)
    }

    return msg, err
}

--

经过上面的处理,L1->L2 消息被转换为了 L2 上的一笔交易,它将与正常交易一样被执行和打包

为什么需要 Address Alias

相关文档

对于 L1->L2 消息,如果 msg.sender 为合约,那么在 L2 执行时,Origin 会被加上偏移

这是出于安全考虑,举例如下:

首先,攻击者在 L2 构造一个开放源码的合约,如 Uniswap pair,并诱导用户对合约授权 (approve())

然后,攻击者在 L1 同一地址部署恶意合约,并通过该恶意合约向 L2 传递攻击消息,取出用户授权的 ERC20 token;参数如下:

  • msg.sender
    • Uniswap pair (L2) / 恶意合约 (L1)
  • _target
    • ERC20 地址
  • _data
    • transferFrom(userAddress, attackerAddress, amount)

解决:

经过讨论:What should the value of tx.origin and msg.sender be for L1 to L2 transactions? #1480,决定参考 Arbitrum 的方案,通过 Address Alias 对msg.sender 地址做了偏移;由于哈希的抗碰撞性,攻击者无法计算相关参数 (如 Create nonce 或 Create2 salt),因此无法将合约部署在偏移前的地址

如何实现 Censorship resistance

通过 L1->L2 消息传递,Optimism 实现了抗 Sequencer 审查某位用户

原因:Sequencer 可以忽略整个 L1->L2 消息队列,但无法跳过队列中某笔请求;因为对队列的消耗,必须遵守 FIFO 的顺序 (queueIndex 要求递增)

比较 Arbitrum

相关文档

根据上文,Optimism Sequencer 虽然无法审查某位用户,但可以审查所有 L1->L2 交易,拒绝执行

与之相对的,Arbitrum 实现了 force inclusion 的功能,抗审查能力更强;两者比较如下:

Arbitrum 和 Optimism 一样,都有两条队列:Sequencer 定序队列,L1->L2 交易队列

  • Sequencer 定序队列
    • Optimism: ChainStorageContainer-CTC-batches
    • Arbitrum: SequencerInbox (文档也称 fast Inbox)
  • L1->L2 交易队列
    • Optimism: CanonicalTransactionChain.queueElements
    • Arbitrum: Inbox (文档也称 delayed Inbox)

正常而言,不管是 Optimism 还是 Arbitrum,Sequencer 都会处理 L2 发起的交易,以及消费 L1->L2 交易队列,对这两种交易定序,定序结果存储在 Sequencer 定序队列中;

区别如下:

在 Optimism 中,只存在唯一一个定序者 Sequencer,两种交易都由它确定顺序;这带来了 Sequencer 拒不处理 L1->L2 交易队列的风险

相反,在 Arbitrum 中,存在两个定序者:L2 Sequencer 和 L1 合约;如果 L1->L2 交易队列中的一笔交易L1->L2 交易经过一段时间 (目前配置为 24 小时) 未被定序,用户可以在 L1 调用合约函数 SequencerInbox.forceInclusion(),强制将它包含进 定序队列

可以预见:Arbitrum 比起 Optimism,其 Sequencer 的实现会更复杂:L1 合约定序,可能会导致后续 L2 Sequencer 回滚

L2->L1

理解如下:

根据 欺诈证明,Optimism 每笔交易都有一个 stateRoot 需要上传到 L1,如果在合约存储,需要消耗大量的 gas

为了节省成本,L1 合约将一批 stateRoot 组织成 Merkle Tree,只存储根哈希;以此支持某个 stateRoot 的存在性证明

流程概览

  1. 用户或合约在 L2 调用入口函数 L2CrossDomainMessenger.sendMessage(),它进一步调用
    OVM_L2ToL1MessagePasser.passMessageToL1(),它计算消息哈希,更新合约状态

  2. batch-submitter 向 L1 合约提交 txBatchstateBatch

  3. L1 合约计算 stateBatch 组织而成的 Merkle Tree,存储根哈希

  4. relayer 监控在 L1 上经过挑战期的 L2->L1 消息,为其生成证明后,在 L1 调用合约 L1CrossDomainMessenger.relayMessage();L1 合约验证证明后,调用目标合约

发起消息

// packages/contracts/contracts/L2/messaging/L2CrossDomainMessenger.sol
contract L2CrossDomainMessenger is IL2CrossDomainMessenger {
    function sendMessage(
        address _target,
        bytes memory _message,
        uint32 _gasLimit
    ) public {
        bytes memory xDomainCalldata = Lib_CrossDomainUtils.encodeXDomainCalldata(
            _target,
            msg.sender,
            _message,
            messageNonce
        );

        iOVM_L2ToL1MessagePasser(Lib_PredeployAddresses.L2_TO_L1_MESSAGE_PASSER).passMessageToL1(
            xDomainCalldata
        );
    }
}

// packages/contracts/contracts/L2/predeploys/OVM_L2ToL1MessagePasser.sol
contract OVM_L2ToL1MessagePasser is iOVM_L2ToL1MessagePasser {
    function passMessageToL1(bytes memory _message) public {
        // Note: although this function is public, only messages sent from the
        // L2CrossDomainMessenger will be relayed by the L1CrossDomainMessenger.
        // This is enforced by a check in L1CrossDomainMessenger._verifyStorageProof().
        sentMessages[keccak256(abi.encodePacked(_message, msg.sender))] = true;
    }
}

Sequencer 在执行 L2->L1 交易时,将修改 OVM_L2ToL1MessagePasser 状态树 (sentMessages[slot]),因而进导致 世界树 的变化,反应在 区块头部 的 stateRoot 字段。batch-submitter 将提交 stateRoot 到 L1 并等待挑战

其中,slot 的计算方式为 keccak256(_message + sender)

提交 stateBatch

// packages/contracts/contracts/L1/rollup/StateCommitmentChain.sol

contract StateCommitmentChain is IStateCommitmentChain, Lib_AddressResolver {
    function _appendBatch(bytes32[] memory _batch, bytes memory _extraData) internal {
        address sequencer = resolve("OVM_Proposer");
        (uint40 totalElements, uint40 lastSequencerTimestamp) = _getBatchExtraData();

        // 2.组织 batchHeader
        Lib_OVMCodec.ChainBatchHeader memory batchHeader = Lib_OVMCodec.ChainBatchHeader({
            batchIndex: getTotalBatches(),
            // 1. 将多笔 stateRoot 组织成一棵 Merkle Tree,得到 batchRoot;
            batchRoot: Lib_MerkleTree.getMerkleRoot(_batch),
            batchSize: _batch.length,
            prevTotalElements: totalElements,
            extraData: _extraData
        });

        // 3. 存储 batchHeader 到数组,可以方便的通过 batchIndex 索引
        batches().push(
            Lib_OVMCodec.hashBatchHeader(batchHeader),
            _makeBatchExtraData(
                uint40(batchHeader.prevTotalElements + batchHeader.batchSize),
                lastSequencerTimestamp
            )
        );
    }
}

batch-submitter 监听 L2 区块,一次批量提交多笔区块的 stateRoot 到 L1 合约

L1 合约处理如下:

  1. 将多笔 stateRoot 组织成一棵 Merkle Tree,得到根哈希 batchRoot
  2. 组织 meta 信息,得到 batchHeader
  3. 存储 batchHeader 到数组 (可以通过 batchIndex 进行索引)

证明生成

// packages/sdk/src/cross-chain-messenger.ts
export class CrossChainMessenger implements ICrossChainMessenger {
  public async getMessageProof(
    message: MessageLike
  ): Promise<CrossChainMessageProof> {
    const resolved = await this.toCrossChainMessage(message)
    if (resolved.direction === MessageDirection.L1_TO_L2) {
      throw new Error(`can only generate proofs for L2 to L1 messages`)
    }

    // 1. 通过 txHash 向 Sequencer 请求 receipt,得到这笔交易所在区块的 stateRoot
    const stateRoot = await this.getMessageStateRoot(resolved)
    if (stateRoot === null) {
      throw new Error(`state root for message not yet published`)
    }

    // 2. 计算 slot
    const messageSlot = ethers.utils.keccak256(
      ethers.utils.keccak256(
        encodeCrossChainMessage(resolved) +
          remove0x(this.contracts.l2.L2CrossDomainMessenger.address)
      ) + '00'.repeat(32)
    )

    // 3. 通过 eth_getProof 请求 Sequencer 生成 slot 的证明
    const stateTrieProof = await makeStateTrieProof(
      this.l2Provider as any,
      resolved.blockNumber,
      this.contracts.l2.OVM_L2ToL1MessagePasser.address,
      messageSlot
    )

    return {
      stateRoot: stateRoot.stateRoot,
      stateRootBatchHeader: stateRoot.batch.header,
      stateRootProof: {
        index: stateRoot.stateRootIndexInBatch,
        // 4. 生成 stateRoot 在 stateBatch 的存在性证明
        siblings: makeMerkleTreeProof(
          stateRoot.batch.stateRoots,
          stateRoot.stateRootIndexInBatch
        ),
      },
      stateTrieWitness: stateTrieProof.accountProof,
      storageTrieWitness: stateTrieProof.storageProof,
    }
  }
}

relayer 监控已经过了挑战期的 L2->L1 交易,为其生成证明并提交到 L1;过程如下:

  1. 通过 txHashSequencer 请求 receipt,得到这笔交易所在区块的 stateRoot
  2. 计算消息的 slot,规则与上面合约的计算方式一致:keccak256(_message + sender)
  3. 生成 消息在 合约地址 OVM_L2ToL1MessagePasser.address 的存在性的证明 (通过调用 Sequencer eth_getProof)
  4. 生成 stateRoot 在此前已提交的 stateBatch 的存在性证明 (通过将 stateBatch 组织成 Merkle Tree)

根据 Should we remove the Message Relayer? #1362relayer 用于测试环境,后续将废弃。开发者需要自己使用 Optimism SDK (开发中) 来桥接交易并承担 L1 gas 费用。

中继消息

// packages/contracts/contracts/L1/messaging/L1CrossDomainMessenger.sol

contract L1CrossDomainMessenger {
    function relayMessage(
        address _target,
        address _sender,
        bytes memory _message,
        uint256 _messageNonce,
        L2MessageInclusionProof memory _proof
    ) public nonReentrant whenNotPaused {
        bytes memory xDomainCalldata = Lib_CrossDomainUtils.encodeXDomainCalldata(
            _target,
            _sender,
            _message,
            _messageNonce
        );

        // 1. 验证 L2->L1 交易存在
        require(
            _verifyXDomainMessage(xDomainCalldata, _proof) == true,
            "Provided message could not be verified."
        );

        bytes32 xDomainCalldataHash = keccak256(xDomainCalldata);

        // 2. 检查交易未曾被中继
        require(
            successfulMessages[xDomainCalldataHash] == false,
            "Provided message has already been received."
        );

        // 3a. 调用前:修改 xDomainMsgSender 为 _sender
        xDomainMsgSender = _sender;

        // 3b. 消息调用
        (bool success, ) = _target.call(_message);

        // 3c. 调用后:重置 xDomainMsgSender
        xDomainMsgSender = Lib_DefaultValues.DEFAULT_XDOMAIN_SENDER;

        // Mark the message as received if the call was successful. Ensures that a message can be
        // relayed multiple times in the case that the call reverted.
        if (success == true) {
            // 4. 如果调用成功,设置标记
            successfulMessages[xDomainCalldataHash] = true;
            emit RelayedMessage(xDomainCalldataHash);
        } else {
            emit FailedRelayedMessage(xDomainCalldataHash);
        }
    }

    // 辅助函数:获取交易发起者的地址
    function xDomainMessageSender() public view returns (address) {
        require(
            xDomainMsgSender != Lib_DefaultValues.DEFAULT_XDOMAIN_SENDER,
            "xDomainMessageSender is not set"
        );
        return xDomainMsgSender;
    }
}

中继消息的流程如下:

  1. 验证 L2->L1 交易存在,分为两步:
    1. 证明 交易 存在 stateRoot
    2. 证明 stateRoot 存在 batchRoot (已过挑战期的 stateBatch,参考 提交 stateBatch) 中
  2. 检查消息未曾被成功中继
  3. 中继消息,被调用方可以通过 xDomainMessageSender() 获取交易发起者的地址
  4. 如果成功,设置标记

比较 Arbitrum

Optimism 对 L2->L1 消息传递 在 L1 的处理,与 Arbitrum 的 Retryable Ticket 设计 类似:

  1. 没有权限限制,任何人可以调用 relayMessage()
  2. 没有次数限制,可以多次重试直到成功
  3. 没有时间限制

理解如下:用户需要自己承担消息在 L1 上的执行费用,即要求用户自己调用 relayMessage()

StandardBridge

Using the Standard Token Bridge
Adding an ERC20 token to the standard bridge
Adding a custom bridge to Optimism

StandardBridge 基于上文描述的消息传递机制,实现 ETH,ERC20 在 L1 与 L2 之间的桥接;但不支持非同质化代币 如 NFT,开发者可以自行通过通用消息传递机制来实现

在实现上,主要为 L2StandardBridge.sol 和 L2StandardBridge.sol,代码比较简单

在应用上,Optimism 基于 StandardBridge 实现了官方桥 Optimism Gateway,支持的 token 列表配置在 optimism.tokenlist.json

--

为什么账户 balance 在 Optimism 中以 ERC20 方式存在?

根据 Historical Note: How OVM_ETH works (as of Optimism v0.5.0) #84,应该是 Optimism 早对 非交互式欺诈证明的实现,是通过在 L1 构造虚拟环境 OVM 来重新执行交易 (合约代码使用修改版本的 solidity 编译器生成,原生 OPCODE 被编译为通过 OVM 获取执行上下文),而虚拟环境将 balance 封装为 ERC20。参考 欺诈证明,Optimism 目前已暂停 非交互式欺诈证明,但为了保持兼容,在 L2 仍以 ERC20 方式存储账户的 balance

The original motivation for putting L2 ETH in an ERC20 token has to do with the 'containerized' approach behind how the old OVM worked.

EVM 特殊处理

EVM 上下文

// l2geth/core/evm.go

// NewEVMContext creates a new context for use in the EVM.
func NewEVMContext(msg Message, header *types.Header, chain ChainContext, author *common.Address) vm.Context {
    // If we don't have an explicit author (i.e. not mining), extract from the header
    var beneficiary common.Address
    if author == nil {
        beneficiary, _ = chain.Engine().Author(header) // Ignore error, we're past header validation
    } else {
        beneficiary = *author
    }

    if rcfg.UsingOVM {
        // When using the OVM, we must:
        // - Set the Time to be the msg.L1Timestamp
        return vm.Context{
            CanTransfer:   CanTransfer,
            Transfer:      Transfer,
            GetHash:       GetHashFn(header, chain),

            // 如果是 L2 -> L2 消息,则未修改
            // 如果是 L1 -> L2 消息,则可能是 msg.sender 的 alias (如果非 EOA)
            Origin:        msg.From(),

            // 收益地址固定为  0x4200....0011
            Coinbase:      dump.OvmFeeWallet, // Coinbase is the fee vault.

            BlockNumber:   new(big.Int).Set(header.Number),

            // 使得 opTimestamp 取值是 L1Timestamp
            Time:          new(big.Int).SetUint64(msg.L1Timestamp()),

            // 固定为 0
            Difficulty:    new(big.Int), // Difficulty always returns zero.

            GasLimit:      header.GasLimit,
            GasPrice:      new(big.Int).Set(msg.GasPrice()),

            // 设置 l1BlockNumber
            L1BlockNumber: msg.L1BlockNumber(),
        }
    }
}

参考 Differences between Ethereum and Optimism

go-ethereum 在将交易提交给 EVM 执行前,需要创建 vm.ContextSequencer 做了如下特殊处理:

  • Time 设置为 msg.L1Timestamp() (参考 区块时间)
  • Origin 设置为 msg.From() (参考 L1->L2)
  • Coinbase 设置为固定地址 OvmFeeWallet (0x4200...0011)
  • Difficulty 设置为 0

另外,Sequencervm.Context 中新增了字段 L1BlockNumber;如果 L2 合约需要获取最近的 L1 区块高度,可以查询 预部署合约 Lib_PredeployAddresses.L1_BLOCK_NUMBER (0x4200...0013)

合约部署

Optimism 通过 预部署合约 管理在 L2 上部署合约的权限:OVM_DeployerWhitelist.sol (0x4200...0002)

针对 Create / Create2 两个操作码,EVM 在实际处理前,会从 白名单合约 中查询调用者的权限

// l2geth/core/vm/evm.go

// create creates a new contract using code as deployment code.
func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address) ([]byte, common.Address, uint64, error) {
    if rcfg.UsingOVM {
        if !evm.AddressWhitelisted(caller.Address()) {
            return ret, common.Address{}, gas, errExecutionReverted
        }
    }

  // ...
}

费用

相关文档

L1->L2 交易

L2 费用

参考 L1->L2:L2 交易执行:交易在 L2 被中继时,valuegasPrice 字段为 0,即不消耗费用

How should we pay gas for deposits? #32 中,Optimism 开发团队讨论了另一种方案:在 L2 收取费用;目前没有定稿,偏向已有方案

L1 费用

参考上文,用户不需要为 L1->L2 交易 支付 L2 执行费用;这引起了下面的问题:

风险:攻击者在提交 L1->L2 交易时,将参数 _gasLimit 设置得很大,然后在 L2 合约中进行耗时操作,导致 Sequencer 性能下降

防范:Optimism 设置了阈值 enqueueL2GasPrepaid,如果提交 L1->L2 交易时,参数 _gasLimit 超过阈值,那么合约将根据 超出大小 按比例燃烧 gas;

参数:

  • enqueueL2GasPrepaid
    • 配置为 1,920,000
  • l2GasDiscountDivisor
    • 配置为 32
// packages/contracts/contracts/L1/rollup/CanonicalTransactionChain.sol

contract CanonicalTransactionChain is ICanonicalTransactionChain, Lib_AddressResolver {
    function enqueue(address _target, uint256 _gasLimit, bytes memory _data) external {
        // 如果 参数 超过 阈值
        if (_gasLimit > enqueueL2GasPrepaid) {
            // 根据 超出大小,计算燃烧的 gas
            uint256 gasToConsume = (_gasLimit - enqueueL2GasPrepaid) / l2GasDiscountDivisor;
            uint256 startingGas = gasleft();

            // 燃烧 gas (通过空转)
            uint256 i;
            while (startingGas - gasleft() < gasToConsume) {
                i++;
            }
        }

        // ...
    }
}

L2 交易

total_fee = l2_fee + l1_data_fee

在 L2 发起的交易,除了需要支付在 L2 本身的费用之外,还需要支付其在 L1 的上链费用。

注意:L2->L1 跨链消息的费用,与 L2->L2 交易费用一致。因为用户需要自己在 L1 调用中继消息,因此中继成本由用户承担 (参考 消息传递:比较 Arbitrum)

L2 费用

l2_fee = l2_gas_price * gas

其中:l2_gas_price 表示 L2 当前的 gasPrice

根据 Transaction fees on L2: Responding to gas price updatesl2_gas_price 会根据拥塞情况动态变化,但我未没找到实现 (Sequencer 确实实现了私有 API rollup_setL2GasPrice(),但没有调用代码);我在 Optimism L2 链上多次查询该字段的取值,都是 1,000,000 (即 0.001 Gwei)。因此动态 gasPrice 应该是开发中..

目前,eth_gasPrice API 返回的 gasPrice 都是 0.001 Gwei

L1 费用

l1_data_fee = l1_gas_price * (tx_data_gas + fixed_overhead) * dynamic_overhead

字段:

  • l1_gas_price
    • L1 平均 gasPrice
  • tx_data_gas
    • count_zero_bytes(tx_data) * 4 + count_non_zero_bytes(tx_data) * 16
  • fixed_overhead
    • 固定 gas,配置为 2100
  • dynamic_overhead
    • 放大系数,配置为 1.24

其中,l1_gas_price 来自 gas-oracle 服务,它定期统计 L1 平均 gasPrice ,更新至 L2 预部署合约 OVM_GasPriceOracle (0x4200...000F)

计算 L1 上链费用:

// l2geth/rollup/fees/rollup_fee.go

func CalculateL1MsgFee(msg Message, state StateDB, gpo *common.Address) (*big.Int, error) {
    // 将交易序列化为 calldata
    tx := asTransaction(msg)
    raw, err := rlpEncode(tx)

    // 读取 GasPriceOracle 中的参数:L1 平均 gasPrice,固定 gas,放大系数
    l1GasPrice, overhead, scalar := readGPOStorageSlots(*gpo, state)

    // 计算 L1 费用
    l1Fee := CalculateL1Fee(raw, overhead, l1GasPrice, scalar)
    return l1Fee, nil
}

func CalculateL1Fee(data []byte, overhead, l1GasPrice *big.Int, scalar *big.Float) *big.Int {
  // 计算 L1 gas
    l1GasUsed := CalculateL1GasUsed(data, overhead)

  // 乘以 gasPrice
    l1Fee := new(big.Int).Mul(l1GasUsed, l1GasPrice)

    // 乘以放大系数 scalar (1.24)
    return mulByFloat(l1Fee, scalar)
}

func CalculateL1GasUsed(data []byte, overhead *big.Int) *big.Int {
    zeroes, ones := zeroesAndOnes(data)

    // 空字节消耗的 gas
    zeroesGas := zeroes * params.TxDataZeroGas

    // 非空字节消耗的 gas
    onesGas := (ones + 68) * params.TxDataNonZeroGasEIP2028

    // 预期 gas
    l1Gas := new(big.Int).SetUint64(zeroesGas + onesGas)

    // 加上 固定 gas
    return new(big.Int).Add(l1Gas, overhead)
}

在执行交易前预先扣除 L1 费用:

// l2geth/core/state_transition.go

func (st *StateTransition) buyGas() error {
    mgval := new(big.Int).Mul(new(big.Int).SetUint64(st.msg.Gas()), st.gasPrice)

    if rcfg.UsingOVM {
        // 如果是 L2 -> L2 消息,需要加上上链费用 l1Fee
        // Only charge the L1 fee for QueueOrigin sequencer transactions
        if st.msg.QueueOrigin() == types.QueueOriginSequencer {
            mgval = mgval.Add(mgval, st.l1Fee)
            if st.msg.CheckNonce() {
                log.Debug("Adding L1 fee", "l1-fee", st.l1Fee)
            }
        }
    }

    // ...
}

Sequencer

根据 EVM 上下文,目前 L2 上所有交易消耗的 gas,都由矿工 0x4200...0011 获得

这个地址是个非常简单的智能合约 OVM_SequencerFeeVault,它实现了一个公开函数 withdraw():通过 StandardBridge 将 ETH 取出到 L1 上的固定地址 l1FeeWalletL1FeeWallet 这个地址没有什么特殊逻辑

// packages/contracts/contracts/L2/predeploys/OVM_SequencerFeeVault.sol
contract OVM_SequencerFeeVault {
    function withdraw() public {
        L2StandardBridge(Lib_PredeployAddresses.L2_STANDARD_BRIDGE).withdrawTo(
            Lib_PredeployAddresses.OVM_ETH,
            l1FeeWallet,
            address(this).balance,
            0,
            bytes("")
        );
    }
}

经济模型

TODO 没找到什么资料;从 Optimism 源码看不出来...

两难

Arbitrum 团队关于 Asserter 和 Checker 的思考

激励

TODO

零知识证明 – PLONK 学习笔记

Compound 的潜在风险和改进

Compound 中一些潜在的风险,以及可能的改进

Compound 代币和价格预言

Compound 中 COMP 代币挖矿 和 价格预言机的实现

深入探索 CALL 指令参数0

`CALL` 指令首个参数的用途,以及外部调用时 gas 的计算