原文: 《Optimism Bedrock vs Arbitrum Nitro》by Norswap,OP Labs 研發人員

編譯:隔夜的粥

這是一篇有關Optimism Bedrock以及Arbitrum Nitro ‌ 之間設計差異的分析文章。

這一切都源於我對Nitro 白皮書‌的閱讀,以及我對Bedrock 設計的感性認識。

這變得非常技術性, 如果你想關注並感到困惑,我建議你參考一下Bedrock 概述以及我關於Cannon 故障證明系統的演示文稿‌,當然還有Nitro 白皮書。

準備好了之後,讓我們開始吧!

強強對決:Optimism Bedrock VS Arbitrum Nitro

首先,Nitro 白皮書很棒,讀起來令人愉快, 我建議所有感興趣的人都去看看。

說到這裡,我的印像是Bedrock 和Nitro 大致使用了相同的架構,但有一些較小的差異。

白皮書大體上證實了這一點。儘管如此,還是有很多的不同之處,包括一些我沒想到的。這就是這篇文章要講的東西。

(A)固定與可變區塊時間

最有趣和最重要的事情之一是, Nitro 將像當前版本的Optimism 一樣工作,每筆交易一個區塊,並且區塊之間的時間可變。

我們放棄了這一點,因為它背離了以太坊的工作方式,也是開發人員的痛點。而Bedrock 將有「真正」的區塊,並且固定時間為2 秒。

不規則的區塊時間使很多常見的合約變得不穩定,因為它們是使用區塊而不是時間戳來表示時間。這尤其包括源自Sushiswap 的分配LP 獎勵的Masterchef 合約。

我不確定為什麼這些合約用區塊而不是時間戳來表示時間!以太坊礦工在操縱時間戳方面有一些迴旋餘地,但默認情況下,客戶端不會構建距離wallclock (Geth 為15 秒)太遠的區塊,所以沒有問題。

無論如何,在Optimism 上,這導致StargateFinance 獎勵比其他鏈提前幾個月用完,因為他們沒有考慮到這種特殊性!

「每筆交易一個區塊」模型還有其他的問題。首先,存儲鏈的開銷很大(每筆交易一個區塊頭)。其次,這意味著狀態根需要在每次交易後更新。

更新狀態根是一項非常昂貴的操作,其成本要在多筆tx 中進行分攤。

(B) Geth 作為庫或作為執行引擎

Nitro 使用Geth 「作為一個庫」,通過鉤子(hooks)對其進行了最低限度的修改,以調用適當的功能。

在Bedrock 中,一個經過最少修改的Geth 作為「執行引擎」獨立運行,它從rollup 節點接收指令,就像執行層從Eth2 中的共識層接收指令一樣。我們甚至使用完全相同的API!

這有一些重要的影響。首先,我們能夠使用除Geth 之外的其他客戶端,在它們之上應用類似的最小差異。這不僅僅是理論,我們已經準備好了Erigon‌

其次,這讓我們可以重用整個Geth(或其他客戶端)堆棧,包括在網絡層,這可以實現對等發現和狀態同步等功能,而無需進行任何額外的開發工作。

(B) 狀態存儲

Nitro 將一些狀態(「ArbOS 的狀態」)保存在一個特殊帳戶中(它本身存儲在Arbitrum 的鏈狀態中),使用特殊的內存佈局將密鑰映射到存儲槽。

從這個意義上說,Bedrock 並沒有太多的狀態,它只有很少的狀態存儲在普通EVM 合約中(公平地說,你可以使用EVM 實現ArbOS 狀態佈局,但我認為他們並不是這樣做的) 。

在確定/ 執行下一個L2 塊時,一個Bedrock 副本會查看:

  • L2 鏈頭部的區塊頭;
  • 從L1 讀取的數據;
  • L2 鏈上EVM 合約中的一些數據,目前只有L1 費用參數。

在Bedrock 中,節點可能會崩潰並立即優雅地重啟。它們不需要維護額外的數據庫,因為所有必要的信息都可以在L1 和L2 區塊中找到。我認為Nitro 的工作原理是一樣的(架構使這成為可能)。

但很明顯, Nitro 比Bedrock 做了更多的記賬工作。

(C) L1 到L2 的消息包含延遲

Nitro 會延遲10 分鐘處理L1 到L2 的消息(我們稱之為「存款交易」或簡稱「存款」)。在Bedrock 上,通常應具有幾個區塊的小確認深度(可能是10 個L1 區塊,所以大約是2 分鐘)。

我們也有一個稱為「排序器漂移」(sequencer drift)的參數,它允許L2 區塊的時間戳在其L1 原點之前漂移(L1 區塊標誌著L1 區塊範圍的結束,批次和存款是從中派生的)。

我們仍然需要確定最終的數值,但我們也傾向於10 分鐘,這意味著最壞的情況是10 分鐘。然而,此參數旨在確保在與L1 的連接暫時丟失期間L2 鏈的活性。

然而,通常在確認深度後會立即包含存款。

Nitro 的白皮書中提到,這10 分鐘的延遲是為了避免L1 上的存款因重組而消失。這讓我對白皮書沒有談到的一個方面感到好奇,那就是:L2 鏈如何處理L1 的重組。我認為答案是它沒有處理。

這並非不合理:合併後,L1 的最終性延遲大約是12 分鐘。因此,如果存款延遲10/12 分鐘是可接受的,那麼這個設計就是可行的。

因為Bedrock 更接近L1,我們需要在需要時通過重組L2 來處理L1 重組。確認深度應避免這種情況過於頻繁地發生。

另一個小的區別是,如果Nitro 排序器在10 分鐘後不包含存款,你可以通過L1 合約調用「強制包含」它。

在Bedrock 上,這不是必需的:擁有一個L2 區塊而不包括其L1 起源的存款是無效的。

並且由於L2 只能比原點提前10 分鐘(排序器漂移),因此10 分鐘後不納入存款的一條鍊是無效的,它將被驗證器拒絕,並受到故障證明機制的挑戰。

(D) L1-to-L2 消息重試機制

Nitro 為L1 到L2 的消息實施了「可重試票證」(retryable tickets)機制。假設你正在跨鏈,tx 的L1 部分可以工作(鎖定你的代幣),但L2 部分可能會失敗。因此,你需要能夠重試L2 部分(可能需要更多的gas),否則你已經丟失了代幣。

Nitro 在節點的ArbOS 部分實現了這一點。在Bedrock 中,這一切都是在Solidity 本身中完成的。

如果你使用我們的L1 跨域messenger 合約向L2 發送tx,該tx 會到達我們的L2 跨域messenger,後者將記錄其哈希值,使其可重試。 Nitro 的工作方式相同,只是在節點中實現。

我們還通過我們的L1 Optimism Portal 合約,公開了一種較低level 的存款方式。

這並沒有為你提供L2 跨域messenger 重試機制的安全網,但另一方面,這意味著你可以在Solidity 中實現自己的應用程序特定重試機制。這很酷!

(E) L2 費用算法

在Bedrock 以及Nitro 這兩個系統上,費用都有L2 部分(執行gas,類似於以太坊)以及L1 部分(L1 calldata 的成本)。對於L2 費用,Nitro 使用了一個定制系統,而Bedrock 重複使用了EIP-1559。 Nitro 必須這樣做,因為他們有上述提到的1 tx/ 區塊系統。

我們仍然需要調整EIP-1559 參數,以使其在2 秒的出塊時間內正常工作。今天,Optimism 只收取低且固定的L2 費用, 我認為我們可能也會出現價格飆升,但在實踐中從未發生過。

重用EIP-1559 的一個優點是,它應該使錢包和其他工具計算費用稍微容易一些。

而Nitro 的gas 計量公式非常優雅,他們似乎已經對此進行了大量思考。

(F) L1 費用算法

那L1 費用如何呢?這裡的區別會更大一些。 Bedrock 使用向後查看的L1 基礎費用數據。這些數據非常新鮮,因為它通過與存款相同的機制傳遞(即幾乎是即時的)。

由於仍然存在L1 費用飆升的風險,所以我們收取預期費用的一個小倍數。

有趣的事實:這個倍數(自啟動鏈以來我們已經多次降低)是所有當前排序器收入的來源!使用EIP-4844 後,這將縮小,收入將來自MEV 提取。

Nitro 做的事情要復雜得多。我並沒有聲稱了解它的所有復雜性,但基本要點是他們有一個控制系統,可以從L1 實際支付的費用中獲得反饋。

這意味著使用此數據將交易從L1 發送回L2。如果排序器支付不足,它可以開始向用戶收取更少的費用。如果它多付了錢,它可以開始向用戶收取更多費用。

順便說一句,你可能想知道為什麼我們需要將費用數據從L1 傳輸到L2。這是因為我們希望費用計劃成為協議的一部分,並接受故障證明的挑戰。否則,流氓排序器可通過設置任意高的費用來拒絕鏈!

最後,交易批次在兩個系統中都被壓縮。 Nitro 根據對交易壓縮程度的估計收取L1 費用。 Bedrock 目前沒這樣做,但我們有這樣做的計劃。

原因在於,不這樣做,會加劇在L2 存儲中緩存數據的不正當動機,從而導致有問題的狀態增長。

(G) 故障證明指令集

故障/ 欺詐證明! Nitro 的工作方式與Cannon(我們目前正在實施的位於Bedrock 之上的防故障系統)的工作方式有相當多的差異。

Bedrock 編譯為MIPS 指令集架構(ISA),Nitro 編譯為WASM。由於編譯為他們稱為WAVM 的WASM 子集,他們似乎對輸出進行了更多的轉換。

例如,他們通過庫調用替換浮點(FP) 操作。我懷疑他們不想在鏈上解釋器中實現粗糙的FP 操作。我們也這樣做,但Go 編譯器會替我們處理!

另一個例子:與大多數只有跳轉的ISA 不同,WASM 具有適當的(可能嵌套的)控制流(if-else、while 等)。從WASM 到WAVM 的轉換消除了這一點以返回跳轉,這可能也是為了解釋器的簡單性。

他們還將Go、C 和Rust 混合編譯為WAVM(在不同的「模塊」中),而我們只編譯Go。顯然WAVM 允許「語言的內存管理不受干擾」,我將其解釋為每個WAVM 模塊都有自己的堆。

我很好奇是:他們是如何處理並發和垃圾收集的。我們能夠在minigeth(我們精簡的geth)中相當容易地避免並發,所以這部分可能很簡單(本文末尾將詳細介紹Bedrock 和Nitro 如何使用geth)。

然而,我們對MIPS 所做的唯一轉換之一是修補垃圾收集調用。這是因為垃圾收集在Go 中使用了並發,而並發和故障證明不能很好地結合在一起。 Nitro 也是做了同樣的事嗎?

(H) 二分博弈結構

Bedrock 故障證明將用於驗證發佈到L1 的狀態根(實際上是輸出根)的有效性的minigeth 運行。此類狀態根不經常發布,並且包括許多區塊/ 批次的驗證。

Cannon 中的二分遊戲是在這個(長期)運行的執行軌跡上進行的。

另一方面,在Nitro 中,狀態根與發佈到L1 的每組批次(RBlock) 一起發布。

Nitro 中的二分遊戲分為兩部分。首先找到挑戰者和防御者不同意的第一個狀態根。然後,在驗證器運行中找到他們不同意的第一個WAVM 指令(它只驗證單個tx )。

權衡之處是在Nitro 執行期間進行更多的哈希運算(參見上面的(A)部分),但在故障證明期間進行更少的哈希運算:在執行跟踪的二分遊戲中的每個步驟,都需要提交內存Merkle 根。

像這樣的故障證明結構也減少了對驗證器內存膨脹的擔憂,其可能會超過當前運行MIPS 的4G 內存限制。

這不是一個很難解決的問題,但我們需要在Bedrock 中小心,而驗證單筆交易可能永遠不會接近這個限制。

(i)原像預言機(Preimage oracle)

用於故障證明的驗證器軟件需要從L1 和L2 讀取數據。因為它最終將在L1 上「運行」(儘管只有一條指令),所以需要通過L1 訪問L2 本身- 通過發佈到L1 的狀態根和區塊哈希。

你如何從狀態或鏈中讀取(無論是L1 還是L2)?

Merkle 根節點是其子節點的哈希,因此如果你可以請求原像,則可以遍歷整個狀態樹。同樣,你可以通過請求區塊頭的原像來向後遍歷整個鏈。 (每個區塊頭都包含其父區塊的哈希值。)

在鏈上執行時,這些原像可以預先提供給WAVM/MIPS 解釋器。 (鏈下執行時,可以直接讀取L2 狀態!)

這就是你在Nitro 和Bedrock 上閱讀L2 的方式。

但是,你需要為L1 做類似的事情。因為交易批次存儲在L1 調用數據中,無法從L1 智能合約訪問。

Nitro 將其批次的哈希存儲在L1 合約中(這就是為什麼他們的「Sequencer Inbox」是一個合約,而不是像Bedrock 那樣的EOA)。所以他們至少需要這樣做,我不知道為什麼沒有提到。

在Bedrock 中,我們甚至不存儲批次哈希(從而節省了一些gas)。相反,我們使用L1 區塊頭返回L1 鏈,然後沿著交易Merkle 根向下查找calldata 中的批次。

第4.1 節的結尾,提醒我們Arbitrum 發明了「哈希預言機技巧」 ‌。不安全不應該成為忘記Arbitrum 團隊貢獻的理由!

(J) 大原像(Large preimages)

Nitro 白皮書還告訴我們,L2 原像(Preimage) 的固定上限是110 kb,但沒有引用L1 的數字。

在Cannon 中,我們有一個稱為「大原像問題」的問題,因為要反轉的潛在原像之一是收據原像,其中包含Solidity 事件發出的所有數據(EVM 級別的「日誌」)。

在收據中,所有日誌數據連接在一起。這意味著攻擊者可以發出大量日誌,並創建一個非常大的原像。

我們需要讀取日誌,因為我們使用它們來存儲存款( L2-to-L1 消息)。這並不是絕對必要的:Nitro 通過存儲消息的哈希來避免這個問題(它比這更複雜,但最終結果是相同的)。

我們不存儲哈希,因為計算和存儲它的成本很高,存儲要消耗大約20k gas ,每計算32 個字節要消耗6 gas。平均一筆交易大約是500 字節,因此一批200 筆交易的哈希成本大約為20k gas 。以2000 美元的ETH 和40 gwei basefee 計算,額外的哈希和存儲成本為3.2$。以5000 美元的ETH 和100 gwei 計算,成本即20 美元。

我們目前解決大原像問題的計劃,是使用簡單的zk-proof 來證明原像中某些字節的值(因為這是一條指令在實踐中需要訪問的全部內容)。

(K) 批次和狀態根

Nitro 將批次和狀態根緊密相連。他們在包含狀態根的RBlock 中發布一組批次。

另一方面,Bedrock 將其批次與狀態根分開發布。關鍵優勢是再次降低了發布批次的成本(無需與合約交互或存儲數據)。這讓我們可以更頻繁地發布批次,並減少狀態根的頻率。

另一個影響是,使用Nitro,如果RBlock 受到挑戰,它包含的交易將不會在新鏈上重放(新的正確狀態根)。

在Bedrock 中,我們目前正在討論在成功挑戰狀態根的情況下該怎麼做:在新的狀態根上重放舊tx,還是完全回滾? (當前的實現意味著完全回滾,但在推出故障證明之前可能會發生更改。)

(L) 其他雜項

影響較小的差異:

(i) Nitro 允許排序器發布的單筆交易可以是「垃圾」(無效簽名等)。為了盡量減少對Geth 的更改,我們總是丟棄包含任何垃圾交易的批次。

排序器總是能夠提前找到那些,所以揮之不去的垃圾交易要么是不當行為要么是bug。排序器運行與故障證明相同的代碼,因此它們對無效內容的定義應該相同。

(ii) Nitro 引入了預編譯合約,尤其是用於L2 到L1 的消息傳遞。我們目前不使用任何預編譯,而是更喜歡它們「預部署」,即存在於創世區塊特殊地址的實際EVM 合約。

事實證明,我們可以在EVM 中做我們需要的事情,這使得節點邏輯稍微簡單一些。不過,我們並不堅決反對預編譯,也許我們會在某個時候需要用到預編譯。

(iii) Nitro 故障證明使用了d 向剖析( d-way dissection)。概念驗證Cannon 實現使用了二分法,但我們也可能會轉向d 向剖析。

Nitro 白皮書中有一個非常好的公式,它解釋了基於固定成本和可變成本的d 的最優值。然而,我希望他們在實踐中包括瞭如何估算這些成本的具體例子!

結尾

沒有什麼宏大的結論!或者更確切地說:請自己總結出結論:)