OP Stack实现细节分析

  • maodaishan
  • 更新于 2024-03-09 11:52
  • 阅读 1758

分析了OP Stack的一些实现细节,如deposit等。 文章写于2023.9.

1. 交易费

交易费分为三部分,这三种费用会被分别收集到3个预部署的合约里。 ● 基础费:与L1上不同,基础费不会被烧掉,而是被收集在预部署合约里,到达一定量之后就会被提取到L1上的一个地址里。不确定这个地址是否可以更新。 ● 优先费:与基础费类似。地址可更新,但是需要更新合约,是否有权限呢? ● L1成本费:与基础费类似。L1提取地址:0xD15782B0ba6D00753c4D5361f7CE5647e01D74c6。 L1地址是admin地址。 EIP1559 的gas fee规则,可以看这篇。 区块的gas Limit设置: 配置json文件里:l2GenesisBlockGasLimit,目前设置为30M,跟以太坊主网一样。 Gas Price设置,这里要注意设置3个地方: ● base fee.这个随着区块是否满,会上下浮动。初始值在 ● max fee cap. 一笔交易的最大gas price。在配置json文件的l2GenesisBlockBaseFeePerGas,可以设小一点。 ● max tip cap.一笔交易的最大优先费。 注意设置完这些后,发交易的时候要设置maxPriorityFeePerGas (例如10),maxFeePerGas (例如1000),其中maxFeePerGas>maxPriorityFeePerGas。没输入会按默认数值,默认1GWei,非常高了。

2. Deposit

2.1 概述

注意:EOA往L2 deposit可以直接调用L1StandardBridge,但是合约不行,合约需要调用OptimismPortal,且调用后from会变。这是为了防止一种L1对L2的攻击。 Deposit交易发生于L1,会被引入L2,是跨链桥交易。OP引入了一种新的交易类型:0x7E代表deposit交易。它的特点:

  1. 它来自L1,协议规定deposit交易被强制纳入L2,以对抗审查攻击。
  2. 它里面没有签名
  3. 用户在L1上已经交了gas费,在L2的gas fee不退还。 deposit交易具有如下字段: ● bytes32 sourceHash:源哈希,唯一标识该的来源,注意deposit交易没有nonce。 ● address from:发送账户的地址 ● address to:接收帐户的地址,如果是合约创建,则为空。 ● uint256 mint:在 L2 上铸造的 ETH 价值。 ● uint256 value:发送到接收者账户的 ETH 值。 ● uint64 gas:L2 交易的 Gas 限制。 ● bool isSystemTx:如果为 true,则交易不会与 L2 区块气池交互。注意:从 Regolith 升级开始该位禁用,强制为false。 ● bytes data:载荷数据。 与EIP-155交易相比,此交易类型: ● 不包括 nonce,因为它是由sourceHash标识的。但receipt仍然包含一个nonce: ○ 在Regolith之前:nonce始终是0 ○ Regolith:nonce设置为depositNonce相应交易收据的属性。 ● 不包含签名,且from是明确的地址。API 响应包含零值的v, r,s以实现向后兼容性。 ● 包括新的sourceHash、from、mint和isSystemTx属性。API 响应包含这些作为附加字段。 sourceHash的计算方法: 有两种deposit交易,它们计算方法不同: ● 用户存入的deposit: keccak256(bytes32(uint256(0)), keccak256(l1BlockHash, bytes32(uint256(l1LogIndex)))). 其中l1BlockHash指的是L1上包含该deposit的区块。 l1LogIndex是该区块的event列表的索引。 ● 写入L1属性(L1 attribute): keccak256(bytes32(uint256(1)), keccak256(l1BlockHash, bytes32(uint256(seqNumber))))。其中l1BlockHash指的是存放信息属性的L1块哈希。且seqNumber = l2BlockNum - l2EpochStartBlockNum。每个L2块都包含一个这样的交易,因此seqNumber就是该L2区块在本epoch里的顺序。 如果没有前面的uint256(0)或uint256(1),那我们就无法区分这两种deposit交易,所以最前面那段是必须的。 每个L2块中都有一个L1属性交易,且必须是第一笔交易,这个交易不收gasfee。用户的deposit交易仅会出现在一个epoch的第一个块里。

    2.2 L1 attribute

    Cannon中,每个L2块都需要继承L1的属性,这是通过用一个内置账户往一个内置合约里写入L1属性来实现的。通过这个合约,每个L2块的合约都可以读取并使用L1块的属性。 这个合约只允许一个内置的,无人知道私钥的账户写入:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001 这个合约的地址;0x4200000000000000000000000000000000000015 它保存如下内容: ● L1块属性: ○ number( uint64) ○ timestamp( uint64) ○ basefee( uint256) ○ hash( bytes32) ● sequenceNumber( uint64):当前L2块在本epoch里的顺序 ● 与 L1 块相关的系统配置 ○ batcherHash( bytes32):对当前正在运行的batcher的版本承诺。 ○ overhead( uint256):Gas Price Oracle (GPO) 参数,用于计算本块中的gas price的L1成本数据。 ○ scalar( uint256):同上。 合约参考代码:https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L1Block.sol 调用它的交易,mint, value都为0, gasLimit为1000000,不收gas fee。

    2.3 deposit交易的执行

    from账户的余额必须增加mint量,这个是无论如何都会执行的,即使deposit交易执行失败,也不会revert。 然后会根据交易内容执行,就是个EIP155的普通交易。 执行时以下部分会与EIP1559不同: ● 不验证gas fee相关的字段,因为在L1已经支付过gas fee。 ● 不验证nonce,因为唯一标识是sourceHash ● 不处理accessList,会将accessList当作空来处理。 ● 不检查from是否是EOA。 ● 没有gas 退还(no gas refund) ● 不收取gas优先费 ● 不收取L1成本费,因为deposit来自L1,因此batch时不需要再次提交回L1 ● 不收取BaseFee,在EIP1559的计算里,也不增加BaseFee的额度。 注意gas的计算还是有的,但是gas fee不算(是否针对deposit, gas price为0???) 任何非EVM状态转换错误都会以特殊方式处理: ● 会被转换为EVM错误。例如,deposit交易总会被纳入L2,但是它因为一个非EVM状态转换错误而失败的话,它的收据会显示失败。例如转账时的余额不足。 ● 在mint新币之外的世界状态部分会revert。 ● from的nonce+1,让它看起来像是本地EVM错误。 其他就跟普通交易一样了。另外从Regolith开始,deposit交易的收据里增加了一个depositNonce,存储在执行EVM之前的nonce值。

    2.4 回执

    跟普通交易类似,多了depositNonce。

    2.5 Deposit合约

    合约部署在L1,当发送deposit交易后,会发出TransactionDeposited 事件,L2节点derive这个事件后,在L2上写入deposit交易。 这个合约负责维护guranteed gas fee market;向deposit交易收取L2 gas fee,并保证在L1上收的手续费不会超过L2的gasLimit,否则在L2一个区块就跑不完了。 它处理两个特殊情况: ● 合约创建deposit,此时要把isCreation设置为true,如果非true,就revert了。 ● 来自合约账户的调用,此时from会被转换为L2上的alias,会在它的地址上加上0x1111000000000000000000000000000000001111 ,注意是数学的加,不是字符串连接。这段代码会被设置为unchecked,并且用uint160处理,所以它会溢出,这用来防止一个攻击:一个合约在L1和L2有相同的地址,但有不同的代码,防止在L1上调用L2。对EOA来说这没什么问题,因为它们都没有代码。这还让用户可以在L2 sequencer已经宕机的情况下调用L2的合约。(这段没太懂)。 合约地址:https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L1/OptimismPortal.sol

    2.6 为什么直接转账给OptimismPortal就能deposit了

    当要deposit ETH的时候,可以直接转账ETH给OptimismPortal就行,这是因为receive函数里会直接发Event通知给L2。

    3. Withdraw

    4. SystemConfig

    SystemConfig是L1上的合约,会发出event通知,L2上的节点derivation的时候就会收到并应用这些配置。包含如下内容: ● batcherHash。在version0里,后20个byte代表当前的batcher的地址。这个位用来标识batcher的轮换。 ● overhead和Scalar,Gas Price Oracle (GPO) 的数据,用来在L2上更新L1的gas成本定价标准。 ● gasLimit,用来定义L2的区块gasLimit。在第一个引入该配置的L2块上生效。 ● unsafeBlockSigner。在L2区块被batcher提交到L1之前,会先在L2的p2p网络里传播,此时需要一个signer来确定这个块被承诺会提交到L1。为了保证能在存储证明里获取它,也就是让它不依赖于代码里的storage,它被存在一个固定的slot:keccak256("systemconfig.unsafeblocksigner")

    5. op-batcher

    batcher会定时把L2的交易打包后发送到L1。 epoch window: L1上的区块跟L2的epoch是一一对应关系。epoch的编号就是L1上对应epoch开始的块的块高。例如N。epoch windows是 N+ SEQUENCING_WINDOWS_SIZE (SWS,主网3600),对epoch N的batch需要在epoch windows内提交,而在L2上对该epoch的derition必须得在epoch windows之外。也就是epoch window是个与具体epoch挂钩的时间段,它限制了batcher和derivition的操作时间。注意epoch window和epoch的长度是不一样的,epoch的长度是L1上两个以太坊epoch时间,一个以太坊epoch是32个块,两个就是64个块。 batch/batchTransaction:L2上的交易被压缩和打包成batchTransaction提交到L1。 channel:是batch的组合。有时候为了更好的压缩率,可以把多个batch打包到一个channel里,上传到L1,以降低gas消耗。 Frame:channel有可能过大,没法在一次batchTransaction提交完,这时候就把channel拆成多个Frame提交。 op-batcher/batcher/driver.go里有个loop循环,处理三种事件:

  4. 定时器,把所有未加载的l2Block加载进来,触发向L1提交batch交易。这个定时器可以在启动op-node时指定。--poll-interval=120s \
  5. 处理batchTransaction的receipt操作,记录成功还是失败,失败的要把Frame重新push进channel
  6. op-batcher的关闭事件。此时需要检查channel,把所有该发到L1的batch发出去。 在定时器事件中,调用loadBlocksIntoState来询问RollupNode.SyncStatus,获取自上次发送batch transaction而派生的最新safeblock后新生成的unsafeblock范围。然后循环将这个范围中的每一个unsafe块调用loadBlockIntoState函数从L2里获取并通过AddL2Block函数加载到内部的block队列里。然后通过channel和frame处理,发送到L1交易。

    6. op-node

    入口在op-node/cmd/main.go,会创建op-node并调用Start,这里初始化很多操作,如derivition。见op-node/node/node.go里的各种初始化: ● 对L1监听并调用:OnNewL1Head,OnNewL1Safe,OnNewL1Finalized ● 初始化L2,调用driver.NewDriver,这里面会启动eventLoop,处理所有事件。 op-node包含: 对L1的各种监听,endpoint等。 对L2(即op-geth)的监听,endpoint等 自身的RPC服务,p2p节点等。 在op-node的Start里,会启动它的loop,在opnode/rollup/driver/state.go->eventLoop,重要的事情都在这里处理的,是很多事情的起点。 EngienControl是一个重要的模块,它协调区块的构建,管理分叉,保持各方状态一致。 它包含一个状态类型,用来描述L2块的状态: type EngineState interface { Finalized() eth.L2BlockRef UnsafeL2Head() eth.L2BlockRef SafeL2Head() eth.L2BlockRef } 还有一些接口: //构建新的L2块。指明parent以及一些参数。注意参数中包括: //deposit的交易。 //时间戳,coinbase,随机数,GasLimit等。所以L2的块对L1的信息的集成,以及deposit就是这样控制的。 //所以geth是受op-node协调控制出块的。这就是execute和beacon之间的沟通桥梁 StartPayload(ctx context.Context, parent eth.L2BlockRef, attrs *eth.PayloadAttributes, updateSafe bool) (errType BlockInsertionErrType, err error)

//要求确认一个L2块,会把确认块的块头和块体内容都返回回来。 ConfirmPayload(ctx context.Context) (out *eth.ExecutionPayload, errTyp BlockInsertionErrType, err error)

//取消构建一个L2块 CancelPayload(ctx context.Context, force bool) error

//查询当前是否在构建块,以及这个块的信息 BuildingPayload() (onto eth.L2BlockRef, id eth.PayloadID, safe bool) loop里对各种事件的处理。注意对sequencer和非sequencer节点的处理都是走这里。 sequencer就要处理sequencer事件,也就是出块。 非sequencer就要处理stepReqCh,也就是derivition。这包括从L1获取区块,以及从L2的p2p获取区块的处理。区块一般都是先从L2的p2p获取到,此时是UnSafe,然后从L1获取到后,变为Safe,当L1上的块变为finalized后,此时L2块变为Finalized。 ● sequencer事件,也就是协调op-geth来构建L2的区块。通过sequencerTimer驱动,通过PlanNextSequencerAction来启动下一件事情的定时。通过RunNextSequencerAction来协调L2的区块构建行为。这些构建行为通过调用EngienControl的几个接口完成。 构建中可能会出现一些error,其中一种严重的问题是reset,也就是L1发生区块重组,这些重组影响到了对L1 origin的选择。此时就得引导op-geth选择新的L1 区块来构建L2区块。这同时也可能会影响到derivition,具体的操作取决于op-geth和derivition。在理想的情况下,derivition可以继续进行,因为它可以对比在p2p收到的块和derivition收到的块。如果L1的区块重组足够深,导致derivition也要reset,那么L2的出块也可以继续,但是节点可能会持续reset和推进derivition,来reset L2的块,直到链正确。 ● altSyncTicker,是个定时器事件,时间为L2出块时间的2倍。用来检查当前的L2区块的sync情况。start = UnsafeL2Headend = UnsafeL2SyncTarget,可能为空,也可能不为空。如果end = nil或end-start > 1,都会发起一个对L2 start -> end 区块的获取,获取到之后触发OnUnsafeL2Payload。 ● unsafeL2Payloads,收到新的L2块后,检查payload,不为空就push进EngineQueue。 ● l1HeadSig,即收到新的L1区块头,调用HandleNewL1HeadBlock 判断是重复收到了当前的头,顺延下一个的头,旧的头,还是未来的头。后两者代表L1可能有re-org,或者我们中间漏了L1块。更新本地保存的l1 head,触发stepReqCh。 ● stepReqCh,控制derivition的流程。derivition是个流水线处理,有很多流程。它通过DerivationPipeline.Step来驱动。它进而调用EngineQueueStage.Step (op-node/rollup/derive/eigien_queue.go)整个过程比较复杂,可以参考https://learnblockchain.cn/article/6758。 a. 如果L1 reorg导致reset,调用tryUpdateEngine。 b. 检查queue里是否有新的unsafePayload,也就是L2是否有收到新块。有的话就先处理,这样可以获取到最新的L2块。如果所有检查都通过,更新unsafeHead为该L2新块。并Sync这个新的L2块,return c. ...不深究了,看文档吧 ● 其他一些对新的L1块头发现,L1块的finalized等事件,都跟derivition有关。

注意: ● 如果当前节点是sequencer,如果safeL2Head + sequencer.max-safe-lag < UnsafeL2Head,也就是当前节点derivition的L1的L2块头比当前最新L2块头的距离太远,那么sequencer就停止L2出块,等待derivition。默认sequencer.max-safe-lag=0,也就是两个必须一致才出块。

7. L2区块生产

L2区块的生产在op-geth里,但是它是由op-node驱动的。详见6. op-node的事件循环里,会不停的判断derivition的情况和出块情况,当合适出块的时候,就会调用op-geth的client来通知geth出块。这由RunNextSequencerAction驱动。会调用StartBuildingBlock来开始一个块的创建,这里实际会创建一个payload然后再让geth根据这个payload创建区块。

。。。 没空了,可能就不再写下去了

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

0 条评论

请先 登录 后评论
maodaishan
maodaishan
0xee37...1912
江湖只有他的大名,没有他的介绍。