从理论到实践:解析以太坊Rollup实现抗审查交易的机制

转载
19 天前
1284
极客 Web3

文章转载来源: 极客 Web3

作者:NIC Lin,Taipei Ethereum Meetup负责人

原文标题:《Rollup的Force Inclusion機制介紹》

就在昨天发生了一起震惊无数人的事情:由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交易