This article focuses on the problems we encounter in the process of predicting Gas and the corresponding solutions.
Author: Dongxi, Particle Network Engineer
Introduction
For an ERC4337 Bundler, there are two core functions:
- Predicting the Gas for UserOperation, i.e. eth_estimateUserOperationGas
- Packaging and submitting UserOperation to the chain, i.e. eth_sendUserOperation
Predicting the Gas for UserOperation is arguably the most challenging part of the Bundler. Therefore, this article focuses on the problems we encounter in the process of predicting Gas and the corresponding solutions. In addition, this article will also discuss the implementation of Gas Fee prediction, which is not within the scope of the ERC4337 protocol, but is a topic that cannot be bypassed in Bundler implementation.
Gas Estimation
First, the user's Account is a contract, and the EVM incurs a gas cost when encountering a contract during transaction execution. Additionally, the UserOp of the user will be encapsulated in a transaction and sent to the chain for execution, specifically by a unified EntryPoint contract. Therefore, even for the most ordinary transfer in AA, the gas consumption is several times that of a normal EOA address transfer.
In theory, you can set a large GasLimit to avoid many complex situations, which is simple. But this requires the user's Account to have a considerable balance to deduct this fee in advance, which is not practical. Accurately estimating gas consumption can allow users to conduct normal transactions within a reasonable range, which greatly helps to improve user experience and reduce transaction barriers.
According to the official documentation of ERC4337, the fields related to gas estimation are as follows:
- preVerificationGas
- verificationGasLimit
- callGasLimit
Let's explain each of these fields and provide a prediction method.
preVerificationGas
First, we need to understand that UserOperation is a structure, which is packaged into a transaction by the Signer in the Bundler and sent to the chain for execution. During execution, the gas consumed belongs to the Signer, and the gas cost generated after execution is calculated and returned to the Signer.
In the Ethereum model, a certain amount of gas is deducted before executing a transaction, which can be summarized as follows:
- If creating a contract, 53000 is deducted; if calling a contract, 21000 is deducted
- A certain amount of gas is deducted based on the length and byte type of the contract code
👉 Relevant code implementation
In other words, a portion of implicit gas is consumed before executing the transaction, which cannot be calculated during execution. Therefore, UserOperation needs to specify preVerificationGas to subsidize the Signer. However, this implicit gas can be calculated off-chain, and the official SDK provides relevant interfaces that we can simply call.
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
As the name suggests, this is the GasLimit allocated during the verification phase, which is used in three cases:
- If UserOp.sender does not exist, execute UserOp.initCode to initialize the Account
- Execute Account.validateUserOp to verify the signature
- If PaymasterAndData exists
- Call Paymaster.validatePaymasterUserOp during the verification phase
- Call Paymaster.postOp at the end of the phase
senderCreator.createSender{gas : verificationGasLimit}(initCode);
IAccount(sender).validateUserOp{gas : verificationGasLimit}
uint256 gas = verificationGasLimit - gasUsedByValidateAccountPrepayment;
IPaymaster(paymaster).validatePaymasterUserOp{gas : gas}
IPaymaster(paymaster).postOp{gas : verificationGasLimit}
It can be seen that verificationGasLimit basically represents the total gas limit for all the above operations, but it is not a strict limit and may not be accurate, because the calls to createSender and validateUserOp are independent, which means that in the worst case, the actual gas consumption may be twice the verificationGasLimit.
Therefore, to ensure that the gas total consumption of createSender, validateUserOp, and validatePaymasterUserOp does not exceed verificationGasLimit, we need to predict the gas consumption of these three operations.
Among these, createSender can be accurately predicted, and here we can use the traditional estimateGas method for prediction.
// userOp.initCode = [factory, initCodeData]
const createSenderGas = await provider.estimateGas({
from: entryPoint,
to: factory,
data: initCodeData,
});
Why should from be set to the entryPoint address? Because basically, most Accounts will set a source (i.e. entryPoint) when created, and calling validateUserOp will verify the source.
Other similar methods like validateUserOp and validatePaymasterUserOp are currently not easy to predict. However, due to the nature of the methods to validate the validity of UserOp (mostly verifying the signature), the gas consumption itself will not be very high. In actual operation, a GasLimit of 100000 can basically cover the consumption of these methods. Therefore, we can set verificationGasLimit as:
verificationGasLimit = 100000 + createSenderGas;
callGasLimit
callGasLimit represents the actual gas consumption of executing callData in the Account, and is the most important part of gas prediction. So how do we predict the gas consumption of this part? It can be implemented using the traditional estimateGas as follows:
const callGasLimit = await provider.estimateGas({
from: entryPoint,
to: userOp.sender,
data: userOp.callData,
});
Here, we simulate calling the method of the Sender Account from the entryPoint, passing the Account's source check and bypassing the signature verification step in validateUserOp (because the UserOp in the eth_estimateUserOperationGas interface does not have a signature).
There is a problem here, which is that the premise of this prediction is that the Sender Account exists. If it is the first transaction of the Account (the Account has not been deployed and needs to execute initCode first), this prediction will revert due to the non-existence of the Account. It is not possible to accurately estimate the callGasLimit.
How to Obtain the callGasLimit for the First Transaction
Since it is not possible to obtain an accurate callGasLimit in the case of the first transaction, do we have any other solutions? Of course, we can estimate the TotalGasUsed of the entire UserOp first, and then subtract createSenderGas to obtain an approximate value.
otherVerificationGasUsed = validateUserOpGasUsed + validatePaymasterGasUsed
TotalGasUsed - createSenderGasUsed = otherVerificationGasUsed + callGas
Here, otherVerificationGasUsed represents the actual consumption of validateUserOp and validatePaymasterUserOp, because as mentioned earlier, the gas consumption of these methods is not very high (basically within 100,000). So we can consider otherVerificationGasUsed as part of the callGasLimit, i.e.,
otherVerificationGasUsed + callGas = callGasLimit
How to Obtain the GasUsed of HandleOps without a Signature
Because in the eth_estimateUserOperation interface, the UserOperation passed up can be without a signature, which means we cannot use the traditional eth_estimateGas(entryPoint.handleOps) to obtain the gas required to execute the UserOp. This simulation will definitely throw an error because the EntryPoint will fail the signature validation in the validate phase and revert.
So, what way can we obtain a fairly accurate GasUsed? The answer, of course, is that the developers of EntryPoint have thoughtfully provided us with the simulateHandleOp method, which can fully simulate the execution process of the entire transaction without the signature of the UserOp. Its actual method is to not return a value after failing the validation in your validate phase, in order to bypass the validation check. Of course, this method will ultimately revert, which means you can only call this interface through 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);
}
We know from the return value that the second parameter is paid:
paid = gasUsed * gasPrice
So, as long as we set gasPrice to 1, paid is the gasUsed.
We find that there is no gasPrice field in UserOp, but something similar to EIP-1559's maxFeePerGas and maxPriorityFeePerGas. Of course, this is just the design of UserOp and does not mean that the AA protocol cannot run on non-EIP-1559 chains. In fact, in the implementation of EntryPoint, maxFeePerGas and maxPriorityFeePerGas are also used to calculate a more reasonable gasPrice. Let's look at the formula:
gasPrice = min(maxFeePerGas, maxPriorityFeePerGas + block.basefee)
Chains that do not support EIP-1559 can be considered to have a basefee of 0, so we only need to set maxFeePerGas and maxPriorityFeePerGas to 1, making gasPrice 1.
In summary, we have figured out how to simulate the specific GasUsed of UserOp without a signature, and can therefore calculate an approximate callGasLimit.
Fee Estimation
Predicting Gas Fee, i.e. maxFeePerGas and maxPriorityFeePerGas, is also very important. This is because the Bundler's Signer cannot lose money.
First, if the gasFee of the user's UserOp is less than the Signer's gasFee, then after executing the UserOp, the calculated cost of the UserOp is not enough to subsidize the cost of the Signer, resulting in a loss for the Signer. Because the Bundler's Signer does not bear the function of paying for UserOp fees, it is only for sending transactions. Therefore, the Signer needs to deposit a certain balance in advance. If there is a loss, it will directly affect the execution of subsequent UserOps, which will also affect the normal operation of the Bundler. Also, because the Signer has costs, generally the Bundler will only maintain a limited number of Signers. If the Bundler needs to support multiple chains, the maintenance cost will also increase. The entities responsible for paying UserOp fees should be the Sender itself and the Paymaster.
Of course, the most ideal situation is that the gasFee of the UserOp should be close to the Signer's gasFee. Therefore, I believe that the Bundler should return recommended maxFeePerGas and maxPriorityFeePerGas in eth_estimateUserOperationGas, which can greatly reduce the cost of the user's UserOp.
Of course, if the UserOp's GasFee is very low, we can also put UserOps with a GasFee lower than the Signer's GasFee into the UserOp pool, and wait until the Signer's GasFee is low enough to package the UserOp. However, in practice, these UserOps often need to wait a long time to be executed, which is not good for user experience.
免责声明:本文章仅代表作者个人观点,不代表本平台的立场和观点。本文章仅供信息分享,不构成对任何人的任何投资建议。用户与作者之间的任何争议,与本平台无关。如网页中刊载的文章或图片涉及侵权,请提供相关的权利证明和身份证明发送邮件到support@aicoin.com,本平台相关工作人员将会进行核查。