作者:joohhnnn
optimism中区块的传递
区块的传递是整个 optimism rollup 系统中较为重要的概念,在这一章节,我们将从介绍 optimism 中多种 sync 方式的原理,来揭开整个系统里区块的传递过程。
区块类型
在进行进一步深入前,让我们了解一些基本的概念。
Unsafe L2 Block (不安全的 L2 区块):
这是指 L1 链上最高的 L2 区块,其 L1 起源是规范 L1 链的 可能 扩展(如 op-node 所知)。这意味着,尽管该区块链接到 L1 链,但其完整性和正确性尚未得到充分验证。
Safe L2 Block (安全的 L2 区块):
这是指 L1 链上最高的 L2 区块,其 epoch 的序列窗口在规范的 L1 链中是完整的(如 op-node 所知)。这意味着该区块的所有前提条件都已在 L1 链上得到验证,因此它被认为是安全的。
Finalized L2 Block (定稿的 L2 区块):
这是指已知完全源自定稿 L1 区块数据的 L2 区块。这意味着该区块不仅安全,而且已根据 L1 链的数据完全确认,不会再发生更改。
sync 类型
op-node p2p gossip 同步:
op-node 通过 p2p gossip 协议接收最新的不安全区块,由 sequencer 推送的。
op-node 基于 libp2p 的请求-响应的逆向区块头同步:
通过此同步方式,op-node 可以填补不安全区块的任何缺口。
执行层(EL,又名 engine sync)同步:
在 op-node 中有两个标志,允许来自 gossip 的不安全区块触发引擎中向这些区块的长范围同步。相关的标志是
--l2.engine-sync
和--l2.skip-sync-start-check
(用于处理非常旧的安全区块)。然后,如果为此设置了 EL,它可以执行任何同步,例如 snap-sync(需要 op-geth p2p 连接等,并且需要从某些节点进行同步)。op-node RPC 同步:
这是一种基于可信 RPC 方法的同步,当 L1 出现问题时,这种同步方式相对简单。
L2EngineSyncEnabled Flag (
l2.engine-sync
):该标志用于启用或禁用执行引擎的 P2P 同步功能。当设置为
true
时,它允许执行引擎通过 P2P 网络与其他节点同步区块数据。它的默认值是false
,意味着在默认情况下,该 P2P 同步功能是禁用的。SkipSyncStartCheck Flag (
l2.skip-sync-start-check
):该标志用于在确定同步起始点时,跳过对不安全 L2 区块的 L1 起源一致性的合理性检查。当设置为
true
时,它会推迟 L1 起源的验证。如果你正在使用l2.engine-sync
,建议启用此标志来跳过初始的一致性检查。它的默认值是false
,意味着在默认情况下,该合理性检查是启用的。
op-node p2p gossip 同步
这种同步的场景处于:当 l2 的块新产生的时候,即在上一节我们讨论的 sequencer 模式下是如何产生新的区块的。
当产生新的区块后,sequencer 通过基于 libp2p 的 P2P 网络的 pub/sub(广播/订阅)模块,向’新 unsafe 区块‘ topic 发出广播。所有订阅了此 topic 的节点都会直接或间接的收到这一广播消息。详情可以查看[2]
op-node 基于 libp2p 的请求-响应的逆向区块头同步
这种同步的场景处于:当节点因为特殊情况,比如宕机后重新链接,可能会产生一些没有同步上的区块(gaps)
当这种情况出现的时候,可以通过 p2p 网络的反向链的方式快速同步,即通过使用 libp2p 原生的 stream 流来和其他 p2p 节点建立链接,同时发送同步请求。详情可以查看[3]
执行层(EL,又名 engine sync)同步
这种同步的场景处于:当有较多区块,一个大范围区块需要同步的时候,从 l1 慢慢派生比较慢,想要快速同步。
使用--l2.engine-sync
和 --l2.skip-sync-start-check
去启动 op-node,发送的 payload 来达到发送长范围同步请求的目的。
代码层讲解
首先我们来看一下这两个标志的定义
在 op-node/flags/flags.go
中定义并解释了这两个 flag 的作用
L2EngineSyncEnabled=&cli.BoolFlag{
Name:"l2.engine-sync",
Usage:"EnablesordisablesexecutionengineP2Psync",
EnvVars:prefixEnvVars("L2_ENGINE_SYNC_ENABLED"),
Required:false,
Value:false,
}
SkipSyncStartCheck=&cli.BoolFlag{
Name:"l2.skip-sync-start-check",
Usage:"SkipsanitycheckofconsistencyofL1originsoftheunsafeL2blockswhendeterminingthesync-startingpoint."+
"ThisdeferstheL1-originverification,andisrecommendedtouseinwhenutilizingl2.engine-sync",
EnvVars:prefixEnvVars("L2_SKIP_SYNC_START_CHECK"),
Required:false,
Value:false,
}
L2EngineSyncEnabled
L2EngineSyncEnabled
标志用于在 op-node 接收到新的unsafe
的 payload(区块)后,发送给 op-geth 进一步验证时,触发 op-geth 的 p2p 之间 sync,在 sync 期间所有的unsafe
区块都会被视为通过验证,并进行下一个 unsafe 的流程。op-geth 内部的 p2p sync 比较适用于长范围的unsafe
区块的获取。其实在 op-geth 内部,不管L2EngineSyncEnabled
标志有没有启用,在遇到 parent 区块不存在的时候,都会开启 sync 去同步数据。
让我们深入代码层面看一下
首先是 op-node/rollup/derive/engine_queue.go
EngineSync
为L2EngineSyncEnabled
标志的具体表达。在这里嵌套在两个检查函数当中。
//checkNewPayloadStatuschecksreturnedstatusofengine_newPayloadV1requestfornextunsafepayload.
//Itreturnstrueifthestatusisacceptable.
func(eq*EngineQueue)checkNewPayloadStatus(statuseth.ExecutePayloadStatus)bool{
ifeq.syncCfg.EngineSync{
//AllowSYNCINGandACCEPTEDifengineP2Psyncisenabled
returnstatus==eth.ExecutionValid||status==eth.ExecutionSyncing||status==eth.ExecutionAccepted
}
returnstatus==eth.ExecutionValid
}
//checkForkchoiceUpdatedStatuschecksreturnedstatusofengine_forkchoiceUpdatedV1requestfornextunsafepayload.
//Itreturnstrueifthestatusisacceptable.
func(eq*EngineQueue)checkForkchoiceUpdatedStatus(statuseth.ExecutePayloadStatus)bool{
ifeq.syncCfg.EngineSync{
//AllowSYNCINGifengineP2Psyncisenabled
returnstatus==eth.ExecutionValid||status==eth.ExecutionSyncing
}
returnstatus==eth.ExecutionValid
}
让我们把视角转到 op-geth 的 eth/catalyst/api.go
当中,当 parent 区块缺失后,触发 sync,并且返回SYNCING Status
func(api*ConsensusAPI)newPayload(paramsengine.ExecutableData)(engine.PayloadStatusV1,error){
…
//Iftheparentismissing,we-intheory-couldtriggerasync,butthat
//wouldalsoentailareorg.Thatisproblematicifmultiplesiblingblocks
//arebeingfedtous,andevenmoreso,ifsomesemi-distantuncleshortens
//ourlivechain.Assuch,payloadexecutionwillnotpermitreorgsandthus
//willnottriggerasynccycle.Thatisfinethough,ifwegetaforkchoice
//updateafterlegitpayloadexecutions.
parent:=api.eth.BlockChain().GetBlock(block.ParentHash(),block.NumberU64()-1)
ifparent==nil{
returnapi.delayPayloadImport(block)
}
…
}
func(api*ConsensusAPI)delayPayloadImport(block*types.Block)(engine.PayloadStatusV1,error){
…
iferr:=api.eth.Downloader().BeaconExtend(api.eth.SyncMode(),block.Header());err==nil{
log.Debug("Payloadacceptedforsyncextension","number",block.NumberU64(),"hash",block.Hash())
returnengine.PayloadStatusV1{Status:engine.SYNCING},nil
}
…
}
SkipSyncStartCheck
SkipSyncStartCheck
这个标识符主要是帮助在选择 sync 模式下,优化性能和减少不必要的检查。在已确认找到一个符合条件的 L2 块后,代码会跳过进一步的健全性检查,以加速同步或其他后续处理。这是一种优化手段,用于在确定性高的情况下快速地进行操作。
在op-node/rollup/sync/start.go
目录中
FindL2Heads
函数通过从给定的“开始”(start)点(即之前的不安全 L2 区块)开始逐步回溯,来查找这三种类型的区块。在回溯过程中,该函数会检查各个 L2 区块的 L1 源是否与已知的 L1 规范链匹配,以及是否符合其他一些条件和检查。这允许函数更快地确定 L2 的“安全”头部,从而可能加速整个同步过程。
funcFindL2Heads(ctxcontext.Context,cfg*rollup.Config,l1L1Chain,l2L2Chain,lgrlog.Logger,syncCfg*Config)(result*FindHeadsResult,errerror){
…
for{
…
ifsyncCfg.SkipSyncStartCheck&&highestL2WithCanonicalL1Origin.Hash==n.Hash{
lgr.Info("FoundhighestL2blockwithcanonicalL1origin.Skipfurthersanitycheckandjumptothesafehead")
n=result.Safe
continue
}
//PullL2parentfornextiteration
parent,err:=l2.L2BlockRefByHash(ctx,n.ParentHash)
iferr!=nil{
returnnil,fmt.Errorf("failedtofetchL2blockbyhash%v:%w",n.ParentHash,err)
}
//ChecktheL1originrelation
ifparent.L1Origin!=n.L1Origin{
//sanitycheckthattheL1originblocknumberiscoherent
ifparent.L1Origin.Number+1!=n.L1Origin.Number{
returnnil,fmt.Errorf("l2parent%sof%shasL1origin%sthatisnotbefore%s",parent,n,parent.L1Origin,n.L1Origin)
}
//sanitycheckthatthelatersequencenumberis0,ifitchangedbetweentheL2blocks
ifn.SequenceNumber!=0{
returnnil,fmt.Errorf("l2block%shasparent%swithdifferentL1origin%s,butnon-zerosequencenumber%d",n,parent,parent.L1Origin,n.SequenceNumber)
}
//iftheL1originisknowntobecanonical,thentheparentmustbetoo
ifl1Block.Hash==n.L1Origin.Hash&&l1Block.ParentHash!=parent.L1Origin.Hash{
returnnil,fmt.Errorf("parentL2block%shasorigin%sbutexpected%s",parent,parent.L1Origin,l1Block.ParentHash)
}
}else{
ifparent.SequenceNumber+1!=n.SequenceNumber{
returnnil,fmt.Errorf("sequencenumberinconsistency%d<>%dbetweenl2blocks%sand%s",parent.SequenceNumber,n.SequenceNumber,parent,n)
}
}
n=parent
//oncewefoundtheblockatseqnr0thatismorethanafullseqwindowbehindthecommonchainpost-reorg,thenusetheparentblockassafehead.
ifready{
result.Safe=n
returnresult,nil
}
}
}
op-node RPC 同步
这种同步场景处于: 当你有信任的 l2 rpc 节点的时候,我们可以直接和 rpc 通信,发送较短范围的同步请求,和 2 类似。如果设置,在反向链同步中会优先使用 RPC 而不是 P2P 同步。
关键代码
op-node/node/node.go
初始化 rpcSync,如果 rpcSyncClient 设置,赋值给 rpcSync
func(n*OpNode)initRPCSync(ctxcontext.Context,cfg*Config)error{
rpcSyncClient,rpcCfg,err:=cfg.L2Sync.Setup(ctx,n.log,&cfg.Rollup)
iferr!=nil{
returnfmt.Errorf("failedtosetupL2execution-engineRPCclientforbackupsync:%w",err)
}
ifrpcSyncClient==nil{//ifnoRPCclientisconfiguredtosyncfrom,thendon'taddtheRPCsyncclient
returnnil
}
syncClient,err:=sources.NewSyncClient(n.OnUnsafeL2Payload,rpcSyncClient,n.log,n.metrics.L2SourceCache,rpcCfg)
iferr!=nil{
returnfmt.Errorf("failedtocreatesyncclient:%w",err)
}
n.rpcSync=syncClient
returnnil
}
启动 node,如果 rpcSync 非空,开启rpcSync eventloop
func(n*OpNode)Start(ctxcontext.Context)error{
n.log.Info("Startingexecutionenginedriver")
//startdrivingengine:syncblocksbyderivingthemfromL1anddrivingthemintotheengine
iferr:=n.l2Driver.Start();err!=nil{
n.log.Error("Couldnotstartarollupnode","err",err)
returnerr
}
//Ifthebackupunsafesyncclientisenabled,startitseventloop
ifn.rpcSync!=nil{
iferr:=n.rpcSync.Start();err!=nil{
n.log.Error("Couldnotstartthebackupsyncclient","err",err)
returnerr
}
n.log.Info("StartedL2-RPCsyncservice")
}
returnnil
}
op-node/sources/sync_client.go
一旦接收到s.requests
通道里的信号后(区块号),调用fetchUnsafeBlockFromRpc
函数从 RPC 节点中获取相应的区块信息。
//eventLoopisthemaineventloopforthesyncclient.
func(s*SyncClient)eventLoop(){
defers.wg.Done()
s.log.Info("Startingsyncclienteventloop")
backoffStrategy:=&retry.ExponentialStrategy{
Min:1000*time.Millisecond,
Max:20_000*time.Millisecond,
MaxJitter:250*time.Millisecond,
}
for{
select{
case<-s.resCtx.Done():
s.log.Debug("ShuttingdownRPCsyncworker")
return
casereqNum:=<-s.requests:
_,err:=retry.Do(s.resCtx,5,backoffStrategy,func()(interface{},error){
//Limitthemaximumtimeforfetchingpayloads
ctx,cancel:=context.WithTimeout(s.resCtx,time.Second*10)
defercancel()
//Weareonlyfetchingoneblockatatimehere.
returnnil,s.fetchUnsafeBlockFromRpc(ctx,reqNum)
})
iferr!=nil{
iferr==s.resCtx.Err(){
return
}
s.log.Error("failedsyncingL2blockviaRPC","err",err,"num",reqNum)
//Rescheduleatendofqueue
select{
cases.requests<-reqNum:
default:
//dropsyncingjobifwearetoobusywithsyncjobsalready.
}
}
}
}
}
接下来我们来看看从哪里往s.requests
通道发送信号的呢?
同文件下的RequestL2Range
函数,此函数介绍一个需要同步的区块范围,然后将任务通过 for 循环,分别发送出去。
func(s*SyncClient)RequestL2Range(ctxcontext.Context,start,endeth.L2BlockRef)error{
//Drainpreviousrequestsnowthatwehavenewinformation
forlen(s.requests)>0{
select{//incaserequestsisbeingreadatthesametime,don'tblockondrainingit.
case<-s.requests:
default:
break
}
}
endNum:=end.Number
ifend==(eth.L2BlockRef{}){
n,err:=s.rollupCfg.TargetBlockNumber(uint64(time.Now().Unix()))
iferr!=nil{
returnerr
}
ifn<=start.Number{
returnnil
}
endNum=n
}
//TODO(CLI-3635):optimizetheby-rangefetchingwiththeEngineAPIpayloads-by-rangemethod.
s.log.Info("SchedulingtofetchtrailingmissingpayloadsfrombackupRPC","start",start,"end",endNum,"size",endNum-start.Number-1)
fori:=start.Number+1;i<endNum;i++{
select{
cases.requests<-i:
case<-ctx.Done():
returnctx.Err()
}
}
returnnil
}
在外层的 OpNode 类型的RequestL2Range
实现方法里。可以清楚的看到rpcSync
类型的反向链同步是优先选择的。
func(n*OpNode)RequestL2Range(ctxcontext.Context,start,endeth.L2BlockRef)error{
ifn.rpcSync!=nil{
returnn.rpcSync.RequestL2Range(ctx,start,end)
}
ifn.p2pNode!=nil&&n.p2pNode.AltSyncEnabled(){
ifunixTimeStale(start.Time,12*time.Hour){
n.log.Debug("ignoringrequesttosyncL2range,timestampistoooldforp2p","start",start,"end",end,"start_time",start.Time)
returnnil
}
returnn.p2pNode.RequestL2Range(ctx,start,end)
}
n.log.Debug("ignoringrequesttosyncL2range,nosyncmethodavailable","start",start,"end",end)
returnnil
}
总结
理解了这些同步方式后,我们知道了unsafe的payload
(区块)究竟是怎么进行传递的。不同的 sync 模块对应着在不同场景下的区块数据传递。那么整个网络中如何一步步的将unsafe
的区块变成safe
区块,然后再进行 finalized 的呢?这些内容会在其他章节进行讲解。