原文标题:《Rollup 的 Force Inclusion 机制介绍》
作者:NIC Lin,Taipei Ethereum Meetup 负责人
就在昨天发生了一起震惊无数人的事情:由Metamask母公司Consensys推出的以太坊二层Linea主动停机了,官方称这么做的目的是为了降低Velocore黑客攻击事件的影响。而这不由得让人想起之前BSC链(BNB Chain)为了降低黑客攻击的损失,在官方主动协调下停机一事。每当人们谈论起这种事情,都会对Web3倡导的去中心化价值感到怀疑。
当然,上述事件发生的核心原因,更多在于基础设施本身的不完善,即不够去中心化:如果一条链足够去中心化,那么就不该说停就停。由于以太坊二层的独特构造,大多数Layer2都依赖于中心化的Sequencer,虽然近些年去中心化排序器的论调越来越多,但考虑到二层的存在目的及其结构,我们大可以认为,Layer2的排序器大概率不会有多去中心化,最后可能还比不上BSC链的去中心化程度。如果事实真的如此,那么我们该怎么办?
其实对于二层而言,排序器不去中心化带来的最直接危害,在于抗审查性和活性。如果处理交易的实体(Sequencer)很少,那么它在是否为你服务这件事上就掌握了绝对权力:想拒绝你就拒绝你,而你可能没有办法。如何解决Layer2的抗审查问题,显然是一个重要的话题。
在过去的数年中,各大以太坊二层针对抗审查问题提出了各种各样的解决方案,比如Loopring和Degate以及StarkEx的强制提款与逃生舱功能、Arbitrum及其他OP Rollup的Force Inclusion功能,这些方法都可以在一定条件下对Sequencer产生制衡,以防止其无端拒绝任意用户的交易请求。
在今天的文章中,来自台北以太坊协会的NIC Lin现身说法,亲自实验了4个主流Rollup的抗审查交易功能,从工作流程和操作方法等方面深入的分析了Force Inclusion的机制设计,这对于以太坊社区和手握巨额资产的大户而言尤其具有参考价值。
交易审查与Force Inclusion
交易抗审查性(Censorship Resistance)对一条区块链来说非常重要,如果区块链能够任意审查并拒绝用户发起的交易,那就和一个Web2服务器没有两样。以太坊目前的交易抗审查能力来自于它为数众多的Validator,如果有人想审查Bob的交易、不让他的交易上链,要么就尝试买通网络中大部分Validator,要不就Spam整个网路,不断送出手续费比Bob更高的垃圾交易来抢占区块空间。不管是哪种方式,成本都会非常高。
注:在Ethereum目前的PBS架构中,审查交易的成本会降低不少,可以参考配合OFAC审查Tornado Cash交易的区块比例。当前的抗审查能力仰赖在OFAC及政府管辖范围之外的独立验证者及Relay。
但Rollup呢?Rollup不需要一大堆的Validator来确保安全性,即便Rollup只有一个中心化的角色(Sequencer)来产出区块,它也和L1一样安全。但安全和抗审查能力是两回事,即便一个Rollup和以太坊一样安全,但在只有一个中心化Sequencer的情况下,想审查任何用户的交易都行。
Sequencer可以拒绝处理用户的交易,导致用户资金被扣留无法离开该Rollup
Force Inclusion机制
与其要求Rollup有大量的去中心化的Sequencer,还不如直接利用L1的抗审查能力:
本来Sequencer就是要将交易数据打包送到L1的Rollup合约中,不如在合约里加入一个设计,让用户可以自行把交易插入到Rollup合约,这个机制就称为“Force Inclusion”。只要Sequencer没办法在L1层面审查用户,它就没法阻止用户在L1强制插入交易。这样一来,Rollup就可以继承L1的抗审查能力。
Sequencer无法审查使用者的L1交易,除非付出很高的成本
强制交易应该怎么生效?
如果允许通过Force Inclusion把交易直接写入到Rollup合约中(也就是立即生效),那Rollup的状态就会马上改变,例如Bob透过Force Inclusion机制插入一笔“转1000 DAI给Carol”的交易,如果交易立即生效,那最新的状态中Bob的余额会少1000 DAI,Carol会多1000 DAI。
如果Force Inclusion能直接把交易写进Rollup合约中并马上生效,那状态就会马上改变
如果此时Sequencer也在链下收集交易,并把下一批交易送到Rollup合约上,就有可能被Bob强制插入并立即生效的交易给影响到。这种问题要极力避免,因此Rollup一般不会让Force Inclusion交易立即生效,而是先让用户把交易插入到L1上的等待队列中,进入“准备中”状态。
Sequencer在把链下交易打包送上Rollup合约时,选择是否在交易序列里塞入前述交易,如果Sequencer一直无视这些处于“准备中”状态的交易,等窗口期结束后,用户可以把这些交易强制插入到Rollup合约中。
Sequencer可以决定在什么时候“顺便收入”等待队列中的交易
Sequencer还是可以拒绝处理等待队列中的交易
如果Sequencer长期拒绝,一段时间后任何人都可以通过Force Inclusion功能把交易强行插入到Rollup合约中
接下来我们将依序介绍Optimism、Arbitrum、StarkNet及zkSync等四个较有名的Rollup的Force Inclusion机制实现。
Optimism的Force Inclusion机制
首先介绍Optimism的Deposit流程,这个Deposit不单是指把钱存进Optimism,还包括“把用户向L2发送的信息”送进L2。L2节点在收到新存入的消息后,会将消息转换成一笔L2交易去执行,送到消息指定的接收方。
使用者从 L1 Deposit 给 L2 的消息
L1CrossDomainMessenger合约
当一个用户要把ETH或ERC-20代币存进Optimism时,他会通过前端网页和L1上的L1StandardBridge合约互动,指定要存多少金额以及由哪个L2地址接收这些资产。
L1StandardBridge合约会将消息传递至下一层的L1CrossDomainMessenger合约,这个合约主要作为L1与L2之间互相通讯的组件,L1StandardBridge便通过这个通用的通讯组件和L2上的L2StandardBridge交流,决定谁可以在L2铸造代币,或是谁可以从L1解锁代币。
如果开发者需要开发一个在L1与L2之间互通、同步状态的合约,那他就可以搭建在L1CrossDomainMessenger合约之上。
使用者的消息透过CrossDomainMessenger合约从L1传递到L2
注:本文的部分图片中将CrossDomainMessager写成了CrossChainMessager
OptimismPortal合约
L1CrossDomainMessenger合约会再将消息送至最底层的OptimismPortal合约,OptimismPortal合约处理完后会抛出一个名为TransactionDeposited的事件,参数包含“发消息的人”、“收消息的人”,以及相关的执行参数。
接著L2的Optimism节点会监听OptimismPortal合约抛出的Transaction Deposited事件,并把event里的参数转换为一笔L2交易,这个交易的发起者会是Transaction Deposited事件参数里指明的“发消息的人”,交易接收者就是事件参数里“收消息的人”,其他交易参数也是由上述事件中的参数而来。
L2节点会将OptimismPortalemit的Transaction Deposited事件参数转换成一笔L2交易
例如,这是某个用户透过L1StandardBridge合约存款0.01ETH的交易,这个消息及ETH一路传到OptimismPortal合约(地址是0xbEb5…06Ed),然后几分钟后被转换成L2交易:
消息发起者是L1CrossDomainMessenger合约;接收者是L2上的L2CrossDomainMessenger合约;消息内容是L1StandardBridge收到了BoB的0.01ETH存款。这之后还会触发一些流程,比如为L2StandardBridge增发0.01枚ETH,再由后者转给Bob。
具体怎么触发
当你想把交易强制收纳进Optimism的Rollup合约中时,你要达到的效果是让一笔“从你的L2地址在L2上发起并要执行的交易”能顺利执行,这时你应该用自己的L2地址把消息直接提交给OptimismPortal合约(注意OptimismPortal合约其实在L1上,但OP的地址格式和L1地址格式一致,你直接用和L2账户相同地址的L1账户调用上述合约即可)。
之后该合约抛出的Transaction Deposited事件转化的L2交易的“发起者”,才会是你的L2账户,此时交易格式和正常的L2交易一致。
从Transaction Deposited事件转换而成的L2交易中,发起人会是Bob自己;接收人是Uniswap合约;而且会附带指定的ETH,就像Bob自己发起L2交易一样
如果要调用Optimism的Force Inclusion功能,你要直接调用OptimismPortal合约的depositTransaction函数,将你想在L2执行的交易的参数填入
我做了一个简单的Force Inclusion实验,这条交易想达成这样一件事:在L2上用我的地址自转账(0xeDc1…6909),并附带一个“force inclusion”的文字讯息。
这是我透过OptimismPortal合约执行depositTransaction函数的L1交易,可以看到在其抛出的Transaction Deposited事件中,from和to都是我自己
剩下的opaque Data一栏里的值则编码了“调用deposit Transaction函数的人附带了多少ETH”、“L2交易发起者要把多少ETH发给接收者”、“L2交易GasLimit”及“给L2接收者的Data”等等信息。
将上述信息解码后分别会得到:
“调用deposit Transaction的人附加了多少ETH”:0,因为我并不是从L1存ETH到L2;
“L2交易发起者要把多少ETH发给接收者”:5566(wei)
“L2交易的GasLimit”:50000
“给L2接收者的Data”:0x666f72636520696e636c7573696f6e,也就是“force inclusion”这个字串的16进制编码
接着没多久就出现转换后的L2交易:一笔我转钱给自己的L2交易,金额是5566 wei,Data是“force inclusion”字串。而且可以注意到,在图中倒数第二行的Other Attributes中的TxnType(交易类型),显示是系统交易126(System),表示这笔交易不是我自己在L2发起的,是由L1交易的Deposited事件转换而来。
转换而成的L2交易
如果你要通过Force Inclusion调用L2合约、发送不同的Data,那无非就是将参数一一填入前面的deposit Transaction函数,只是要记得,要用和自己L2账户相同的L1地址去调用deposit Transaction函数,这样当Deposited Event转化为L2交易时,发起者就是你的L2账户。
SequencerWindow
前面提到的Optimism L2节点将Transaction Deposited事件转换成L2交易,其实这个Optimism节点指的是Sequencer,毕竟这关系到交易排序,所以只有Sequencer可以决定何时要将前述事件转换成L2交易。
在监听到TransactionDeposited事件时,Sequencer并不一定会马上将event转换成L2交易,可以有一段延时,这段时间的最大值称为SequencerWindow。
目前Optimism主网上的Sequencer Window为24小时,也就是当用户从L1存入一笔钱或Force Inclusion一条交易,最糟情况是24小时后才被收入到L2交易历史中。
Arbitrum的Force Inclusion机制
在Optimism中L1的Deposit操作会抛出一个Transaction Deposited事件,剩下的就是等待Sequencer收录上述操作;但在Arbitrum中发生于L1的操作(存钱或传消息给L2等)会被存在L1上的一个队列里,而不是单纯抛出个事件。
Sequencer会被给予一段时间将上述队列里的交易纳入L2交易历史,如果时间到了Sequencer都没有作为,那任何人都可以去替Sequencer完成。
Arbitrum会在L1合约维护一个Queue,如果Sequencer没有主动处理Queue里的交易,时间到了任何人都可以把Queue里的交易强制收录到L2交易历史中
Arbitrum的设计中,L1上发生的如存款等操作都要经由Delayed Inbox合约,顾名思义这里的操作都会延迟生效;另一个合约则是Sequencer Inbox,是Sequencer把L2交易上传到L1时的直接场所。每次Sequencer上传L2交易时,都可以顺便从Delayed Inbox取出一些待处理的交易一并写进交易历史中。
Sequencer写入新交易时可以顺便从DelayedInbox拿出交易一起写入
复杂的设计以及凡善可陈的参考资料
如果读者直接参考Arbitrum官方关于Sequencer及Force Inclusion的章节,会看到里面提到了Force Inclusion大致如何运作,以及一些参数名称和函数名称:
使用者先去DelayedInbox合约调用sendUnsignedTransaction函数,如果Sequencer没在约24小时内收录,那使用者可以调用SequencerInbox合约的forceInclusion函数。然后Arbitrum官方也没把函数的链接附加在官网文档里,只能自己去看合约代码里相对应的函数。
当找到sendUnsignedTransaction函数后,你发现竟然要自己填nonce值还有maxFeePerGas值。是哪个地址的nonce?是哪个网络上的maxFeePerGas?要怎么填比较好?没有文件参考,连Natpsec都没有。然后你还会在Arbitrum合约里发现一堆看着相似的函数:
sendL1FundedUnsignedTransaction、sendUnsignedTransactionToFork、sendContractTransaction、sendL1FundedContractTransaction,一样没有文件告诉你这些函数的区别、该怎么用、参数该怎么填,连Natpsec都没有。
你抱著姑且一试的心态来试填参数并送出交易,想用试错的方式看能不能找出正确的用法,但发现这些函数全都会把你的L1地址做AddressAliasing,导致最终在L2上发起交易时的Sender根本是不一样的地址,于是你的L2地址一动不动。
sendL2Message
后来偶然点开Google搜索,才发现原来Arbitrum自己有一个Tutorial程式库,里面有脚本示范怎么从L1发送L2交易(也就是Force Inclusion的意思),然后它列举的函数完全不是上面提到的任何一个,而是一个叫sendL2Message的函数,而且message参数要带入的竟然是用L2账户签完名的交易?
谁会知道要“通过Force Inclusion送给L2的消息”竟然会是一笔“签完名的L2交易”?而且没有任何文件及Natspec解释什么时候用及如何使用这个函数。
结论:要手动产生一个Arbitrum的强制交易比较麻烦,建议就照著官方Tutorial跑Arbitrum SDK呗。Arbitrum不像其他Rollup有清楚的开发者文件及程式码附注,许多函数的用途和参数缺乏说明,导致开发者得花费比预期多更多的时间来接入和使用。我也在Arbitrum Discord上询问Arbitrum的人,但并没有得到令人满意的答案。
在Discord上询问,对方也只会叫我去看sendL2Message,没有想要解释其他函数的功能(甚至是Force Inclusion文档里提到的sendUnsignedTransaction)是什么用途、怎么用、什么时候用。
StarkNet的ForceInclusion机制
很遗憾地,StarkNet目前还没有ForceInclusion机制。只有两篇在官方论坛上讨论到Censorship及ForceInclusion的文章。
无法证明失败的交易
上述原因其实是因为,StarkNet的零知识证明系统没办法证明一笔失败的交易,所以不能允许Force Inclusion。因为如果有人恶意(或无意)Force Include一笔失败的、无法被证明的交易,那StarkNet就会直接卡住:因为交易被强制收入后,Prover就必须证明该笔失败交易,但它却没办法证明。
而StarkNet预期在v0.15.0版引入证明失败交易的功能,之后应该就可以进一步实现Force Inclusion机制。
zkSync的ForceInclusion机制
zkSync的L1->L2讯息传送以及Force Inclusion机制,都是透过MailBox合约的requestL2Transaction函数进行,使用者指定L2地址、calldata、附加的ETH数量、L2GasLimit值等,requestL2Transaction会将这些参数组合成一个L2交易,然后放进优先队列(PriorityQueue)中,Sequencer会在交易打包上传到L1时(通过commitBatches函数),说明要顺便从优先队列中拿出多少笔交易一起收录进L2交易记录中。
zkSync在Force Inclusion形式上和Optimism很像,都是以发起者的L2地址(与L1地址一致)去调用相关函数,并填入资料(被呼叫者、calldata等等),而不是像Arbitrum一样是填一笔签完名的L2交易;但在设计上则是和Arbitrum一样,都是在L1维护一个队列Queue,并由Sequencer从Queue中拿出用户直接提交的待处理交易,并写入交易历史中。
如果你透过zkSync的官方桥去Deposit ETH,像是这笔交易,它便是去呼叫MailBox合约的requestL2Transaction函数,它会将这个Deposit ETH的L2交易放进优先队列中抛出一个NewPriorityRequest事件。因为合约把L2交易资料编码成一串bytes字串所以不易读,改成看这笔L1交易的参数的话,会看到参数中L2的接收方也是交易的发起人(因为是Deposit给自己),所以过一阵子这笔L2交易被Sequeuncer从优先队列拿出,并收录进交易历史时,它会在L2上被转换成一笔自己转给自己的交易,而转帐的金额就是交易发起人在L1的Deposit ETH交易中带上的ETH金额。
L1Deposit交易中,交易发起者和接收者都是0xeDc1…6909,金额是0.03ETH,calldata为空
L2上会出现一笔0xeDc1…6909自己转帐给自己的交易,交易类型(TxnType)是255,也就是系统交易
接着我直接像之前实验OP的强制交易功能一样,调用zkSync的requestL2Transaction函数,发了一笔自转账:没有带任何ETH,calldata带入“force inclusion”字串的HEX编码。
接著它被转换成L2上一笔自己转自己的交易,calldata里是“force inclusion”的十六进制字串:0x666f72636520696e636c7573696f6e。
当Sequencer把交易从PriorityQueue拿出来并写进交易历史中,在L2上就会转换成相对应的L2交易
透过requestL2Transaction函式,使用者可以用和L2地址一样的L1账户,在L1提交资料,指定L2接收方、附带的ETH金额以及calldata。如果使用者要call其他合约、带不同Data,那一样就是将参数一一填入requestL2Transaction函数。
还没有让使用者强制收录的功能
虽然L2交易放到优先队列中后,会顺便计算出这笔L2交易被Sequencer收录的等待期限,但目前zkSync设计中并没有让使用者能强制执行的Force Inclusion函数,等于是只做半套。也就是虽然有“收录等待期限”,但实际上还是“看Sequencer要不要收入”:Sequencer可以等到过期后才收入,也可以永远不再收入优先队列中任何交易。
未来zkSync应该要加入相关函数,让使用者可以在收入有效期过了但都还没被Sequeuncer收录时,能强制把交易包含进L2交易历史,如此才是真正有效的Force Inclusion机制。
总结
L1靠为数众多的验证者们来确保网路的“安全性”及“抗审查能力”,Rollup因为都是由少数甚至单一的Sequencer来写入交易,抗审查能力更弱。因此Rollup需要有Force Inclusion机制来让使用者可以绕过Sequencer,将交易写入历史中,避免被Sequencer审查导致无法使用也无法把资金撤离该Rollup。
Force Inclusion让使用者可以强制将交易写入历史中,但在设计上需在“交易是否能立即插入历史、立即生效”上做选择。如果允许交易立即生效,那就会对Sequencer产生负面影响,因为L2上等待被收入的交易都可能会被L1强制收入的交易所影响。
因此目前Rollup的Force Inclusion机制都会先让L1上插入的交易进入等待状态,并让Sequencer有一段时间窗口来反应、来选择要不要收入这些等待中的交易。
zkSync和Arbitrum都是在L1维护一个队列Queue,用来管理使用者从L1送出的L2交易或给L2的讯息。Arbitrum称为DelayedInbox;zkSync称为PriorityQueue
但zkSync送出L2交易的方式和Optimism比较像,都是以L2地址去L1上发送消息,如此转换为L2交易后,其发起人才会是该L2地址。Optimism送L2交易的函数称为depositTransaction;zkSync称为requestL2Transaction。而Arbitrum则是生成一笔完整的L2交易并签名,然后透过sendL2Message函数送出,Arbitrum在L2上会透过签名还原签名者来作为L2交易的发起人。
StarkNet目前还没有Force Inclusion机制;zkSync则是像做了半套的Force Inclusion,—有PriorityQueue且每个Queue里的L2交易都有收录有效期限,但这个有效期限目前只是装饰用,实际上Sequencer可以选择完全不收入任何PriorityQueue里的L2交易