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举例
- optimistic.etherscan.io/address/0x4…
- 只需要简单调用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还得再乘以一个系数用来缓解这种情况
开源实现可以参考:github.com/Particle-Ne…