ERC-4337:如何预测用户操作的 Gas 消耗

bitget

Bitget交易所

Bitget交易所是全球前4大交易所之一、打新活动多、领空投到手软,新用户注册即可领取BGB空投

点击注册 进入官网

更多交易所入口

一站式注册各大交易所、点击进入加密世界、永不失联,币安Binance/欧易OKX/GATE.IO芝麻开门/Bitget/抹茶MEXC/火币Huobi

点击进入 永不失联
本文重点探讨在预测 Gas 的过程我们会遇到哪些问题以及对应的解决方案。


撰文:Dongxi,Particle Network 工程师


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 举例:


  1. https://optimistic.etherscan.io/address/0x420000000000000000000000000000000000000F#readProxyContract
  2. 只需要简单调用 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 还得再乘以一个系数用来缓解这种情况。


开源实现可以参考:https://github.com/Particle-Network/particle-bundler-server


相关阅读
https://www.alchemy.com/blog/erc-4337-gas-estimation
https://www.alchemy.com/blog/user-operation-fee-estimation

目录[+]