optimism-rollup 技术原理

optimism-rollup 做为目前最流行的以太坊L2解决方案,最近研究了下,写个笔记。

另外,layer2并不是侧链,而是以太坊的扩展,layer1到layer2的交易,并不是跨链交易,而是跨域交易.

optimism 的项目源码在 https://github.com/ethereum-optimism/optimism。
虽然是一个项目,但是分了很多层。为了方便理解,把每层的作用先记录下.

一.代码结构

代码结构.jpg

项目分了5层:

  • l2geth
  • contracts
  • data-transport-layer
  • batch-submitter
  • message-relayer

上面的5层共同构成了optimism-rollup 这个系统。下来分别了解下:

l2geth:

这是fork的以太坊的1.9.10版本,里面增加了个rollup包,实现了layer2上的两种角色:Sequncer,Verifier.

  • Sequencer 用于侦听layer1的跨域消息,并且将交易改为OVMMessage到 虚拟机(OVM)中运行,

  • Verifier 用于验证layer2上的Sequencer提交的交易的正确性。

以下是两种角色的启动代码:

 func (s *SyncService) Start() error {
    if !s.enable {
        return nil
    }
    log.Info("Initializing Sync Service", "eth1-chainid", s.eth1ChainId)

    // When a sequencer, be sure to sync to the tip of the ctc before allowing
    // user transactions.
    if !s.verifier {
        err := s.syncTransactionsToTip()
        if err != nil {
            return fmt.Errorf("Cannot sync transactions to the tip: %w", err)
        }
        // TODO: This should also sync the enqueue'd transactions that have not
        // been synced yet
        s.setSyncStatus(false)
    }

    if s.verifier {
        go s.VerifierLoop()
    } else {
        go s.SequencerLoop()
    }
    return nil
}

packages包下有几个文件夹,不过主要的模块是: batch-submitter,contracts,data-transport-layer,message-relayer. 我们分别说明下先了解下这些结构所伴演的角色:

batch-submitter

向layer1的CTC chain和 SCC chain分别提交layer2的交易和交易的状态根。里面分别实现了两个typescript文件,state-batch-submitter.ts 和 tx-batch-submitter.ts 这两个文件就是通过
分别向 scc chain 和 ctc chain提交状态和交易的两个文件。另外,在CTC chain中,区块叫batch,也是交易的集合。 batch-sumitter.ts就是每隔一段时间,从layer2中,从当前ctc的index开始,获取一批交易.组成一个batch, 提交到ctc chain的 appendSequencerBatch 中去。代码如下:

  public async _submitBatch(
    startBlock: number,
    endBlock: number
  ): Promise {
    // Do not submit batch if gas price above threshold
    const gasPriceInGwei = parseInt(
      ethers.utils.formatUnits(await this.signer.getGasPrice(), 'gwei'),
      10
    )
    if (gasPriceInGwei > this.gasThresholdInGwei) {
      this.log.warn(
        'Gas price is higher than gas price threshold; aborting batch submission',
        {
          gasPriceInGwei,
          gasThresholdInGwei: this.gasThresholdInGwei,
        }
      )
      return
    }

    const [
      batchParams,
      wasBatchTruncated,
    ] = await this._generateSequencerBatchParams(startBlock, endBlock)
    const batchSizeInBytes = encodeAppendSequencerBatch(batchParams).length / 2
    this.log.debug('Sequencer batch generated', {
      batchSizeInBytes,
    })

    // Only submit batch if one of the following is true:
    // 1. it was truncated
    // 2. it is large enough
    // 3. enough time has passed since last submission
    if (!wasBatchTruncated && !this._shouldSubmitBatch(batchSizeInBytes)) {
      return
    }
    this.log.debug('Submitting batch.', {
      calldata: batchParams,
    })

    const nonce = await this.signer.getTransactionCount()
    const contractFunction = async (gasPrice): Promise => {
      const tx = await this.chainContract.appendSequencerBatch(batchParams, {
        nonce,
        gasPrice,
      })
      this.log.info('Submitted appendSequencerBatch transaction', {
        nonce,
        txHash: tx.hash,
        contractAddr: this.chainContract.address,
        from: tx.from,
        data: tx.data,
      })
      return this.signer.provider.waitForTransaction(
        tx.hash,
        this.numConfirmations
      )
    }
    return this._submitAndLogTx(contractFunction, 'Submitted batch!')
  }
  

state-batch-submitter.ts 过程和tx-batch-submitter一样,不过state-batch-submitter提交的是区块的状态根(state root),方法是_generateStateCommitmentBatch(startBlock:number,endBlock: number);

调用的是scc chain的 appendStateBatch方法.代码如果下:

public async _submitBatch(
  startBlock: number,
  endBlock: number
): Promise {
  const batch = await this._generateStateCommitmentBatch(startBlock, endBlock)
  const tx = this.chainContract.interface.encodeFunctionData(
    'appendStateBatch',
    [batch, startBlock]
  )
  const batchSizeInBytes = remove0x(tx).length / 2
  this.log.debug('State batch generated', {
    batchSizeInBytes,
    tx,
  })

  if (!this._shouldSubmitBatch(batchSizeInBytes)) {
    return
  }

  const offsetStartsAtIndex = startBlock - BLOCK_OFFSET // TODO: Remove BLOCK_OFFSET by adding a tx to Geth's genesis
  this.log.debug('Submitting batch.', { tx })

  const nonce = await this.signer.getTransactionCount()
  const contractFunction = async (gasPrice): Promise => {
    const contractTx = await this.chainContract.appendStateBatch(
      batch,
      offsetStartsAtIndex,
      { nonce, gasPrice }
    )
    this.log.info('Submitted appendStateBatch transaction', {
      nonce,
      txHash: contractTx.hash,
      contractAddr: this.chainContract.address,
      from: contractTx.from,
      data: contractTx.data,
    })
    return this.signer.provider.waitForTransaction(
      contractTx.hash,
      this.numConfirmations
    )
  }
  return this._submitAndLogTx(contractFunction, 'Submitted state root batch!')
}

contracts

Layer2系统中使用的各种智能合约,不过有些需要在layer1上布署,有些要在layer2上布署.需要注意的是: 这些合约要用 optimistic-solc 编译器进行编译,目的是为了保证无论何时,在执行同一个交易的时候,输出结果都是一样的。 因为 OVM_ExecutionManager.sol 中对于一些动态的opcode进行了重写, 比如: timestamp 在evm中获取的是当前区块的时间戳,而ovm中是按交易来的,执行哪个交易,是哪个交易的时间戳。

除了实现了ovm外,还包括一些账户,跨域桥,layer1上的验证者,预编译合约和ctc,scc chain 。这些都是optimism系统的核心。 所有的跨域消息都是通过调用这些合约和侦听合约的事件进行工作的。

data-transport-layer

数据传输层,其实这层就是个事件索引器,通过rpc访问layer1的rpc接口,索引layer1的合约事件,比如:
CTC chain的 TransactionEnqueued事件和SequencerBatchAppended事件,另外还有SCC chain的StateBatchAppended事件,索引到这些事件后,就会存在本地数据库下。然后再提供个rpc接口,供layer2也就是l2geth 来获取这些事件。当然这层也会提供相当的rpc接口,也就是实现了个client专门供layer2来获取数据。
**TransactionEnqueued 事件就是CTC chain的enqueue方法执行完毕,将一个交易提交到了CTC chain 的queue队列.SequencerBatchAppended 就是squencer提交了个batch到CTC chain中。是 appendSequencerBatch 这个接口的事件。StateBatchAppended 当然就是 交易的状态根提交到了SCC chain中 是方法 _appendBatch 的执行事件。

相应的代码如下:

protected async _start(): Promise {
  // This is our main function. It's basically just an infinite loop that attempts to stay in
  // sync with events coming from Ethereum. Loops as quickly as it can until it approaches the
  // tip of the chain, after which it starts waiting for a few seconds between each loop to avoid
  // unnecessary spam.
  while (this.running) {
    try {
      const highestSyncedL1Block =
        (await this.state.db.getHighestSyncedL1Block()) ||
        this.state.startingL1BlockNumber
      const currentL1Block = await this.state.l1RpcProvider.getBlockNumber()
      const targetL1Block = Math.min(
        highestSyncedL1Block + this.options.logsPerPollingInterval,
        currentL1Block - this.options.confirmations
      )

      // We're already at the head, so no point in attempting to sync.
      if (highestSyncedL1Block === targetL1Block) {
        await sleep(this.options.pollingInterval)
        continue
      }

      this.logger.info('Synchronizing events from Layer 1 (Ethereum)', {
        highestSyncedL1Block,
        targetL1Block,
      })

      // I prefer to do this in serial to avoid non-determinism. We could have a discussion about
      // using Promise.all if necessary, but I don't see a good reason to do so unless parsing is
      // really, really slow for all event types.
      await this._syncEvents(
        'OVM_CanonicalTransactionChain',
        'TransactionEnqueued',
        highestSyncedL1Block,
        targetL1Block,
        handleEventsTransactionEnqueued
      )

      await this._syncEvents(
        'OVM_CanonicalTransactionChain',
        'SequencerBatchAppended',
        highestSyncedL1Block,
        targetL1Block,
        handleEventsSequencerBatchAppended
      )

      await this._syncEvents(
        'OVM_StateCommitmentChain',
        'StateBatchAppended',
        highestSyncedL1Block,
        targetL1Block,
        handleEventsStateBatchAppended
      )

      await this.state.db.setHighestSyncedL1Block(targetL1Block)

      if (
        currentL1Block - highestSyncedL1Block <
        this.options.logsPerPollingInterval
      ) {
        await sleep(this.options.pollingInterval)
      }
    } catch (err) {
      if (!this.running || this.options.dangerouslyCatchAllErrors) {
        this.logger.error('Caught an unhandled error', { err })
        await sleep(this.options.pollingInterval)
      } else {
        // TODO: Is this the best thing to do here?
        throw err
      }
    }
  }
}

_syncEvents就是通过某个合约的某个事件,然后通过相应的handle的存储在本地的数据库中。

到这里,有了batch-submitter和data-transport-layer就可以把layer1和layer2上的交易形成循环。

如果有人在layer2上执行了交易,交易在打包后,会通过batch-submitter 提交到layer1的CTC chain,然后data-transport-layer侦听到事件后,会存在本地数据库,这时l2geth可以通过rpc获取 data-transport-layer存的数据。然后再到 layer2上尝试执行,拿结果和layer2已经确定的交易进行比较,如果一样,说明layer1上的交易是正确的,如果不一样,则需要layer1上的验证者去做验证。这是verifier的功能。

l2geth是另一个角 色是Sequencer,他是把data-transport-layer中侦听到的quence的交易,提交到layer2中打包。

然后batch-submitter获取区块的stateroot再提交到layer1的SCC chain中。**
这块逻辑有点绕。需要慢慢理解。。

message-relayer

这是一个 中继服务,是将layer2中的提现交易中继到layer1上。其实现过程,就是利用rpc接口侦听layer2的SentMessages事件,这个事件就是跨域转账或其他跨域消息。然后,relayer侦听到这个事件后,会根据事件的参数。在layer1上调用OVM_L1CrossDomainMessenger的relayMessage方法,进行relay.然后就会到相应的合约上执行相应的方法。以达到跨域转账的目的.

我们先介绍这几个主要的模块代码。希望以大家理解有帮助。

你可能感兴趣的:(optimism-rollup 技术原理)