Rollup Bridge 介绍(七):Optimism 原生桥
来源:    发布时间: 2023-12-28 17:15   45 次浏览   大小:  16px  14px  12px
这个系列一开始从跨 Rollup 桥开始介绍,直到第七篇才回过头来介绍 Rollup 本身提供的原生桥,还请读者见谅。

这个系列一开始从跨 Rollup 桥开始介绍,直到第七篇才回过头来介绍 Rollup 本身提供的原生桥,还请读者见谅。

Rollup 原生桥是去中心化跨 Rollup 桥例如 Hop Protocol 的基石,没有 Rollup 原生桥来协助在 L1 与 L2 之间传递信息,这种跨 Rollup 桥就没办法可信的将跨 Rollup 的转账信息从源头 Rollup 传递到目标 Rollup。没办法可信的传递信息就表示使用者要仰赖、相信第三方来传递信息,如此就和一般跨 L1 桥的安全性无异,即便这个第三方是一个多签。

先备知识包含:

  • Ethereum Transaction

  • Solidity、contract call、msg.sender 的概念

  • Optimistic Rollup

Recap: L1 对 Rollup 的重要性

在介绍 Rollup 原生桥之前,先快速复习一下 Rollup 以及 L1 对它的重要性。

Rollup 把交易资料送到链上,但把交易执行搬到链下,执行完后再把结果(State Root)丢回链上。在 Optimistic Rollup 中,都先默认 State Root 是正确的,如果发现错误再通过挑战机制移除错误的 State Root。在 ZK Rollup 中则是通过零知识证明来证明 State Root 是经过正确计算得来的。

但不管是 Optimistic Rollup 或是 ZK Rollup,这两者都需要 L1 来帮他们确保一件事:交易的顺序。Rollup 把交易资料送到链上就决定了这些交易的排序,只要 L1 没有被攻击、没有发生 re-org,交易的执行顺序就不会改变,执行结果也就一定不会改变。因此 Rollup 的安全性也等同于 L1 的安全性。

交易(蓝色区块)送到 L1 后,大家都能够以此交易顺序自行算出执行结果 state 2

交易记录(或更精确地说交易顺序)是最重要的,state(执行结果)是其次、是副产品。确定了交易顺序,自然能确定执行结果。

接下我们将进入到 Optimism 的协议里,先从最核心 — 确保交易顺序的元件 — Canonical Transaction Chain 介绍,接着是交易资料送到链上的方式、信息在 L1 与 Rollup 之间传递的方式、处理另一边传过来的信息的方式,最后是实际通过原生桥传送信息的例子。

Canonical Transaction Chain (CTC)

CTC 这个合约就是用来储存交易记录的。当交易一笔一笔提交到 CTC 上,就同时确定了交易彼此之间的顺序,这个顺序就决定了最新的状态。Optimism 上的交易会由 Sequencer 打包然后提交到 CTC 里。另外也有不经由 Sequencer 来送交易的管道,待会会提到。

注:Sequencer 通过 appendSequencerBatch 来打包交易送到 CTC。

另一个合约 State Commitment Chain 则是让人对每一笔交易去 propose 交易执行后的状态,例如交易前状态是我有 10 元,你有 5 元,当一笔我给你 5 元的交易执行后,状态会变成我有 5 元,你有 10 元。我可以提交一个假的状态说交易执行后变成我有 15 元,你有 0 元,但大家都会发现这是错的状态而去发起挑战,最后换上正确的状态。

注:任何人抵押后都可以通过 appendStateBatch来 propose 交易执行后的状态。

Bob propose 一个错的 state,任何人都可以去发起挑战,换上正确的 state

Sequencer and Censorship Resistance

目前 Rollup 几乎都是采用中心化 Sequencer 的方式,由项目方先担任唯一 Sequencer,未来再慢慢引入去中心化 Sequncer 的机制。但在这段过渡期我们只能相信 Sequencer 不会作恶,不会故意审查、屏蔽我们的交易吗?

注:我们不需要相信 Sequencer 会把钱卷走,因为当他 propose 一个错的状态时,马上就会被人发现是错的状态并去挑战,所以安全性是不需要担心的。但我们必须要担心 Sequencer 会故意忽略特定使用者的交易(Censorship Attack)。

Force Inclusion

Rollup 必须要有机制让使用者发现自己交易被屏蔽时,也能有手段强制让他的交易进到 L1 的交易记录中,让大家都看到他的交易,这个机制称作 Force Inclusion。在前一段我们有提到 Optimism 在 L1 的 CTC 合约就是用来储存交易记录的,那我们开放让使用者能够自己到 CTC 合约去插入交易不就可以让使用者绕过恶意 Sequencer,来达成 Force Inclusion?没错!就是这么做,但会有些地方需要注意。

我们让使用者可以自己把交易插入 CTC 的交易记录中

如果我们让使用者可以直接插入交易到队列中,就有可能被利用来攻击网络的节点,包含 Sequencer。想像你送一笔交易给 Sequencer 请他打包这笔交易,这笔交易是从你朋友 Bob 身上转 100 DAI 给你。Sequencer 收到这笔交易并检查状态,确认 Bob 身上真的有 100 DAI 且他也允许你这么做,所以 Sequencer 告诉你交易成功,这笔交易会被收入,于是你开心地继续准备接下去的交易……

Sequencer confirm 你的交易

但这时 Bob 的坏朋友 Alice 发现这笔交易,于是她直接在 L1 上插入她的交易,这笔交易是她从 Bob 身上转走 100 DAI 的合法交易(假设 Bob 是个慷慨的人)。因为 Sequencer 还没把你的交易打包送到 L1,这表示 Alice 已经插入 L1 的交易会发生在你的交易之前。

Alice 直接在 L1 将交易插入 CTC 里

因此当 Sequencer 发现 Alice 强制插入了这笔交易,Sequencer 必须要重新模拟一次他原本打包到一半的交易们,这时候他就会发现你的交易已经失效了(因为 Bob 的 DAI 已经被 Alice 转走),于是就会产生一连串骨牌效应导致你原本在准备的后续交易也全都失败或无效了。这样的攻击除了会造成使用者的麻烦,也导致即便是诚实的 Sequencer 也没办法确定交易的执行结果。

Sequencer 发现 Alice 的交易排在他本地端准备的交易之前,所以必须重新模拟交易执行

Alice 的交易导致其他交易失败或无效

Enqueue

所以虽然我们允许使用者可以绕过 Sequencer,自行插入交易,但我们不能让使用者插入的交易可以即时生效!所以这些交易会先被放在 CTC 的一个 queue 里(把它当作一个交易的候补队伍),交易被放进这个 queue 里代表不会马上生效,需要等待 Sequeuncer 指示。交易放进 queue 里让 Sequencer 先看见并有时间准备,在下一包交易中 Sequencer 就可以指定要额外处理 queue 里的交易。如此一来使用者有手段可以 Force Inclusion,Sequencer 也可以不被影响。

注:使用者可以通过 enqueue 来将交易放进 queue 里。Sequencer 在 appendSequencerBatch 时可以指定要额外处理 queue 里的交易

Alice 的交易不会直接生效,而是先放进 CTC 的 queue 里

Sequencer 送出下一包的交易时再顺便指定要处理几笔 CTC queue 里的交易

Where is the Force!?

你可能已经发现:如果还是让 Sequencer 自己决定要不要额外处理 queue 里的交易,那 Sequencer 不就还是可以故意不处理交易吗?没错!所以会需要为 queue 的交易加上限制,例如规定 Sequencer 一定要在交易被放进 queue 后的 XX 时间内处理,否则无法再收入交易,或是 Sequencer 每一包交易都要包含 XX 笔 queue 里的交易。如此一来就能确保交易真的能够被「Force」Inclusion。

如果 Sequencer 还是能自己决定要不要处理 CTC queue 里的交易,就没办法 Force Inclusion

其中一个方式:CTC 规定每一包交易一定要处理 XX 笔 queue 里的交易

但目前 Optimism 还没有提供这个功能,之前版本的 CTC 原本有提供 Force Inclusion 的功能(appendQueueBatch),但被拿掉了。这表示我们目前必须得相信 Sequencer 不会审查我们的交易。

以上是对 Optimism 的核心元件 — CTC 的介绍,接下来将介绍搭建在这之上的信息跨链功能。有了信息跨链,我们才能有代币跨链。

Cross-chain Message(信息跨链)

这里的 Cross-chain 指的是 L1 与 L2 之间的 Cross-chain。

L1 -> L2 Message

L1 到 L2 的跨链信息的核心和 Force Inclusion 一样,都是通过上面提到的 enqueue 方式来发送。

enqueue 里带的资料很简单:target 及 data,你可以把它比拟做 L1 交易要填的 to 及 data,只是是在 L2 的环境执行而已。但你不需自己去触发 enqueue 来发送 L1 -> L2 信息,Optimism 提供了一套合约来做跨链信息:CrossDomainMessenger 合约。CrossDomainMessenger 合约在 L1 及 L2 各有一个,你可以把它们想像成是 L1 及 L2 之间通讯的传送门。

L1 Messenger 将传送信息给 L2 Messenger,信息先放进 queue 里,系统再于 L2 解读出接收人和信息

如果你要送一个 L1 -> L2 信息,你要通过 L1 CrossDomainMessenger 合约的 sendMessage 函式,指定你信息要送给谁(target )及内容( data)。L1 CrossDomainMessenger 合约会封装你的信息,夹带在它自己传给 L2 CrossDomainMessenger 合约的信息里,如下图。L2 CrossDomainMessenger 合约收到 L1 CrossDomainMessenger 合约送来的信息后,会拆封对方封装的信息,去呼叫 target 并带上 data。

L1 Messenger 把使用者信息(橘色外框)封装在自己的信息(绿色外框)里,等到 L2 Messenger 收到信息后再拆封并去呼叫使用者指定的 target 合约

注意这边是由 L2CrossDomainMessenger 去触发 target,不是你,因为你在 L1 不在 L2。

跨链信息的 msg.sender

你可能会觉得奇怪,如果在 L2 上是由 L2 CrossDomainMessenger 合约来呼叫 Bob 的 L2 合约,那 Bob 的 L2 合约要怎么知道这信息的来源?有可能是 Alice 在 L1 送的信息,或是别人在 L1 送错信息,或是其实根本是 Carol 在 L2 去呼叫 Bob 的合约,它要怎么分辨?

这个就是 CrossDomainMessenger 要协助解决的问题:当 Bob L1 合约通过 L1 CrossDomainMessenger 合约送 L1 -> L2 信息时,L1 CrossDomainMessenger 合约会把「sender 是 Bob L1 合约」这个资讯附在 relayMessage 信息里,如此 L2 CrossDomainMessenger 合约收到信息时就可以知道原本发起 L1 -> L2 信息的人( sender)是 Bob L1 合约。想像刚刚图中橘色外框的信封里除了 target 和 data 外,L1 CrossDomainMessenger 合约另外在信封上加上 sender 的资讯。

L1 Messenger 为使用者讯息(橘色外框)附加上发送人资讯

L2 CrossDomainMessenger 合约在呼叫 Bob L2 合约之前会先把 sender 记录起来再去触发 Bob L2 合约,如此一来 Bob L2 合约就可以通过查询 L2 CrossDomainMessenger 合约的 xDomainMessageSender 变数来查询 L1 -> L2 信息的发起人是谁,如果不是 Bob L1 合约就终止交易,确保只有 Bob L1 合约可以成功送信息给 Bob L2 合约。

CrossDomainMessenger 合约记录相关资讯,确保信息接收方能验证信息来源

如果是 Carol 在 L2 去呼叫 Bob 合约,想伪装成 L1 -> L2 信息,则 Bob 合约只要检查来呼叫它的人(msg.sender)是 L2 CrossDomainMessenger 合约就好,因为 L2 CrossDomainMessenger 合约没办法在 L2 被操控,它只会接收从 L1 CrossDomainMessenger 合约送来的信息 。


L2 -> L1 Message

L2 -> L1 信息的流程基本上和 L1 -> L2 讯息一样,你可以看到 L1 和 L2 的 CrossDomainMessenger 合约长得很像:sendMessage、relayMessage、xDomainMessageSender……主要的差别就在 L2 -> L1 信息要等待挑战期过后才能完成 relay,且要附上 Merkle Proof 证明那笔 L2 -> L1 信息确实存在(L2 CrossDomainMessenger 在收到 L2 -> L1 信息时会把信息存起来,所以 Merkle Proof 会是用来证明 L2 CrossDomainMessenger 合约的 storage 里确实有这个 L2 -> L1 信息)。

L1 -> L2 信息和 L2 -> L1 信息流程其实差不多:在一端 send,在另一端 relay

以上是信息在 L1 及 L2 之间传递的方式,接下来会以实际的例子介绍使用方法。

代币跨链只是信息跨链的其中一种

不管是 L1 <-> L1 还是 L1 <-> L2 的代币跨链,代币都没办法真的从一条链移动到另一条链。常见的代币跨链方法是在 A 链上锁住代币,并在 B 链上铸造出新的代币(反过来则是先在 B 链上烧毁代币,并在 A 链上解锁代币)。如果我们往后退一步来看,「A 链通知 B 链去铸造出新的代币」这一个动作其实就是在 relay 一个跨链信息:「我这里锁住他的代币了,请在另一边铸造出新的代币给他」、「我在这里烧毁他的代币了,请在另一边解锁他的代币」。真正在移动的是信息,不是代币。

Optimism 的官方代币桥

Optimism 官方有提供 L1StandardBridge、L2StandardBridge 合约来提供代币跨链,但你也可以像 Synthetix 一样自己写一组代币桥合约,毕竟核心是信息的跨链,只要通过 CrossDomainMessenger 合约就能完成各种信息的跨链,包含代币,不需完全仰赖官方的代币桥(但当然你自己写的和官方的代币桥相比,可信度不同)。

L1StandardBridge.depositERC20

以官方桥的跨 ERC20 代币为例,首先 L1StandardBridge 合约在创建时要知道 L2StandardBridge 的地址,因为要告诉 L2StandardBridge 代币已经锁住,可以放心铸造对应的代币了。

使用者呼叫 L1StandardBridge 合约的 depositERC20 函式来启动代币跨链,函式里要指定 L1 代币地址、L2 代币地址及其他一些资讯,函式里面会把代币转到 L1StandardBridge 合约身上,并送出跨链信息到 L2StandardBridge 合约,并触发 L2StandardBridge 合约的 finalizeDeposit 函式。

L2StandardBridge 的 finalizeDeposit 函式里会检查跨链信息里提供的 L1 代币地址是不是符合 L2 代币合约所记录的 L1 地址。这表示 L2 代币合约要记录自己对应的 L1 代币合约。例如 Tether 如果部署代币合约到 Optimism 上,则它在部署时就要记录自己对应的 L1 USDT 地址。如果它 L1 代币地址不小心填成一个 L1 上的垃圾代币,那任何持有那个垃圾代币的人都可以通过代币桥把 L1 垃圾币换成 L2 的 USDT。

如果检查通过,finalizeDeposit 就会铸造出 L2 代币给接收者;如果检查没有通过,则 L2StandardBridge 会封装一个 L2 -> L1 信息 finalizeERC20Withdrawal,这个信息会指示 L1StandardBridge 把原先的 L1 代币解锁还给对方(不过要等挑战期结束才能 relay,完成解锁),也就是原路退还代币。

L2StandardBridge 检查通过则 mint 代币(4a),没通过则送信息回去 L1 解锁代币(4b)

L2StandardBridge.withdraw

  • 代币从 L2 回到 L1 的流程基本上就和 L1 到 L2 相反,先烧毁 L2 代币,再接着送出一个 L2 -> L1 信息,基本上就是上一段提到的 finalizeETHWithdrawal,指示 L1StandardBridge 把 L1 代币解锁还给对方。

由 L2StandardBridge 指示 L1StandardBridge 解锁代币给使用者

以上就是 Optimism 官方代币桥的运作方式。如果你要部署代币合约到 Optimism 上并借由官方代币桥来做代币跨链,请记得一些重要的值例如 L2StandardBridge 地址及 L1 代币地址不要填错。

注意事项

L1 -> L2 信息指定的 gas limit

当你在送出 L1 -> L2 信息时,和 L1 交易一样,你需要顺便指定这个信息在 L2 上执行时的 gas limit。但在那个当下没办法知道 L2 实际执行会耗费多少 gas 或是执行结果,这样要怎么收手续费?

Optimism 采用的方式是让使用者在 L1 送出信息时,就预付一笔手续费,但有一个免费的限额:只要指定的 gas limit 小于 enqueueL2GasPrepaid,Optimism 就不收钱(目前 enqueueL2GasPrepaid 的值是 192w gas)。超过的话,合约里会用 while loop 去消耗 gas(意即让使用者用消耗的 L1 gas fee 来代付 L2 gas fee)。

但最重要需要注意的是:gas limit 请一定要指定足够,合约里有一个防呆检查:检查一个最低值 MIN_ROLLUP_TX_GAS(目前是 10w gas),但如果你的信息真的到 L2 花超过预期的 gas,那这个信息就会失败,而且没有办法补救。