0%
HomeBlogTechnical
How ERC-4337 Gas Estimation Works

How ERC-4337 Gas Estimation Works


Published on July 11, 202310 min read

As Alchemy built its ERC-4337 Bundler, called “Rundler”, the most challenging component to get correct has been user operation gas estimation. This post will explain the obstacles we encountered attempting to provide users with accurate gas estimates, and the solutions we currently employ.

This is a technical overview meant for ERC-4337 builders. If you’re new to ERC-4337 style account abstraction we suggest you start by reading our intro to Account Abstraction series.

Providing accurate user operation gas estimations is important to the user experience of ERC-4337. If a gas estimate is too low, a user operation may revert during simulation, or worse, revert onchain during the execution phase, leaving the user to pay for gas of a reverted operation. If gas estimation is too high, a user may be dissuaded from, or unable to, send their operation due to costs.

While it’s important to be accurate, gas estimation does not need to be 100% correct, as long as the errors are always overestimating (but not by too much). In ERC-4337 gas fields are represented as limits*, and the user is refunded for any gas they don’t consume onchain. Thus, gas estimation doesn’t impact the actual operation cost.

💡 *Except for preVerificationGas, more on this later.

The gas fields in a user operation, and their definitions from the ERC-4337 spec, are:

  • preVerificationGas: The amount of gas to pay to compensate the bundler for pre-verification execution and calldata.

  • verificationGasLimit: The amount of gas to allocate for the verification step.

  • callGasLimit: The amount of gas to allocate for the main execution call.

  • maxFeePerGas : Maximum fee per gas (similar to EIP-1559 max_fee_per_gas).

  • maxPriorityFeePerGas: Maximum priority fee per gas (similar to EIP-1559 max_priority_fee_per_gas).

An ERC-4337 bundler pays the cost upfront to send a bundle transaction to the entry point contract. The entry point will meter the gas used by each user operation, multiply that by the calculated fee, and compensate the bundler for this value after the user operation completes.

The effective calculation looks like this:*

Copied
uint256 gasFee = min(maxFeePerGas, maxPriorityFeePerGas + block.basefee); uint256 gasUsed = preVerificationGas + meteredVerificationGas + meteredCallGas; uint256 gasCost = gasFee * gasUsed; (bool success,) = bundler.call{value : gasCost}("");

💡 *For simplicity this is only for networks that support EIP-1559.

preVerificationGas is added as is, while verification and call gas are “metered”. That is, their gas usage is measured by the entry point on chain and they are charged for the exact amount they use, up to their limit. If the limit is hit in either the verification or call phases, the operation will revert.

The entry point has no way to process gas refunds, as it meters gas as part of a transaction, while gas refunds are issued after a transaction completes and thus are returned directly to the Bundler. This is an extra source of cost for users, and a potential source of revenue for bundlers.

The typical flow for sending a user operation consists of the following:

  1. Construct a partial user operation with sender, nonce, initCode, and callData populated.
    a. Also, populate signature and paymasterAndData with “dummy” values.

  2. Estimate gas for this partial user operation via a bundler RPC with eth_estimateUserOperationGas.
    a. Populate preVerificationGas, verificationGasLimit, callGasLimit from the return value.

  3. Estimate the required gas fees for the operation and populate maxFeePerGas and maxPriorityFeePerGas.
    a. This step isn’t dependent on steps 1 or 2, can be run at any time, or in parallel.

  4. (Optional) Send their user operation to a sponsoring paymaster for signing
    a. Populate paymasterAndData from the return value.

  5. Sign the above user op, populate signature, and send to a bundler via eth_sendUserOperation.

This post will focus on step 2 and the bundler RPC method eth_estimateUserOperationGas.

eth_estimateUserOperationGas is an RPC method that bundlers must support as per the ERC-4337 specification.

Its definition:

Estimate the gas values for a UserOperation. Given UserOperation optionally without gas limits and gas prices, return the needed gas limits. The signature field is ignored by the wallet, so that the operation will not require user’s approval. Still, it might require putting a “semi-valid” signature (e.g. a signature in the right length).

Parameters: same as eth_sendUserOperation gas limits (and prices) parameters are optional, but are used if specified. maxFeePerGas and maxPriorityFeePerGas default to zero, so no payment is required by neither account nor paymaster.

Return Values:

  • preVerificationGas gas overhead of this UserOperation

  • verificationGasLimit actual gas used by the validation of this UserOperation

  • callGasLimit value used by inner account execution

PreVerificationGas is used to capture any gas usage that the entry point doesn’t meter, compensating the bundler for this gas.

In the simple case this can be broken down into 3 separate calculations:

  1. The operation’s share of the intrinsic gas for the bundle transaction.
    a. Note that the bundler must assume a bundle size to determine this up front.

  2. The operation’s share of the calldata gas cost.
    a. This is directly attributable to the size and byte composition of the operation.

  3. The operation’s share of any execution gas overhead that the entry point incurs outside of what is metered.
    a. This is determined off-chain by doing a gas usage analysis of the entry point contract and attributing it on a per user operation basis.

Note that preVerificationGas is not a limit. That is, the value set in this field is always paid to the bundler in full. Be careful with this field, as an incorrect value could mean sending the bundler more in fees than is needed!

Check out this in-depth analysis of preVerificationGas written by the StackUp team for more detail.

Bundlers will typically run their preVerificationGas calculation during the “pre-check” phase (e.g. checks run before the full trace-based simulation).

If an operation’s preVerificationGas is lower than the value calculated by the bundler, it will reject it. This may become a source of incompatibility between bundler implementations.

If a particular bundler implementation, L, is estimating a lower value for preVerificationGas , and a different implementation, H, is estimating (and requiring) a higher value, H will reject operations that used L for estimation.

Its yet to be seen how this incompatibility will impact the P2P mempool, but its likely that users will be incentivized to estimate a lightly higher preVerificationGas in order to maximize their changes of being bundled.

Providing accurate estimates for the gas limit fields, verificationGasLimit and callGasLimit is important. The goal can be stated as: provide a function that estimates the gas used during these phases while being sure to only overestimate, but not by so much that it deters the user from sending their operation.

Lets discuss the various attempts we’ve made to provide this function in Rundler.

Standard Ethereum JSON-RPC provides a nice method for estimating gas of transactions, let’s just use eth_estimateGas!

The method goes like this:

  • verificationGasLimit : call the simulateValidation function on the entry point and pass to eth_estimateGas

  • callGasLimit: call the innerHandleOp function on the entry point and pass to eth_estimateGas

This seems to work nicely. While both simulateValidation and innerHandleOp don’t exactly capture the metered portions of each phase, they are strictly supersets and will overestimate slightly.

Note that these calls are completely independent, but onchain execute in a single transaction. In this method the outcome of simulateValidation is not persisted to impact innerHandleOp.

This is the case for the initial deployment of an account, and possibly for some advanced validation schemes. The factory method is called during the validation phase to deploy the account contract. The execution phase requires this contract to be deployed and innerHandleOp will revert if not, failing any estimation attempts.

We need some way to estimate both of the gas limit fields while ensuring that the estimation for the execution phase is run in the same call as the validation phase.

Onto the next attempt…

The ERC-4337 v0.6 implementation provides a simulateHandleOp method that combines both the validation and execution phases into a single function call. Its return value contains preOpGas which is the sum of the gas used during validation and the preVerificationGas. It also contains the total amount paid by the user op, paid.

To determine the callGasLimit, paid can be converted into gas used by dividing by the gas fee. The bundler can determine the gas fee by pinning simulation to a (recent) block height with a known base fee and setting the priority fee to 0. Through some simple conversations, all of the gas fields can be calculated. This method may also overestimate gas used as some of the unmetered entry point logic is attributed to the gas used.

Another reasonable method is to measure the gas used during the call phase is to measure the gas used during the entire simulateHandleOp call and then subtract the gas used by the validation phase.

We can deploy a helper contract that meters the gas used by simulateHandleOp and appends that to the return value. This method has the same issue with overestimation as the previous one.

For those who are picky about overestimates, we can refine this even further.

Can we trace the entry point's simulateHandleOp method to calculate gas?

Bundlers must have the ability to run a trace call in order to perform the simulation checks needed to protect itself from onchain invalidation. Can we use this functionality to estimate gas?

The method goes like this:

  1. Trace the entry point’s simulateHandleOp method, which conveniently uses a “number marker” scheme so that the tracer can know exactly which phase of execution they’re in.

  2. In the tracer, take note of the amount of gas left at the beginning of each phase, and at the very end.

  3. From these values, calculate the amount of gas used by each phase.

Great! We’ve used slightly more expensive tracing to calculate exactly the amount of gas used.

Not so fast, using any of the methods above can cause operations to run out of gas for certain contract calls.

Why?

To answer that we can take hints from how Reth estimates gas. Notice the binary search? Whats going on there? Why doesn’t Reth just use a similar tracing method as above, as it has access to all of the same information?

Well, it turns out that in an EVM function call: gas used ≠ gas required.

An overview of why this is the case can be found in Sergio Lerner’s “The Dark Side of Ethereum 1/64th CALL Gas Reduction” article. The most important of these reasons is called the “63/64th Rule” (or the “1/64th Rule” depending on who you’re talking to).

This rule states that the EVM will only forward 63/64ths of the remaining gas to each function call. The result of this is that you need slightly more gas upfront than what is eventually consumed. This happens for each call, so a validation/execution phase with a deep call stack might need to set aside a large amount of gas upfront to handle the set-aside gas.

We want a solution that works for ALL potential user operations, so this invalidates any attempts that use gas used measurements for gas limit estimation. For our next attempt, lets take inspiration from the EVM clients.

💡 It may be possible to perform a detailed analysis of a tracing function and backtrack the gas used into gas limit by taking into account the 63/64ths rule (and any other EVM intricacies). We have yet to explore this angle.

Lets not reinvent the wheel here. To handle the complexity described above, node clients use binary searches during gas estimation, finding the lowest gas value that leads to a successful transaction. We can do something similar for user operations!

The approach goes like this:

First, note that since binary searching requires many calls to the same function, we want to ensure that the function has high performance. This eliminates using tracing as a reasonable approach here, and we will need to rely on eth_call.

To estimate verificationGasLimit:

  1. Run an attempt of simulateHandleOp at MAX_VERIFICATION_GAS (a bundler setting defining the maximum gas that can be used in the validation phase) to ensure that success is even possible in the first place.
    a. During this run, set callGasLimit to 0 to save computation.

  2. Run the binary search on simulateHandleOp to hone in on the lowest verificationGasLimit value that does not run out of gas.
    a. If the validation phase runs out of gas the call will revert. Since we’ve already verified that the call can succeed with a higher gas limit, any reverts can result in moving the bottom edge of the binary search up.

One nice optimization here is to note that we don’t need a 100% accurate estimation. The bundler can choose how close to exact it wants to be, and terminate the binary search once its within this range, always erring on the high side. This can save many rounds of calls. For example, Rundler sets this error range to 1K gas, saving 10 iterations.

The second optimization to save a few iterations is to determine a better starting point for the search. Multiply the gas used in (1) by a scalar (e.g. Reth uses 3x), and set that to the initial guess for the binary search algorithm.

To estimate callGasLimit:

  1. Set verificationGasLimit to MAX_VERIFICATION_GAS

  2. Run an attempt at MAX_EXECUTION_GAS (not part of the spec, but required for a bundler to protect itself against DOS) and check if execution success is possible.

This is where this estimation method breaks down. Reverts are caught by the entry point and emitted as logs and eth_call does not provide a way to inspect any logs emitted.  It also wastes computation by running validation fully during each attempt.

Can we provide a way to run this callGasLimit binary search?

💡 It is possible to detect this revert if using a trace, but we would like to avoid that for cost reasons. It may be worth revisiting this decision if a bundler is in full control over the node client and can use a native tracer.

Building on the procedure for verificationGasLimit estimation, the core idea behind this method is to run the binary search for callGasLimit in Solidity, encoding logic that can capture if the execution portion fails.

Let’s define the constraints:

  1. The binary search must occur after validation is run to ensure that any account contracts are deployed
    a. A desirable, but not required, constraint would be to only run validation once (or to limit the amount of times) in order to reduce wasted computation.

  2. The execute calls must initiate from the entry point. Many account implementations only allow their execute functions to be called when msg.sender equals a hardcoded entry point address.

  3. The binary search must return its final result.

  4. All entry point functions must behave exactly as they would normally.

The solution we came up with is to use a proxy contract that uses DELEGATECALL to forward to the entry point contract, but adds additional logic. During estimation we use eth_call overrides to move the original entry point contract to a random address, and replace it with the proxy contract. This satisfies constraints (2) and (4).

As noted above, the entry point hides the result of the execution phase. We instead skip the standard execution call (by setting callGasLimit to 0), and utilize the target/targetCallData arguments to simulateHandleOp. This runs after validation and can return information, satisfying constraints (1) and (3). See the entry point code below (comments are added):

Copied
function simulateHandleOp(UserOperation calldata op, address target, bytes calldata targetCallData) external override { UserOpInfo memory opInfo; // VALIDATION IS RUN ONCE PER CALL _simulationOnlyValidations(op); (uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(0, op, opInfo); ValidationData memory data = _intersectTimeRange(validationData, paymasterValidationData); numberMarker(); // SKIP THIS STEP WITH CALLGASLIMIT = 0 uint256 paid = _executeUserOp(0, op, opInfo); numberMarker(); bool targetSuccess; bytes memory targetResult; if (target != address(0)) { // RUN THE BINARY SEARCH HERE (targetSuccess, targetResult) = target.call(targetCallData); } // TARGET RESULT IS RETURNED HERE revert ExecutionResult(opInfo.preOpGas, paid, data.validAfter, data.validUntil, targetSuccess, targetResult); }

For our custom target call we call a method, estimateCallGas, on the proxy contract with call data that encodes:

  1. The account and its call data (from the original user operation)

  2. The binary search parameters

Since the proxy is located at the original entry point’s address, estimateCallGas is able to call the account’s execute function, determine if the call succeeded or ran out of gas, and run the binary search algorithm until success.

Voila! The binary search can now occur in an  eth_call to get an accurate estimate for callGasLimit!

In practice, Rundler breaks this single eth_call into multiple calls, each with a maximum global gas limit, in order to work around node’s maximum call gas limits. Each call will run binary search iterations until the next iteration would cause the call to run out of gas.

💡 This method misses a slight amount of overhead imposed by the entry point during innerHandleOp. This overhead is static and added to the result of the estimation.

Potential Entry Point Change

We’ve been thinking about potential changes to the entry point that could make the gas estimation process described above easier to implement and more performant for users. This isn’t an easy problem to solve so we would love to hear from other bundlers or members of the ERC-4337 community if you have ideas!

We want to ensure that the validation phase runs before the execution phase during gas estimation. The execution phase may rely on state set during the validation phase (i.e. a USDC paymaster transfer and and an execution phase that uses USDC) and thus it’s most accurate to estimate directly after.

One potential improvement would be to have the entry point return a boolean representing if the execution portion reverted or not during simulateHandleOp. The bundler could then run the binary search off-chain without the proxy contract and state overrides. However, this binary search will be inefficient as it will be running the validation phase during each iteration.

This leads us to taking the binary search done by the proxy contract in attempt 3 above and adding its functionality directly to the entry point contract (pseudocode):

Copied
contract EntryPoint { ... function estimateValidationGas(userOp, min, max, rounding) { // run _validatePrepayment once at max gas to see if success is possible // binary search on _validatePrepayment // terminate when binary search completes (within rounding) // or when the next iteration would cause the call to run out of gas // return min and max revert(...) } function estimateCallGas(userOp, min, max, rounding) { // run _validatePrepayment at max gas to set validation state // run target call once at max gas to see if success is possible // binary search on the target call // return min and max // add entry point overhead off-chain revert(...) } }

Our implementation of the above (as a proxy contract) will be open source soon. We think it may be beneficial to ERC-4337 users to have estimation via binary search be part of the entry point contract itself.

Keep an eye out for our open source bundler implementation coming soon! 🦀

The next articles in this 4-part series explore gas estimation challenges we encountered and the solutions our engineering team implemented.

Desktop section background image

Build blockchain magic

Alchemy combines the most powerful web3 developer products and tools with resources, community and legendary support.

Get your API key