本文重點探討在預測 Gas 的過程我們會遇到哪些問題以及對應的解決方案。
(前情提要:速覽》以太坊基金會「帳戶抽象」資助輪獲獎的18個ERC-4337潛力項目 )
(背景補充:V神大推的「以太坊帳戶抽象」是什麼? ERC-4337 實用案例說明 )
Introduction
對於一個 ERC4337 的 Bundler 來說,核心職能有兩個:
- 預測 UserOperation 的 Gas,即 eth_estimateUserOperationGas
- 打包並提交 UserOperation 到鏈上,即 eth_sendUserOperation
其中預測 UserOperation 的 Gas 可謂是 Bundler 中最具有挑戰性的部分。因此本文重點探討在預測 Gas 的過程我們會遇到哪些問題以及對應的解決方案。除此之外,本文還將討論 Gas Fee 的預測的實現,這雖然不在 ERC4337 的協議範疇內,但是卻是 Bundler 實現中無法繞過的話題。
Gas Estimation
首先,使用者的 Account 是個合約,EVM 在執行交易時遇到合約會有一筆載入合約的 Gas 消耗。另外使用者的 UserOp 會被封裝到交易裡發到鏈上,具體由一個統一的 EntryPoint 合約執行。所以在 AA 中哪怕是最普通的轉帳,消耗的 Gas 也是普通 EOA 地址轉帳的好幾倍。
理論上,你可以設定一個很大的 GasLimit 去規避很多複雜的情況,這很簡單。但是這要求使用者的 Account 能夠有相當大的餘額去提前扣除這筆費用,這並不現實。如果能夠準確的預估 Gas 的消耗,可以讓使用者在合理的範圍內去正常交易,這對於提高使用者體驗和降低交易門檻有很大的幫助。
根據 ERC4337 的官方文件,跟 Gas 估算有關的欄位如下:
- preVerificationGas
- verificationGasLimit
- callGasLimit
讓我們來一一講解這幾個欄位並提供一個預測方法。
preVerificationGas
首先我們需要明白,UserOperation 是一個結構,由 Bundler 中的 Signer 將其打包成交易,並發送到鏈上去執行,而在執行的過程中消耗的是 Signer 的 Gas,在執行結束後計算產生的 GasCost,並返還給 Signer。
在以太坊的模型中,執行一個交易前會預先扣除一定的 Gas,這裡簡單歸納為兩點:
- 如果是建立合約會扣除 53000,呼叫合約則扣除 21000
- 根據合約長度以及合約程式碼的位元組型別扣除一定的 Gas
相關的程式碼實現
也就是說,執行交易前就會消耗一部分隱性的 Gas,是無法在執行的時候計算的,所以 UserOperation 需要指定 preVerificationGas,用來補貼 Signer。不過這部分隱性的 Gas 是可以通過鏈下計算的,官方的 SDK 中給出了相關的介面,我們只需要呼叫即可。
import { calcPreVerificationGas } from ‘@account-abstraction/sdk’;
@param userOp filled userOp to calculate. The only possible missing fields can be the signature and preVerificationGas itself
@param overheads gas overheads to use, to override the default values
const preVerificationGas = calcPreVerificationGas(userOp, overheads);
verificationGasLimit
顧名思義,這是在驗證階段分配的 GasLimit,有三種情況會使用到這個 GasLimit:
- 如果 UserOp.sender 不存在,執行 UserOp.initCode 初始化 Account
- 執行 Account.validateUserOp 驗證簽名
- 如果存在 PaymasterAndData
驗證階段呼叫 Paymaster.validatePaymasterUserOp
結束階段呼叫 Paymaster.postOp
senderCreator.createSender{gas : verificationGasLimit}(initCode);
IAccount(sender).validateUserOp{gas : verificationGasLimit}
uint256 gas = verificationGasLimit – gasUsedByValidateAccountPrepayment;
IPaymaster(paymaster).validatePaymasterUserOp{gas : gas}
IPaymaster(paymaster).postOp{gas : verificationGasLimit}
可以看到,verificationGasLimit 基本代表上述所有操作的 Gas 總限制,但不是嚴格限制也不一定準確,因為 createSender 和 validateUserOp 的呼叫是獨立的,也就意味著最壞的情況,實際 Gas 消耗可能是 verificationGasLimit 的兩倍。
所以為了確保 createSender 和 validateUserOp,validatePaymasterUserOp 的 gas 總消耗不會超過 verificationGasLimit,我們需要預測這三個操作的 Gas 消耗。
其中 createSender 是可以準確預測的,這裡我們可以使用傳統的 estimateGas 方法去預測。
// userOp.initCode = [factory, initCodeData]
const createSenderGas = await provider.estimateGas({
from: entryPoint,
to: factory,
data: initCodeData,
});
為什麼 from 要設定為 entryPoint 地址呢,因為基本上大部分的 Account 在建立的時候會設定一個來源(即 entryPoint),呼叫 validateUserOp 會驗證來源。
其他的類似 validateUserOp,validatePaymasterUserOp 目前不太好預測,但是由於方法本身的特性為驗證 UserOp 的有效性(大概率是驗證簽名),所以本身 Gas 消耗並不會很高,在實際操作中我們給一個 100000 的 GasLimit 基本能涵蓋這類方法的消耗。所以綜上,我們可以將 verificationGasLimit 設定為:
verificationGasLimit = 100000 + createSenderGas;
callGasLimit
callGasLimit 代表 Account 實際執行 callData 的消耗,也是預測 Gas 中最重要的部分。那麼我們該如何預測這部分的 Gas 消耗呢,用傳統的 estimateGas 實現如下:
const callGasLimit = await provider.estimateGas({
from: entryPoint,
to: userOp.sender,
data: userOp.callData,
});
這裡模擬從 entryPoint 呼叫 Sender Account 的方法,通過了 Account 的來源檢查,也繞過了 validateUserOp 中驗證簽名步驟(因為在 eth_estimateUserOperationGas 介面中的 UserOp 是沒有簽名的)。
這裡存在一個問題,就是這種預測成立的前提是 Sender Account 是存在的,如果是 Account 的第一筆交易 (Account 還沒有被部署,需要先執行 initCode),這種預測會因為 Account 不存在而發生 revert。無法預估準確的 callGasLimit。
如何獲取首次交易的 callGasLimit
既然首次交易的情況下無法拿到準確的 callGasLimit,那我們還有沒有別的方案呢?當然是有的,我們可以先估算整個 UserOp 的 TotalGasUsed,然後再用總的 TotalGasUsed 減去 createSenderGas 後可以得到一個近似值。
otherVerificationGasUsed = validateUserOpGasUsed + validatePaymasterGasUsed
TotalGasUsed – createSenderGasUsed = otherVerificationGasUsed + callGas
這裡 otherVerificationGasUsed 即 validateUserOp,validatePaymasterUserOp 的實際消耗,因為根據上文,這類方法的 Gas 消耗不會很大(基本在 10 萬以內),所以我們可以把 otherVerificationGasUsed 當成 callGasLimit 的一部分,即
otherVerificationGasUsed + callGas = callGasLimit
如何在沒有 signature 的情況下獲取 HandleOps 的 GasUsed
因為在 eth_estimateUserOperation 介面中,傳上來的 UserOperation 是可以不包含 signature 的,這也就意味著我們無法通過傳統的 eth_estimateGas (entryPoint.handleOps) 去獲取到執行 UserOp 需要的 Gas,這個模擬必定報錯,因為 EntryPoint 在 validate 階段驗證簽名不通過並 revert。
那麼有什麼方式能夠獲取一個比較準確的 GasUsed 呢,答案當然是有的,EntryPoint 的開發者貼心地為我們預留了 simulateHandleOp 方法,這個方法可以在你沒有 UserOp 的 signature 的情況下,完整模擬整個交易的執行過程,它的實際做法是在你的 validate 階段驗證失敗後,不返回值,以達到繞過 validate 檢查的目的。當然這個方法最後是一個 revert,這也就意味著你只能通過 eth_call 的方式呼叫這個介面:
// EntryPoint.sol
function simulateHandleOp(UserOperation calldata op, address target, bytes calldata targetCallData) external override {
UserOpInfo memory opInfo;
_simulationOnlyValidations(op);
(uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(0, op, opInfo);
// Hack validationData, paymasterValidationData
ValidationData memory data = _intersectTimeRange(validationData, paymasterValidationData);
numberMarker();
uint256 paid = _executeUserOp(0, op, opInfo);
numberMarker();
bool targetSuccess;
bytes memory targetResult;
if (target != address(0)) {
(targetSuccess, targetResult) = target.call(targetCallData);
}
revert ExecutionResult(opInfo.preOpGas, paid, data.validAfter, data.validUntil, targetSuccess, targetResult);
}
我們通過返回值知道第二個為引數為 paid:
paid = gasUsed * gasPrice
因此只要我們把 gasPrice 設定為 1,那麼 paid 就是 gasUsed。
我們發現 UserOp 中並沒有 gasPrice 的欄位,而是類似 EIP-1559 的 maxFeePerGas 和 maxPriorityFeePerGas,當然這只是 UserOp 的設計,並不代表 AA 的協議不能在非 EIP-1559 的鏈執行,實際上在 EntryPoint 的實現中,maxFeePerGas 和 maxPriorityFeePerGas 也只是為了計算一個更合理的 gasPrice,我們看下公式:
gasPrice = min(maxFeePerGas, maxPriorityFeePerGas + block.basefee)
不支援 EIP-1559 的鏈可以看作 basefee 為 0,所以我們只需要把 maxFeePerGas 和 maxPriorityFeePerGas 都設定為 1 即 gasPrice 為 1。
綜上所述,我們搞定了在沒有 signature 的情況下模擬出 UserOp 具體的 GasUsed,也就能算出大致近似的 callGasLimit 了
Fee Estimation
預測 Gas Fee,也就是 maxFeePerGas 和 maxPriorityFeePerGas 為什麼也非常重要。這是因為 Bundler 的 Signer 是不能夠虧錢的。
首先如果使用者的 UserOp 的 gasFee < Signer 的 gasFee,那麼在執行 UserOp 後,計算出來的 UserOp 的費用不足以補貼 Signer 的費用,這樣 Signer 就虧損了。因為 Bundler 的 Signer 並沒有承擔 UserOp 費用的職能,僅僅是為了傳送交易,這樣 Signer 需要提前存入一定的餘額,如果出現虧損,會直接影響後續的 UserOp 的執行,也就會影響 Bundler 的正常執行。也因為 Signer 是有成本的,所以一般 Bundler 也只會維護有限數量的 Signer。如果 Bundler 要支援多鏈,這樣維護的成本也會變高。支付 UserOp 費用的主體應該為 Sender 本身和 Paymaster。
當然,最理想的情況下是 UserOp 的 gasFee 應該接近於 Signer 的 gasFee,所以我認為 Bundler 應該在 eth_estimateUserOperationGas 返回推薦的 maxFeePerGas 和 maxPriorityFeePerGas, 這樣能夠最大幅度降低使用者 UserOp 的費用。
當然如果使用者的 UserOp 的 GasFee 很低,我們也可以把低於 Signer GasFee 的 UserOp 放到 UserOp 池子裡,等到 Signer 的 Gas Fee 低到可以打包該 UserOp 為止,但是在實踐中,這種 UserOp 往往需要等待很長的時間才能被執行,對於使用者體驗而言並不好。
所以,正常情況下,我們可以返回比 Signer 的 Gas Fee 高一點點的 maxFeePerGas 和 maxPriorityFeePerGas,這樣可以保證 UserOp 在傳送的時候能夠被立即執行。
L2 Fee Estimation
上面的方案我們只能解決 L1 的 Fee Estimation,為什麼不能適用於 L2 呢?
因為 L2 依賴 L1 作為資料安全保障,在執行完一定數量 L2 Transaction 後會生成一個 Rollup 證明發到 L1 上,所以 L2 的 Transaction Fee 包含了一個隱性的 L1 Fee:
L2 Transaction Fee = L2GasPrice * L2GasUsed + L1 Fee
這種 L2 的 Transaction Fee 的計算方式帶來了一個問題,就是很多錢包比如 metamask 並沒有把 L1 Fee 算進去,如果你的餘額剛好滿足 GasPrice * GasLimit,發出去的交易也大概率是會報錯的。
如果在 L2 我們也讓 UserOp 的 GasPrice 和 Signer 的 GasPrice 接近,毫無疑問,Signer 會承擔 L1Fee 的費用,這並不符合預期。不過好在,L1 Fee 是可以被計算出來的。
通常,L2 都會提供一個 GasPriceOracle 合約能夠讓你快速獲取到 L1 Fee。
比如 Scroll/Base/OPBNB/Optimism。
這裡我們以 Optimism 舉例:
https://optimistic.etherscan.io/address/0x420000000000000000000000000000000000000F#readProxyContract
只需要簡單呼叫 getL1Fee 方法即可獲取具體的 L1Fee
這樣我們能夠很容易的獲得 L1Fee,並將它折算到 UserOp 的 GasPrice 中
const signerPaid = gasUsed * signerGasPrice + L1Fee;
const minGasPrice = signerPaid / gasUsed;
其他的 L2 像 Taiko 這種,已經把 L1 Fee 折算到 GasPrice 中了,我們就無需再算 L1 Fee 了
總結
至此,我們基本算解決了 Gas / Fee 的預測問題,不過需要注意的是,有些鏈的 Gas Price 波動很大,比如 Polygon,相同的 GasPrice 可能在短時間內失效,在實踐中我們需要針對波動大的鏈預測出來的 Gas Fee 還得再乘以一個係數用來緩解這種情況。