{"content":{"title":"OP Stack实现细节分析","body":"# 1.    交易费\r\n交易费分为三部分，这三种费用会被分别收集到3个预部署的合约里。\r\n● 基础费：与L1上不同，基础费不会被烧掉，而是被收集在预部署合约里，到达一定量之后就会被提取到L1上的一个地址里。不确定这个地址是否可以更新。\r\n● 优先费：与基础费类似。地址可更新，但是需要更新合约，是否有权限呢?\r\n● L1成本费：与基础费类似。L1提取地址：0xD15782B0ba6D00753c4D5361f7CE5647e01D74c6。\r\nL1地址是admin地址。\r\nEIP1559 的gas fee规则，可以看这篇。\r\n区块的gas Limit设置：\r\n配置json文件里：l2GenesisBlockGasLimit，目前设置为30M，跟以太坊主网一样。\r\nGas Price设置，这里要注意设置3个地方：\r\n● base fee.这个随着区块是否满，会上下浮动。初始值在\r\n● max fee cap. 一笔交易的最大gas price。在配置json文件的l2GenesisBlockBaseFeePerGas，可以设小一点。\r\n● max tip cap.一笔交易的最大优先费。\r\n注意设置完这些后，发交易的时候要设置maxPriorityFeePerGas （例如10），maxFeePerGas （例如1000），其中maxFeePerGas>maxPriorityFeePerGas。没输入会按默认数值，默认1GWei，非常高了。\r\n# 2.    Deposit\r\n## 2.1    概述\r\n注意：EOA往L2 deposit可以直接调用L1StandardBridge，但是合约不行，合约需要调用OptimismPortal，且调用后from会变。这是为了防止一种L1对L2的攻击。\r\nDeposit交易发生于L1，会被引入L2，是跨链桥交易。OP引入了一种新的交易类型：0x7E代表deposit交易。它的特点：\r\n1. 它来自L1，协议规定deposit交易被强制纳入L2，以对抗审查攻击。\r\n2. 它里面没有签名\r\n3. 用户在L1上已经交了gas费，在L2的gas fee不退还。\r\ndeposit交易具有如下字段：\r\n● bytes32 sourceHash：源哈希，唯一标识该的来源，注意deposit交易没有nonce。\r\n● address from：发送账户的地址\r\n● address to：接收帐户的地址，如果是合约创建，则为空。\r\n● uint256 mint：在 L2 上铸造的 ETH 价值。\r\n● uint256 value：发送到接收者账户的 ETH 值。\r\n● uint64 gas：L2 交易的 Gas 限制。\r\n● bool isSystemTx：如果为 true，则交易不会与 L2 区块气池交互。注意：从 Regolith 升级开始该位禁用，强制为false。\r\n● bytes data：载荷数据。\r\n与EIP-155交易相比，此交易类型：\r\n● 不包括 nonce，因为它是由sourceHash标识的。但receipt仍然包含一个nonce：\r\n    ○ 在Regolith之前：nonce始终是0\r\n    ○ Regolith：nonce设置为depositNonce相应交易收据的属性。\r\n● 不包含签名，且from是明确的地址。API 响应包含零值的v, r,s以实现向后兼容性。\r\n● 包括新的sourceHash、from、mint和isSystemTx属性。API 响应包含这些作为附加字段。\r\nsourceHash的计算方法：\r\n有两种deposit交易，它们计算方法不同：\r\n● 用户存入的deposit： keccak256(bytes32(uint256(0)), keccak256(l1BlockHash, bytes32(uint256(l1LogIndex)))). 其中l1BlockHash指的是L1上包含该deposit的区块。 l1LogIndex是该区块的event列表的索引。\r\n● 写入L1属性（L1 attribute）： keccak256(bytes32(uint256(1)), keccak256(l1BlockHash, bytes32(uint256(seqNumber))))。其中l1BlockHash指的是存放信息属性的L1块哈希。且seqNumber = l2BlockNum - l2EpochStartBlockNum。每个L2块都包含一个这样的交易，因此seqNumber就是该L2区块在本epoch里的顺序。\r\n如果没有前面的uint256(0)或uint256(1)，那我们就无法区分这两种deposit交易，所以最前面那段是必须的。\r\n每个L2块中都有一个L1属性交易，且必须是第一笔交易，这个交易不收gasfee。用户的deposit交易仅会出现在一个epoch的第一个块里。\r\n#  2.2    L1 attribute\r\nCannon中，每个L2块都需要继承L1的属性，这是通过用一个内置账户往一个内置合约里写入L1属性来实现的。通过这个合约，每个L2块的合约都可以读取并使用L1块的属性。\r\n这个合约只允许一个内置的，无人知道私钥的账户写入：0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001\r\n这个合约的地址;0x4200000000000000000000000000000000000015\r\n它保存如下内容：\r\n● L1块属性：\r\n    ○ number( uint64)\r\n    ○ timestamp( uint64)\r\n    ○ basefee( uint256)\r\n    ○ hash( bytes32)\r\n● sequenceNumber( uint64)：当前L2块在本epoch里的顺序\r\n● 与 L1 块相关的系统配置\r\n    ○ batcherHash( bytes32)：对当前正在运行的batcher的版本承诺。\r\n    ○ overhead( uint256)：Gas Price Oracle (GPO) 参数，用于计算本块中的gas price的L1成本数据。\r\n    ○ scalar( uint256)：同上。\r\n合约参考代码：https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L1Block.sol\r\n调用它的交易，mint, value都为0， gasLimit为1000000，不收gas fee。\r\n#  2.3    deposit交易的执行\r\nfrom账户的余额必须增加mint量，这个是无论如何都会执行的，即使deposit交易执行失败，也不会revert。\r\n然后会根据交易内容执行，就是个EIP155的普通交易。\r\n执行时以下部分会与EIP1559不同：\r\n● 不验证gas fee相关的字段，因为在L1已经支付过gas fee。\r\n● 不验证nonce，因为唯一标识是sourceHash\r\n● 不处理accessList，会将accessList当作空来处理。\r\n● 不检查from是否是EOA。\r\n● 没有gas 退还（no gas refund）\r\n● 不收取gas优先费\r\n● 不收取L1成本费，因为deposit来自L1，因此batch时不需要再次提交回L1\r\n● 不收取BaseFee，在EIP1559的计算里，也不增加BaseFee的额度。\r\n注意gas的计算还是有的，但是gas fee不算（是否针对deposit, gas price为0？？？）\r\n任何非EVM状态转换错误都会以特殊方式处理：\r\n● 会被转换为EVM错误。例如，deposit交易总会被纳入L2，但是它因为一个非EVM状态转换错误而失败的话，它的收据会显示失败。例如转账时的余额不足。\r\n● 在mint新币之外的世界状态部分会revert。\r\n● from的nonce+1，让它看起来像是本地EVM错误。\r\n其他就跟普通交易一样了。另外从Regolith开始，deposit交易的收据里增加了一个depositNonce，存储在执行EVM之前的nonce值。\r\n#  2.4    回执\r\n跟普通交易类似，多了depositNonce。\r\n#  2.5    Deposit合约\r\n合约部署在L1，当发送deposit交易后，会发出TransactionDeposited 事件，L2节点derive这个事件后，在L2上写入deposit交易。\r\n这个合约负责维护guranteed gas fee market；向deposit交易收取L2 gas fee，并保证在L1上收的手续费不会超过L2的gasLimit，否则在L2一个区块就跑不完了。\r\n它处理两个特殊情况：\r\n● 合约创建deposit，此时要把isCreation设置为true，如果非true，就revert了。\r\n● 来自合约账户的调用，此时from会被转换为L2上的alias，会在它的地址上加上0x1111000000000000000000000000000000001111 ，注意是数学的加，不是字符串连接。这段代码会被设置为unchecked，并且用uint160处理，所以它会溢出，这用来防止一个攻击：一个合约在L1和L2有相同的地址，但有不同的代码，防止在L1上调用L2。对EOA来说这没什么问题，因为它们都没有代码。这还让用户可以在L2 sequencer已经宕机的情况下调用L2的合约。（这段没太懂）。\r\n合约地址：https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L1/OptimismPortal.sol\r\n#  2.6    为什么直接转账给OptimismPortal就能deposit了\r\n当要deposit ETH的时候，可以直接转账ETH给OptimismPortal就行，这是因为receive函数里会直接发Event通知给L2。\r\n# 3.    Withdraw\r\n# 4.    SystemConfig\r\nSystemConfig是L1上的合约，会发出event通知，L2上的节点derivation的时候就会收到并应用这些配置。包含如下内容：\r\n● batcherHash。在version0里，后20个byte代表当前的batcher的地址。这个位用来标识batcher的轮换。\r\n● overhead和Scalar，Gas Price Oracle (GPO) 的数据，用来在L2上更新L1的gas成本定价标准。\r\n● gasLimit，用来定义L2的区块gasLimit。在第一个引入该配置的L2块上生效。\r\n● unsafeBlockSigner。在L2区块被batcher提交到L1之前，会先在L2的p2p网络里传播，此时需要一个signer来确定这个块被承诺会提交到L1。为了保证能在存储证明里获取它，也就是让它不依赖于代码里的storage，它被存在一个固定的slot：keccak256(\"systemconfig.unsafeblocksigner\")\r\n# 5.    op-batcher\r\nbatcher会定时把L2的交易打包后发送到L1。\r\nepoch 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个块。\r\nbatch/batchTransaction：L2上的交易被压缩和打包成batchTransaction提交到L1。\r\nchannel：是batch的组合。有时候为了更好的压缩率，可以把多个batch打包到一个channel里，上传到L1，以降低gas消耗。\r\nFrame：channel有可能过大，没法在一次batchTransaction提交完，这时候就把channel拆成多个Frame提交。\r\nop-batcher/batcher/driver.go里有个loop循环，处理三种事件：\r\n1. 定时器，把所有未加载的l2Block加载进来，触发向L1提交batch交易。这个定时器可以在启动op-node时指定。--poll-interval=120s \\\r\n2. 处理batchTransaction的receipt操作，记录成功还是失败，失败的要把Frame重新push进channel\r\n3. op-batcher的关闭事件。此时需要检查channel，把所有该发到L1的batch发出去。\r\n在定时器事件中，调用loadBlocksIntoState来询问RollupNode.SyncStatus，获取自上次发送batch transaction而派生的最新safeblock后新生成的unsafeblock范围。然后循环将这个范围中的每一个unsafe块调用loadBlockIntoState函数从L2里获取并通过AddL2Block函数加载到内部的block队列里。然后通过channel和frame处理，发送到L1交易。\r\n# 6.    op-node\r\n入口在op-node/cmd/main.go，会创建op-node并调用Start，这里初始化很多操作，如derivition。见op-node/node/node.go里的各种初始化：\r\n● 对L1监听并调用：OnNewL1Head，OnNewL1Safe，OnNewL1Finalized\r\n● 初始化L2，调用driver.NewDriver，这里面会启动eventLoop，处理所有事件。\r\nop-node包含：\r\n对L1的各种监听，endpoint等。\r\n对L2（即op-geth）的监听，endpoint等\r\n自身的RPC服务，p2p节点等。\r\n在op-node的Start里，会启动它的loop，在opnode/rollup/driver/state.go->eventLoop，重要的事情都在这里处理的，是很多事情的起点。\r\nEngienControl是一个重要的模块，它协调区块的构建，管理分叉，保持各方状态一致。\r\n它包含一个状态类型，用来描述L2块的状态：\r\ntype EngineState interface {\r\n   Finalized() eth.L2BlockRef\r\n   UnsafeL2Head() eth.L2BlockRef\r\n   SafeL2Head() eth.L2BlockRef\r\n}\r\n还有一些接口：\r\n//构建新的L2块。指明parent以及一些参数。注意参数中包括：\r\n//deposit的交易。\r\n//时间戳，coinbase,随机数，GasLimit等。所以L2的块对L1的信息的集成，以及deposit就是这样控制的。\r\n//所以geth是受op-node协调控制出块的。这就是execute和beacon之间的沟通桥梁\r\nStartPayload(ctx context.Context, parent eth.L2BlockRef, attrs *eth.PayloadAttributes, updateSafe bool) (errType BlockInsertionErrType, err error)\r\n\r\n//要求确认一个L2块，会把确认块的块头和块体内容都返回回来。\r\nConfirmPayload(ctx context.Context) (out *eth.ExecutionPayload, errTyp BlockInsertionErrType, err error)\r\n\r\n//取消构建一个L2块\r\nCancelPayload(ctx context.Context, force bool) error\r\n\r\n//查询当前是否在构建块，以及这个块的信息\r\nBuildingPayload() (onto eth.L2BlockRef, id eth.PayloadID, safe bool)\r\nloop里对各种事件的处理。注意对sequencer和非sequencer节点的处理都是走这里。\r\nsequencer就要处理sequencer事件，也就是出块。\r\n非sequencer就要处理stepReqCh，也就是derivition。这包括从L1获取区块，以及从L2的p2p获取区块的处理。区块一般都是先从L2的p2p获取到，此时是UnSafe，然后从L1获取到后，变为Safe，当L1上的块变为finalized后，此时L2块变为Finalized。\r\n● sequencer事件，也就是协调op-geth来构建L2的区块。通过sequencerTimer驱动，通过PlanNextSequencerAction来启动下一件事情的定时。通过RunNextSequencerAction来协调L2的区块构建行为。这些构建行为通过调用EngienControl的几个接口完成。\r\n构建中可能会出现一些error，其中一种严重的问题是reset，也就是L1发生区块重组，这些重组影响到了对L1 origin的选择。此时就得引导op-geth选择新的L1 区块来构建L2区块。这同时也可能会影响到derivition，具体的操作取决于op-geth和derivition。在理想的情况下，derivition可以继续进行，因为它可以对比在p2p收到的块和derivition收到的块。如果L1的区块重组足够深，导致derivition也要reset，那么L2的出块也可以继续，但是节点可能会持续reset和推进derivition，来reset L2的块，直到链正确。\r\n● altSyncTicker，是个定时器事件，时间为L2出块时间的2倍。用来检查当前的L2区块的sync情况。start = UnsafeL2Headend = UnsafeL2SyncTarget，可能为空，也可能不为空。如果end = nil或end-start > 1，都会发起一个对L2 start -> end 区块的获取，获取到之后触发OnUnsafeL2Payload。\r\n● unsafeL2Payloads，收到新的L2块后，检查payload，不为空就push进EngineQueue。\r\n● l1HeadSig，即收到新的L1区块头，调用HandleNewL1HeadBlock\r\n判断是重复收到了当前的头，顺延下一个的头，旧的头，还是未来的头。后两者代表L1可能有re-org，或者我们中间漏了L1块。更新本地保存的l1 head，触发stepReqCh。\r\n● stepReqCh，控制derivition的流程。derivition是个流水线处理，有很多流程。它通过DerivationPipeline.Step来驱动。它进而调用EngineQueueStage.Step （op-node/rollup/derive/eigien_queue.go）整个过程比较复杂，可以参考https://learnblockchain.cn/article/6758。\r\n    a. 如果L1 reorg导致reset，调用tryUpdateEngine。\r\n    b. 检查queue里是否有新的unsafePayload,也就是L2是否有收到新块。有的话就先处理，这样可以获取到最新的L2块。如果所有检查都通过，更新unsafeHead为该L2新块。并Sync这个新的L2块，return\r\n    c. ...不深究了，看文档吧\r\n● 其他一些对新的L1块头发现，L1块的finalized等事件，都跟derivition有关。\r\n\r\n注意：\r\n● 如果当前节点是sequencer，如果safeL2Head + sequencer.max-safe-lag < UnsafeL2Head，也就是当前节点derivition的L1的L2块头比当前最新L2块头的距离太远，那么sequencer就停止L2出块，等待derivition。默认sequencer.max-safe-lag=0，也就是两个必须一致才出块。\r\n# 7.    L2区块生产\r\nL2区块的生产在op-geth里，但是它是由op-node驱动的。详见6. op-node的事件循环里，会不停的判断derivition的情况和出块情况，当合适出块的时候，就会调用op-geth的client来通知geth出块。这由RunNextSequencerAction驱动。会调用StartBuildingBlock来开始一个块的创建，这里实际会创建一个payload然后再让geth根据这个payload创建区块。\r\n\r\n。。。\r\n没空了，可能就不再写下去了\r\n\r\n​"},"author":{"user":"https://learnblockchain.cn/people/13806","address":null},"history":null,"timestamp":1709956358,"version":1}