本文重点探讨在预测 Gas 的过程我们会遇到哪些问题以及对应的解决方案。
(前情提要:速览》以太坊基金会「帐户抽象」资助轮获奖的18个ERC-4337潜力项目 )
(背景补充:V神大推的「以太坊帐户抽象」是什么? ERC-4337 实用案例说明 )
本文目录
Introduction
对于一个 ERC4337 的 Bundler 来说,核心职能有两个:
其中预测 UserOperation 的 Gas 可谓是 Bundler 中最具有挑战性的部分。因此本文重点探讨在预测 Gas的过程我们会遇到哪些问题以及对应的解决方案。除此之外,本文还将讨论 Gas Fee 的预测的实现,这虽然不在 ERC4337 的协议范畴内,但是却是 Bundler 实现中无法绕过的话题。
Gas Estimation
首先,使用者的 Account 是个合约,EVM 在执行交易时遇到合约会有一笔载入合约的 Gas 消耗。另外使用者的 UserOp会被封装到交易里发到链上,具体由一个统一的 EntryPoint 合约执行。所以在 AA 中哪怕是最普通的转帐,消耗的 Gas 也是普通 EOA地址转帐的好几倍。
理论上,你可以设定一个很大的 GasLimit 去规避很多复杂的情况,这很简单。但是这要求使用者的 Account能够有相当大的余额去提前扣除这笔费用,这并不现实。如果能够准确的预估 Gas的消耗,可以让使用者在合理的范围内去正常交易,这对于提高使用者体验和降低交易门槛有很大的帮助。
根据 ERC4337 的官方文件,跟 Gas 估算有关的栏位如下:
让我们来一一讲解这几个栏位并提供一个预测方法。
preVerificationGas
首先我们需要明白,UserOperation 是一个结构,由 Bundler 中的 Signer将其打包成交易,并发送到链上去执行,而在执行的过程中消耗的是 Signer 的 Gas,在执行结束后计算产生的 GasCost,并返还给 Signer。
在以太坊的模型中,执行一个交易前会预先扣除一定的 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:
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 还得再乘以一个系数用来缓解这种情况。
📍相关报导📍
Vitalik ETH香港演讲:以太坊至今的成就!未来又有什么挑战?
比特币上涨如火箭,为何以太坊却是像「开拖拉机」一样?
V神高度关注的以太坊ERC-4337,将开启Web3用户大爆发?
Leave a Reply