跳至內容
0%

ERC-4337 gas 預估的運作方式

最後更新:2023年7月11日4 分鐘閱讀

在 Alchemy 建構 ERC-4337 Bundler「Rundler」的過程中,最難處理好的元件就是 user operation 的 gas estimation。本文將說明我們在提供準確 gas 估算時遇到的障礙,以及目前採用的解決方案。

這是一篇技術性的概述文章,適合 ERC-4337 開發者閱讀。若你剛接觸 ERC-4337 風格的 account abstraction,建議先閱讀我們的 Account Abstraction 系列入門文章。

為什麼 gas estimation 對 ERC-4337 user operation 很重要?

提供準確的 user operation gas 估算,對 ERC-4337 的使用者體驗來說相當重要。如果 gas 估算過低,user operation 可能在模擬階段就 revert,更糟的是可能在鏈上執行階段才 revert,導致使用者為一筆已 revert 的操作支付 gas 費用。如果 gas 估算過高,使用者可能因為成本而卻步,甚至無法送出他們的 operation。

雖然準確度很重要,但 gas estimation 並不需要做到 100% 準確,只要誤差始終是高估(但不能高估太多)即可。在 ERC-4337 中,gas 欄位皆以上限(limit)表示,使用者對於鏈上未消耗的 gas 會獲得退款。因此,gas estimation 並不會影響 operation 的實際成本。

💡 *但 preVerificationGas 是例外,稍後會詳細說明。

ERC-4337 gas 欄位定義

user operation 中的 gas 欄位,依 ERC-4337 規範 定義如下:

  • preVerificationGas:用來補償 bundler 進行 pre-verification 執行與 calldata 所需支付的 gas 數量。
  • verificationGasLimit:為驗證步驟分配的 gas 數量。
  • callGasLimit:為主要執行呼叫分配的 gas 數量。
  • maxFeePerGas:每單位 gas 的最高手續費(類似 EIP-1559 的 max\_fee\_per\_gas)。
  • maxPriorityFeePerGas:每單位 gas 的最高優先費(類似 EIP-1559 的 max\_priority\_fee\_per\_gas)。

ERC-4337 的 gas 計量方式

ERC-4337 bundler 會預先支付將 bundle 交易送至 entry point 合約的成本。entry point 會計量每個 user operation 所使用的 gas,將其乘以計算出的費用,並在 user operation 完成後,以此金額補償 bundler。

實際的計算方式如下:*

💡 *為簡化說明,此處僅適用於支援 EIP-1559 的網路。

preVerificationGas 會直接加上,而 verification gas 與 call gas 則會被「計量」。也就是說,它們的 gas 使用量會由鏈上的 entry point 進行測量,並依其實際使用量收費,上限為各自的 limit。若在 verification 或 call 階段觸及 limit,該 operation 將會 revert。

entry point 無法處理 gas 退款,因為它是在交易過程中計量 gas,而 gas 退款是在交易完成後才發放,因此會直接退還給 Bundler。這對使用者而言是額外的成本來源,對 bundler 而言則是潛在的收入來源。

User operation 流程

送出 user operation 的典型流程包含以下步驟:

  1. 建構一個部分填寫的 user operation,填入 sender、nonce、initCode 與 callData。 a. 同時以 「dummy」值 填入 signature 與 paymasterAndData。
  2. 透過 bundler RPC 的 eth\_estimateUserOperationGas 為這個部分填寫的 user operation 估算 gas。 a. 依回傳值填入 preVerificationGas、verificationGasLimit、callGasLimit。
  3. 估算此 operation 所需的 gas 費用,並填入 maxFeePerGas 與 maxPriorityFeePerGas。 a. 此步驟不依賴步驟 1 或 2,可以在任何時間執行,或平行執行。
  4. (選擇性)將 user operation 送至 sponsoring paymaster 進行簽署 a. 依回傳值填入 paymasterAndData。
  5. 對上述 user op 進行簽署,填入 signature,並透過 eth\_sendUserOperation 送至 bundler。

本文將聚焦於步驟 2,以及 bundler RPC 方法 eth\_estimateUserOperationGas。

User operation gas estimation

eth\_estimateUserOperationGas 是一個 RPC 方法,依 ERC-4337 規範 規定,bundler 必須支援此方法。

定義如下:

估算一個 UserOperation 所需的 gas 值。給定一個選擇性未帶 gas limit 與 gas 價格的 UserOperation,回傳所需的 gas limit。錢包會忽略 signature 欄位,因此該 operation 不需要使用者核准。不過,仍可能需要放入一個「半有效」的簽章(例如長度正確的簽章)。

*參數**:與 eth_sendUserOperation 相同,gas limit(與價格)參數為選擇性,但若有指定則會被採用。maxFeePerGas 與 maxPriorityFeePerGas 預設為零,因此帳戶與 paymaster 皆不需支付費用。*

回傳值:

  • *preVerificationGas** 此 UserOperation 的 gas 額外開銷*
  • *verificationGasLimit** 此 UserOperation 驗證階段實際使用的 gas*
  • *callGasLimit** 帳戶內部執行所使用的值*

PreVerificationGas 的計算方式

PreVerificationGas 用來涵蓋 entry point 未計量的 gas 使用量,藉此補償 bundler。

在簡單情況下,可拆解為 3 項獨立計算:

  1. 該 operation 在 bundle 交易中所佔的 intrinsic gas 份額。 a. 注意 bundler 必須事先假設一個 bundle 大小,才能決定此份額。
  2. 該 operation 所佔的 calldata gas 成本份額。 a. 這與該 operation 的大小及位元組組成直接相關。
  3. 該 operation 所佔、entry point 在計量範圍之外產生的任何執行 gas 額外開銷份額。 a. 這是透過鏈下對 entry point 合約進行 gas 使用量分析後決定,並依每個 user operation 分攤計算。

請注意 preVerificationGas 並非 limit。也就是說,該欄位設定的值一律會全額支付給 bundler。使用此欄位時要小心,因為錯誤的數值可能導致支付給 bundler 的費用超出實際所需!

詳情可參考 StackUp 團隊撰寫的深入分析,內容針對 preVerificationGas 有更詳細的說明。

Bundler 通常會在「pre-check」階段(即完整 trace-based 模擬之前執行的檢查)進行 preVerificationGas 的計算。

若某個 operation 的 preVerificationGas 低於 bundler 計算出的值,該 bundler 會予以拒絕。這可能成為不同 bundler 實作之間不相容的來源。

若某個 bundler 實作 L 估算出較低的 preVerificationGas 值,而另一個實作 H 估算(並要求)較高的值,則 H 會拒絕使用 L 估算值送出的 operation。

目前尚不清楚這種不相容性會如何影響 P2P mempool,但使用者很可能會傾向於稍微提高 preVerificationGas 的估算值,以提升自己的 operation 被打包的機率。

Alchemy 的 ERC-4337 bundler 如何估算 gas limit

準確估算 gas limit 欄位——verificationGasLimit 與 callGasLimit——十分重要。目標可以這樣描述:提供一個函式,用來估算這兩個階段所使用的 gas,且必須確保只會高估,但高估幅度不能大到讓使用者打消送出 operation 的念頭。

讓我們來討論我們在 Rundler 中,為了實現這個函式所做過的各種嘗試。

嘗試一:eth\_estimateGas

標準 Ethereum JSON-RPC 提供了一個估算交易 gas 的好用方法,那我們直接用 eth\_estimateGas 吧!

這個方法的做法是:

  • verificationGasLimit :呼叫 entry point 上的 simulateValidation 函式,並傳給 eth\_estimateGas
  • callGasLimit:呼叫 entry point 上的 innerHandleOp 函式,並傳給 eth\_estimateGas

這看起來運作得不錯。雖然 simulateValidation 與 innerHandleOp 都無法精確捕捉各階段被計量的部分,但它們都是嚴格意義上的超集合(superset),因此會稍微高估。

請注意,這兩次呼叫是完全獨立的,但在鏈上是在同一筆交易中執行。在這個方法中,simulateValidation 的執行結果不會被保留下來影響 innerHandleOp。

如果 innerHandleOp 依賴於 simulateValidation 期間所做的事情呢?

這種情況會發生在帳戶初次部署時,也可能出現在某些進階的驗證方案中。factory 方法會在驗證階段被呼叫,以部署帳戶合約。執行階段則需要該合約已部署完成,否則 innerHandleOp 會 revert,導致任何估算嘗試失敗。

我們需要一種方式,能夠同時估算這兩個 gas limit 欄位,並確保執行階段的估算與驗證階段在同一次呼叫中執行。

接下來看下一個嘗試……

嘗試二:模擬並測量

ERC-4337 v0.6 的實作提供了 simulateHandleOp 方法,將驗證與執行兩個階段合併在單一函式呼叫中。其回傳值包含 preOpGas,也就是驗證階段所用 gas 與 preVerificationGas 的總和。它同時也包含了 user op 所支付的總金額 paid。

要決定 callGasLimit,可以將 paid 除以 gas 費用,換算成使用的 gas 量。bundler 可以透過將模擬固定在某個(近期的)區塊高度(該高度的 base fee 已知),並將 priority fee 設為 0,來決定 gas 費用。經過一些簡單的換算,就能計算出所有的 gas 欄位。此方法同樣可能高估所使用的 gas,因為部分未被計量的 entry point 邏輯會被歸入使用的 gas 之中。

另一種合理的方法,是測量整個 simulateHandleOp 呼叫過程中使用的 gas,再減去驗證階段所使用的 gas,以此測量 call 階段所使用的 gas。

我們可以部署一個輔助合約,用來計量 simulateHandleOp 所使用的 gas,並將其附加到回傳值中。這個方法和前一種方法一樣,都有高估的問題。

對於在意高估程度的人,我們可以進一步精煉這個方法。

我們能否透過追蹤(trace)entry point 的 simulateHandleOp 方法來計算 gas?

Bundler 必須具備執行 trace call 的能力,才能進行保護自身免於鏈上失效所需的模擬檢查。我們能否利用這項功能來估算 gas?

這個方法的做法是:

  1. 追蹤 entry point 的 simulateHandleOp 方法,該方法很方便地使用了一種「number marker」機制,讓 tracer 能夠準確得知目前處於哪個執行階段。
  2. 在 tracer 中,記錄每個階段開始時剩餘的 gas 量,以及最終結束時的剩餘量。
  3. 依這些數值,計算出每個階段所使用的 gas 量。

太好了!我們用了稍微昂貴一些的 trace,精確計算出實際使用的 gas 量。

先別高興得太早,使用上述任一方法,都可能導致某些合約呼叫在執行時 gas 不足。

為什麼?

要回答這個問題,我們可以參考 Reth 如何估算 gas。注意到裡面的二元搜尋(binary search)了嗎?那是在做什麼?為什麼 Reth 不直接使用上述類似的追蹤方法,畢竟它能取得完全相同的資訊?

其實原因在於,在 EVM 的函式呼叫中:gas used(已使用的 gas)≠ gas required(所需的 gas)。

關於為何如此的完整說明,可參閱 Sergio Lerner 撰寫的「The Dark Side of Ethereum 1/64th CALL Gas Reduction」一文。其中最重要的原因稱為 「63/64th 規則」(或依對象不同,也稱為「1/64th 規則」)。

此規則規定,EVM 在每次函式呼叫時,只會轉發剩餘 gas 的 63/64。結果就是,你事先需要準備的 gas 量會略高於最終實際消耗的量。由於每次呼叫都會發生這種情況,因此若驗證/執行階段的呼叫堆疊很深,就可能需要預先保留大量 gas,以應付這些被保留起來的 gas。

我們希望找到一個對「所有」可能的 user operation 都適用的解決方案,因此任何依賴「已使用 gas」測量值來估算 gas limit 的做法都不再適用。在下一個嘗試中,讓我們從 EVM 客戶端汲取靈感。

💡 或許可以對追蹤函式進行詳細分析,並考慮 63/64th 規則(以及其他 EVM 的細微機制),將已使用的 gas 反推回 gas limit。我們尚未深入探討這個方向。

這裡不需要重新發明輪子。為了處理上述提到的複雜性,節點客戶端在進行 gas estimation 時會使用二元搜尋,找出能讓交易成功的最低 gas 值。我們也可以對 user operation 採用類似的做法!

做法如下:

首先要注意,由於二元搜尋需要對同一個函式進行多次呼叫,我們希望確保該函式的效能夠高。這使得追蹤(tracing)不再是合理的選擇,因此我們需要依賴 eth\_call。

估算 verificationGasLimit 的方式:

  1. 先以 MAX\_VERIFICATION\_GAS(bundler 的一項設定,定義驗證階段可使用的最大 gas 量)執行一次 simulateHandleOp,以確認成功執行是否可行。 a. 在此次執行中,將 callGasLimit 設為 0 以節省運算。
  2. 對 simulateHandleOp 執行二元搜尋,以逼近不會耗盡 gas 的最低 verificationGasLimit 值。 a. 若驗證階段耗盡 gas,呼叫會 revert。由於我們已經確認在較高 gas limit 下呼叫能成功,因此任何 revert 都可以將二元搜尋的下界往上移動。

這裡有個不錯的最佳化方式,就是意識到我們並不需要 100% 精確的估算結果。bundler 可以自行決定希望接近精確值的程度,一旦落在此範圍內就結束二元搜尋,並始終偏向較高的一側。這樣可以省下許多輪次的呼叫。舉例來說,Rundler 將這個誤差範圍設為 1K gas,省下了 10 次迭代。

第二個可以省下幾次迭代的最佳化方式,是為搜尋找出更好的起始點。將(1)中所使用的 gas 乘上一個係數(例如 Reth 使用 3 倍),並以此作為二元搜尋演算法的初始猜測值。

估算 callGasLimit 的方式:

  1. 將 verificationGasLimit 設為 MAX\_VERIFICATION\_GAS
  2. 嘗試執行 MAX\_EXECUTION\_GAS(此並非規範的一部分,但 bundler 需要用它來保護自己免於 DOS 攻擊),並檢查執行是否有可能成功。

到了這一步,這個估算方法就出現問題了。Revert 會被 entry point 捕捉並以 log 的形式發出,而 eth\_call 並未提供檢視任何已發出 log 的方式。而且它在每次嘗試中都會完整執行一次驗證,浪費了運算資源。

我們能否找出一種方式,讓 callGasLimit 的二元搜尋得以執行?

💡 若使用 trace,是有可能偵測到此 revert 的,但出於成本考量,我們希望避免這麼做。若某個 bundler 能完全掌控節點客戶端,並能使用原生的 tracer,或許值得重新考慮這個決定。

解決方案:在 Solidity 中執行二元搜尋

在 verificationGasLimit 估算方法的基礎上,這個方法的核心概念是在 Solidity 中執行 callGasLimit 的二元搜尋,並將能夠偵測執行部分是否失敗的邏輯直接編碼進去。

讓我們先定義限制條件:

  1. 二元搜尋必須在驗證執行完成之後才能進行,以確保任何帳戶合約都已部署完成 a. 一個理想但非必要的限制,是希望驗證只執行一次(或限制執行次數),以減少浪費的運算。
  2. 執行呼叫必須從 entry point 發起。許多帳戶實作只允許在 msg.sender 等於硬編碼的 entry point 位址時,才能呼叫其執行函式。
  3. 二元搜尋必須回傳最終結果。
  4. 所有 entry point 函式的行為都必須與正常情況完全一致。

我們想出的解決方案,是使用一個代理合約(proxy contract),透過 DELEGATECALL 將呼叫轉發至 entry point 合約,同時加入額外的邏輯。在估算期間,我們使用 eth\_call overrides,將原本的 entry point 合約移到一個隨機位址,並以代理合約取代它。這滿足了限制條件(2)與(4)。

如前所述,entry point 會隱藏執行階段的結果。我們改為略過標準的執行呼叫(將 callGasLimit 設為 0),並利用傳給 simulateHandleOp 的 target/targetCallData 參數。這會在驗證之後執行,並能回傳資訊,滿足限制條件(1)與(3)。以下是 entry point 的程式碼(已加上註解):

對於我們自訂的目標呼叫,我們會呼叫代理合約上的一個方法 estimateCallGas,其呼叫資料編碼了以下內容:

  1. 該帳戶及其呼叫資料(來自原始的 user operation)
  2. 二元搜尋的參數

由於代理合約位於原本 entry point 的位址,estimateCallGas 能夠呼叫該帳戶的執行函式,判斷該呼叫是成功還是因耗盡 gas 而失敗,並持續執行二元搜尋演算法,直到成功為止。

大功告成!現在二元搜尋可以在一次 eth\_call 中執行,藉此取得 callGasLimit 的精確估算值!

實務上,Rundler 會將這單一次 eth\_call 拆成多次呼叫,每次都設有一個全域最大 gas limit,以此繞過節點的最大 call gas limit 限制。每次呼叫都會執行二元搜尋迭代,直到下一次迭代會導致該呼叫耗盡 gas 為止。

💡 此方法會遺漏 entry point 在 innerHandleOp 期間所產生的少量額外開銷。這部分開銷為固定值,會加到估算結果中。

潛在的 entry point 修改方向

我們一直在思考對 entry point 進行哪些潛在的修改,能讓上述的 gas estimation 流程更容易實作,也讓使用者獲得更好的效能。這不是一個容易解決的問題,因此如果其他 bundler 或 ERC-4337 社群的成員有任何想法,我們非常樂意聽取!

我們希望確保在 gas estimation 過程中,驗證階段一定會在執行階段之前執行。執行階段可能依賴驗證階段所設定的狀態(例如 USDC paymaster 的轉帳,以及使用 USDC 的執行階段),因此在驗證階段結束後立即進行估算會最為準確。

其中一項潛在的改進,是讓 entry point 在 simulateHandleOp 期間回傳一個布林值,用來表示執行部分是否有 revert。bundler 便能在鏈下自行執行二元搜尋,而不需要代理合約與 state overrides。然而,這樣的二元搜尋效率不佳,因為每次迭代都需要重新執行驗證階段。

這讓我們想到,可以將上述嘗試三中由代理合約執行的二元搜尋功能,直接加入 entry point 合約本身(虛擬程式碼):

我們對上述內容(以代理合約形式)的實作,即將開源釋出。我們認為,若能讓透過二元搜尋進行估算的功能,直接成為 entry point 合約本身的一部分,對 ERC-4337 的使用者而言會相當有幫助。

敬請期待我們即將推出的開源 bundler 實作!🦀

繼續閱讀

本系列共四篇文章,後續文章將探討我們在 gas estimation 上遇到的挑戰,以及工程團隊實作的解決方案。

Alchemy 電子報

搶先掌握最新發布消息

訂閱我們的電子報

取得 Alchemy 最新產品更新與資源

A
O
D
+
超過 80,000 名訂閱者

輸入您的電子郵件地址,即表示您同意接收我們的行銷通訊與產品更新。您了解 Alchemy 會依照我們的隱私權聲明處理所收到的資訊。您可以隨時取消訂閱。