ERC-4337: Predicting the Gas Consumption of UserOperation

ERC-4337: Predicting the Gas Consumption of UserOperation

Table of Contents:

ERC-4337 Bundlers serve two core functions:

  • Predicting the gas costs for a UserOperation, i.e., eth_estimateUserOperationGas.
  • Packaging and submitting the UserOperation to the chain, i.e., eth_sendUserOperation.

Among these, predicting the gas costs for UserOperations is one of the most challenging parts of the Bundler. As we recently open-sourced our own Bundler implementation, this article will discuss the problems we have encountered in the process of gas prediction and their corresponding solutions.

We will also focus on enacting a gas fee prediction, which is not within the scope of the ERC-4337 protocol but is an inevitable matter regarding Bundler implementations.

Gas Estimation

Let’s first consider that, in an AA framework, the user's account is a contract. When the EVM executes a transaction involving a contract, loading said contract typically incurs gas costs. Additionally, the user's UserOp will be encapsulated in a transaction and sent to the chain, where it will be executed by the unified EntryPoint contract. Therefore, even for a regular transfer, the gas consumption will be several times higher than that of a regular EOA transfer.

In theory, to deal with the above, one can set a large GasLimit to avoid different complex situations. This is quite simple, although it requires the user's account to have a considerable balance to deduct this fee in advance, which turns out impractical. An ideal solution would be to have a way to accurately estimate gas consumption, allowing users to make regular transactions within a reasonable range, greatly improving user experience and reducing transaction barriers.

According to the official documentation of ERC-4337, there are three fields related to gas estimation:

  • preVerificationGas
  • verificationGasLimit
  • callGasLimit

Let's look into these fields individually and provide a gas prediction method for each.

preVerificationGas

First, we need to understand that a UserOperation is a structure packaged into a transaction by the Bundler’s Signer, and sent to the chain for execution. During the execution process, the gas consumed comes from the Signer, and after execution, it 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:

  • Creating a contract deducts 53,000 gas, while calling a contract deducts 21,000 gas.
  • Gas is deducted based on the length and byte type of the contract’s code.

👉 Related code implementation

In other words, a certain amount of implicit gas is consumed before executing a transaction and cannot be calculated during execution. Therefore, the UserOperation needs to specify preVerificationGas to subsidize the Signer. However, this implicit gas can be calculated off-chain. The official SDK provides relevant interfaces that we can 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 assigned during the verification phase. There are three cases where this GasLimit is used:

  • If UserOp.sender does not exist, UserOp.initCode is executed to initialize the Account.
  • Account.validateUserOp is executed to validate the signature.
  • If PaymasterAndData exists:
    • Paymaster.validatePaymasterUserOp is called during the verification phase.
    • Paymaster.postOp is called during the completion 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}

    

As we can see, verificationGasLimit represents the overall gas limit for all the operations mentioned above. However, it is not a strict limit and may not be accurate, as the invocations of createSender and validateUserOp are independent. This means that in a worst-case scenario, the actual gas consumption could be twice the verificationGasLimit.

Therefore, to ensure that the gas consumption of createSender, validateUserOp, and validatePaymasterUserOp does not exceed the verificationGasLimit, we need to predict the gas consumption of these three operations.

The gas consumption of createSender can be accurately predicted using the traditional estimateGas method.


// userOp.initCode = [factory, initCodeData]
const createSenderGas = await provider.estimateGas({
    from: entryPoint,
    to: factory,
    data: initCodeData,
});

    

Why does from need to be set as the entryPoint address? Because most Accounts will set a source (i.e., EntryPoint) when they are created. Calling validateUserOp will validate the source.

For other methods like validateUserOp and validatePaymasterUserOp, gas consumption is currently difficult to predict. However, due to the nature of these methods, which is to check the validity of UserOp (most likely to validate the signature), gas consumption is not very high. In practice, a GasLimit of 100,000 can cover the consumption of these methods. Therefore, based on the above, we can set verificationGasLimit as


verificationGasLimit = 100000 + createSenderGas;

    

callGasLimit

callGasLimit represents the gas consumption of executing callData by an Account and is the most important part of gas predictions. So, how do we predict the gas consumption of this process? We can use the conventional 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, bypassing the source check of the Account, and also 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 this prediction is based on the assumption that the Sender Account exists. If it is the first transaction of the Account (the Account has not been deployed yet 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 get the callGasLimit for the first UserOperation

Since we cannot obtain an accurate callGasLimit for the first transaction, do we have other solutions? We do. We can first estimate the TotalGasUsed of the entire UserOp, and then subtract the createSenderGas from the total TotalGasUsed to get an approximate value.


otherVerificationGasUsed = validateUserOpGasUsed + validatePaymasterGasUsed
TotalGasUsed - createSenderGasUsed = otherVerificationGasUsed + callGas

    

Here, otherVerificationGasUsed refers to the actual gas consumption of validateUserOp and validatePaymasterUserOp. Based on this, the gas consumption of these methods is not expected to be significant (typically within 100,000). Therefore, we can consider otherVerificationGasUsed as part of the callGasLimit, i.e.


otherVerificationGasUsed + callGas = callGasLimit

    

How to get GasUsed for HandleOps without a signature

In the eth_estimateUserOperation interface, the UserOperation passed does not necessarily include a signature. This means that we cannot use eth_estimateGas(entryPoint.handleOps) to obtain the gas required for executing the UserOp. This estimation will result in an error because the EntryPoint reverts if the signature validation fails.

So, how can we obtain an accurate GasUsed without a signature? The answer is to use the simulateHandleOp method provided by the EntryPoint contract. This method allows us to simulate the entire execution process of the transaction without the need for a signature. It achieves this by bypassing signature validation during the validate phase and not returning a value. However, note that this method ultimately reverts, which means it can only be called using 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 can determine that the second parameter, paid, represents the gas used.


paid = gasUsed * gasPrice

    

Therefore, if we set gasPrice to 1, paid will equal gasUsed.

We notice that the UserOp does not have a gasPrice field, but instead has maxFeePerGas and maxPriorityFeePerGas, similar to EIP-1559. However, this is only the design of the UserOp and does not imply that the AA protocol cannot run on non-EIP-1559 chains. In fact, in the implementation of EntryPoint, maxFeePerGas, and maxPriorityFeePerGas are used to calculate a more reasonable gasPrice. Let's look at the formula:


gasPrice = min(maxFeePerGas, maxPriorityFeePerGas + block.basefee)

    

For chains that do not support EIP-1559, we can consider the basefee as 0. Therefore, we just need to set maxFeePerGas and maxPriorityFeePerGas to 1, making the gasPrice 1.

To summarize, we have covered how to simulate the specific GasUsed for UserOp without a signature. This allows us to approximate the callGasLimit.

Fee Estimation

Gas Fee prediction, also known as maxFeePerGas and maxPriorityFeePerGas, is also very important. This is because the Bundler's Signer cannot lose money.

Firstly, if the gasFee of the user's UserOp is less than the Signer's gasFee, then after executing the UserOp, the calculated fee of the UserOp is not enough to subsidize the Signer's fee, causing the Signer to lose money. The Bundler's Signer does not bear the responsibility of the UserOp fee, it only sends 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 and the normal operation of the Bundler. Also, because the Signer has costs, Bundler usually maintains a limited number of Signers. If the Bundler needs to support multiple chains, the maintenance cost will also increase. The entity responsible for paying the UserOp fee should be the Sender itself and the Paymaster.

Of course, the 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 the recommended maxFeePerGas and maxPriorityFeePerGas from eth_estimateUserOperationGas. This can greatly reduce the fee of the UserOp.

if the gasFee of the UserOp is very low, we can also put the UserOp that is 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, this type of UserOp often needs to wait a long time to be executed, which is not good for user experience.

Therefore, under normal circumstances, we can return slightly higher maxFeePerGas and maxPriorityFeePerGas than the Signer's gasFee, so that the UserOp can be executed immediately when sent.

L2 Fee Estimation

The above solution can only solve the L1 Fee Estimation. Why can't it be applied to L2?

Because L2 relies on L1 as a data security guarantee. After executing a certain number of L2 Transactions, a Rollup proof is generated and sent to L1. Therefore, the L2 Transaction Fee includes an implicit L1 Fee.


L2 Transaction Fee = L2GasPrice * L2GasUsed + L1 Fee

    

This calculation method of the L2 transaction fee creates a problem: Many wallets, such as MetaMask, do not include the L1 fee. If your balance is just enough to satisfy GasPrice * GasLimit, the transaction you send will most likely fail.

If, in an L2, we make the GasPrice of UserOp close to the Signer's GasPrice, undoubtedly, the Signer will bear the cost of the L1 fee, which is not expected. Fortunately, the L1 fee can be calculated.

Usually, L2s provide a GasPriceOracle contract that allows you to quickly obtain the L1 fee.

For example, in Scroll/Base/opBNB/Optimism etc, selecting Optimism as an example, given a gas price oracle, one could simply call the getL1Fee method to obtain the specific L1 fee and convert it into the GasPrice of UserOp.


const signerPaid = gasUsed * signerGasPrice + L1Fee;
const minGasPrice = signerPaid / gasUsed;

    

Other L2s like Taiko have already converted the L1 Fee into the GasPrice, so we don't need to calculate the L1 Fee again.

Final thoughts

By now, we have basically solved the gas prediction problem. However, it should be noted that the gas price of some chains fluctuates greatly, as happens in Polygon. As such, a gas price may become invalid in a short period of time. In practice, we must multiply the predicted gas fee for chains with large fluctuations by a coefficient to mitigate this situation.

You can refer to the Particle Network’s open-source Bundler’s implementation here: https://github.com/Particle-Network/particle-bundler-server

Particle Network is creating an advanced framework for better Web3’s user and developer experiences with our Smart Wallet-as-a-Service (Smart WaaS) offering. As the article highlighted, AA is a game-changer in how dApps and blockchain-based services can be designed, making them more user-friendly, flexible, and adaptive to complex scenarios. To help developers swiftly transition and make the most out of AA, Particle Network's Smart WaaS can enable developers to construct advanced applications, going beyond the typical constraints of the EOA framework.

Particle's Smart WaaS is set apart by its modular design, ensuring developers have the freedom and flexibility they need. Whether it's selecting specific smart account implementations, choosing Bundlers, or plugging into third-party tools, the options are vast and tailored to developer needs. Particle's emphasis on combining the benefits of WaaS with the capabilities of AA translates into a seamless transition for developers, further enhanced by its native support for AA within the platform. With Particle’s Smart Wallet-as-a-Service Modular Stack, developers get a comprehensive toolkit to integrate AA into their dApps effortlessly, heralding a new era of advanced, user-centric blockchain applications.

  1. https://blog.particle.network/particle-network-open-sources-our-account-abstraction-bundler/
  2. https://blog.particle.network/announcing-our-smart-wallet-as-a-service-modular-stack-upgrading-waas-with-erc-4337/
  3. https://www.alchemy.com/blog/erc-4337-gas-estimation
  4. https://www.alchemy.com/blog/user-operation-fee-estimation

Particle Network's Wallet Abstraction solutions are 100% free for developers and teams. By integrating them, you can set your project in a path to leveraging chain abstraction.


About Particle Network

Particle Network Logo

Particle Network powers chain abstraction, addressing Web3's fragmentation of users and liquidity. This is enabled by Particle's Universal Accounts, which give users a unified account and balance across all chains.

Website | Docs | Discord | Twitter

Share this article

About the author(s)

Peter Pan

Peter Pan

CTO at Particle Network.