back

Rick ·

Why does gas estimation fail when my contract call should work?

Dev tooling

Your button does nothing. No wallet popup, no hash, just an error in the console with the word "gas" in it. The call passes forge test, and the contract is obviously fine.

eth_estimateGas is not a calculation. It executes your call — once, against one block's state, with one sender — and returns the smallest gas limit that still succeeds. So a failing estimate is not a bug in your gas math. It is the node telling you that the call it ran did not work, and the useful question is which call it ran.

Which call it ran is decided by the request object, and that object is easy to get wrong precisely because a real transaction fills most of it in for you. I put each one on a local chain, reproduced the failure, fixed exactly that field, and recorded both. Then I sent the same nine requests to every public mainnet endpoint that answered, three times each, because the error text you get back is not a property of Ethereum.

Everything below was measured on 2026-09-22 with Foundry 1.5.1 and 1.8.3, viem 2.56.5, Node.js 22.17.0 and Solidity 0.8.36, on an Apple M3 Pro running Darwin 25.5.0. The contracts, harness and raw results are in this repository under examples/articles/why-does-gas-estimation-fail-when-my-contract-call-should-work. No transaction was ever broadcast to a public network; the mainnet requests are all read-only.

Five steps the node takes with an estimate request. Step one starts the ceiling at the block gas limit, or at the gas field you sent, and can exit with

Step order follows go-ethereum's gas estimator and Anvil's. The quoted messages are the ones this article measured.

Here is the whole matrix, on a vault fixture with an ERC-20 token and an allowlist. Every row is the same call twice, changing one field:

What I changed The estimate said With that field fixed
No from NotAllowlisted(0x0000…0000) 49,808
from set to a non-owner NotOwner(0x7099…79C8) 43,856
No value on a payable function ZeroAmount() 47,394
No ERC-20 approval yet InsufficientAllowance(…, 0, 100e18) 87,840
Approval sent but unmined InsufficientAllowance(…, 0, 100e18) 87,840 at the pending tag
Sender holds no ether, with a fee field gas required exceeds allowance: 0 49,808
A gas field of 21,000 in the request gas required exceeds allowance: 21000 266,657
Someone paused the contract in between Paused() 47,394

Two of those eight never reach the contract: the sender's ether and the gas field are checked before the condition you wrote, and neither has anything to do with it.

The sender the node invents for you

The JSON-RPC specification makes from optional. GenericTransaction, the object both eth_call and eth_estimateGas take, has no required properties at all, and the eth_estimateGas definition says nothing about what a node should do when the field is missing.

In practice nodes substitute the zero address. My allowlisted function proved it locally by naming the caller in its own revert:

NotAllowlisted(0x0000000000000000000000000000000000000000)

It is easier to see on mainnet, because USDC rejects that address by name. The same zero-value transfer request, with and without a from, against seven public endpoints:

{"to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
 "data": "0xa9059cbb000000000000000000000000000000000000000000000000000000000000dead0000000000000000000000000000000000000000000000000000000000000000"}
with a from:     0x9c63  (40,035 gas, identical on all seven, all three passes)
without a from:  execution reverted: ERC20: transfer from the zero address

So the very first thing to check is whether your client sent one. viem's own behaviour here is worth knowing precisely, because it differs between two calls you would reasonably expect to be equivalent. I put a logging proxy in front of the node and read the requests off the wire:

estimateContractGas({ account })  → eth_estimateGas [{ data, from, to }]
estimateContractGas()             → eth_estimateGas [{ data, to }]
simulateContract({ account })     → eth_call        [{ data, from, to }, "latest"]
writeContract()                   → eth_estimateGas [{ data, from, to,
                                                       maxFeePerGas, maxPriorityFeePerGas, nonce }]

Captured against Anvil 1.5.1; the last line has a second form, which the fee section gets to.

viem's documentation lists account among the parameters for estimateContractGas, but omitting it in 2.56.5 does not throw. The request simply goes out without a from, and the node answers about a different account than the one your user is holding. In a browser app the account is whatever the wallet is connected to, so this usually shows up as "it fails for me and works for my colleague", which is exactly backwards: it fails for whoever forgot to thread the account through.

A wrong sender fails the same way and reads better. Estimating an owner-only function from a connected user gives NotOwner(0x7099…79C8) with the user's address inside it, which is a much better error than most — it tells you who the node thought was calling.

Value belongs in the estimate, not just in the send

A payable function that requires ether behaves like a different function when you estimate it without any:

cast estimate $VAULT 'depositEth()' --from $ALICE                   → execution reverted
cast estimate $VAULT 'depositEth()' --from $ALICE --value 1ether    → 47394

This one is easy to introduce in application code, because the two values often live in different places: the amount is in a form field, and the estimate is built by a helper that only knows the function name and its arguments. value is not an argument. It has to be copied into the estimate request explicitly, and viem and cast both take it as a separate field.

value also participates in the balance check two sections down, so an estimate that is missing it can fail in two different ways depending on how much ether the sender has.

The approval that has not landed yet

The classic version is boring: no approval, transferFrom reverts, the estimate carries the token's error.

InsufficientAllowance(0x7099…79C8, 0xe7f1…0512, 0, 100000000000000000000)

The interesting version is the one where the user did approve. An app that sends approve and then immediately estimates deposit is asking about a block the approval is not in yet. With automining switched off so the approval stays in the mempool:

Block tag eth_estimateGas
latest InsufficientAllowance(…, 0, 100e18)
pending 87,840
latest, after the approval is mined 87,840

The pending tag is the second parameter of eth_estimateGas, and every one of the seven public endpoints accepted it in every pass. That makes it tempting as a fix, and it is the wrong one. pending is that node's view of its own mempool. It says nothing about whether the approval will be included, whether it will be included before your deposit, or whether the endpoint you send the deposit to has even heard of it. A load-balanced public endpoint can answer pending from a backend that never saw your approval at all.

Waiting for the receipt is the fix. pending is a diagnostic: if latest fails and pending succeeds, you have proved the problem is an unmined transaction rather than your code.

"Gas required exceeds allowance (0)" has nothing to do with gas

This is the one that costs people afternoons, because it is a message about ether wearing the vocabulary of gas.

Same account, same call, same node — Anvil 1.5.1 here, and the release matters by the end of this section:

// The account is allowlisted. Its ether balance is zero.
await publicClient.estimateContractGas({ address: vault, abi, functionName: "claimBonus", account });
// → 49808n

await walletClient.writeContract({ address: vault, abi, functionName: "claimBonus" });
// → Out of gas: gas required exceeds allowance: 0

The difference is in the request shapes above. estimateContractGas sends no fee fields; writeContract prepares the transaction first, so its estimate carries maxFeePerGas. And the node only checks the sender's balance when a fee field is present. From go-ethereum:

// eth/gasestimator/gasestimator.go, v1.17.5
if feeCap.BitLen() != 0 {
    balance := opts.State.GetBalance(call.From).Clone()
    // ...
    allowance := new(uint256.Int).Div(available, feeCap)
    if allowance.IsUint64() && hi > allowance.Uint64() {
        hi = allowance.Uint64()
    }
}
// ...
return 0, nil, fmt.Errorf("gas required exceeds allowance (%d)", hi)

Anvil implements the same rule, and its source comment is explicit about the asymmetry: "calls without from historically estimate against the default caller and must not be capped by the zero address balance."

The number in the parentheses is the ceiling the search was given, not the gas your call needs. I varied the sender's balance at a fixed 1 gwei gas price to check that it really is the quotient:

Balance Balance ÷ gas price Result
0 wei 0 gas required exceeds allowance: 0
21,000 gwei 21,000 gas required exceeds allowance: 21000
40,000 gwei 40,000 gas required exceeds allowance: 40000
49,808 gwei 49,808 49,808
60,000 gwei 60,000 49,808

Five for five. The call needs 49,808 gas, and the moment the account can afford exactly that much, the estimate returns it.

Which fee field you send changes the answer on some endpoints. I repeated the empty-account probe three times against each public endpoint, sending no fee field, a legacy gasPrice, and an EIP-1559 maxFeePerGas pair:

Fee field in the request Endpoints that still returned an estimate
none 7 of 7
gasPrice: 1 gwei 2 of 7 — eth.drpc.org and xrpc.cl/eth
maxFeePerGas: 1 gwei 0 of 7

Stable across all three passes on every endpoint except 1rpc, whose rate limiting made one cell unusable. So an app that checks "can this user afford the gas" by watching the estimate fail gets a different answer from drpc depending on which transaction type it is building.

The write path has a second fork, and it is why the same failure reads completely differently on two nodes. viem's documentation for prepareTransactionRequest says only that it "populates a nonce, gas limit, fee values, and a transaction type"; the installed source says how. prepareTransactionRequest.js first attempts eth_fillTransaction, which hands the whole job to the node, and remembers per client if the node answers MethodNotFoundRpcError. Capturing every JSON-RPC call viem made from an empty account:

Anvil 1.5.1     eth_fillTransaction      ✗ Method not found
                eth_estimateGas          ✗ Out of gas: gas required exceeds allowance: 0
                → "Execution reverted with reason: Out of gas: gas required exceeds allowance: 0."

Anvil 1.8.3     eth_fillTransaction      ✓
                eth_sendRawTransaction   ✗ Insufficient funds for gas * price + value
                → "The total cost (gas * gas fee + value) of executing this transaction
                   exceeds the balance of the account."

Identical account, identical contract, identical balance. One release tells the user their transaction reverted; the other tells them they cannot afford it.

eth_fillTransaction was only added to the execution APIs on 24 July 2026, and one of the seven public endpoints implements it. ethereum-rpc.publicnode.com filled the transaction for the empty account without complaint, returning a gas field of 40,035 — alongside a raw field that the same pull request removed from the result schema. So the confusing message is still the one most production apps will see, and the accurate one is arriving unevenly.

Two practical consequences. First, an estimate with gasPrice: 0 separates the two questions completely: if it succeeds, your contract condition is satisfied and the account simply cannot pay. Second, the same message appears when you put a gas field in the estimate request, because the ceiling comes from there instead — gas: 21000 on a call needing 266,657 produces gas required exceeds allowance: 21000. A gas field in an estimate is an upper bound on the search, never a hint.

The estimate is exact, which is the problem

There is no headroom in the answer. Four calls, estimated and then sent:

Call Estimate Receipt gasUsed Difference
Plain ether transfer 21,000 21,000 0
claimBonus(), one SSTORE 49,808 49,808 0
processQueue(), 2 queued items 58,265 48,665 9,600
processQueue(), 10 queued items 85,037 68,030 17,007

The first two are exact to the gas unit. Send the estimate as your limit and you have precisely enough, with nothing spare for a state change between now and inclusion.

The last two are not measurement noise, and the gap is not headroom either. processQueue deletes its queue, and EIP-3529 refunds 4,800 gas per cleared slot, capped at gas_used // 5. Two items refund 2 × 4,800 = 9,600, under the cap of 11,653. Ten items would refund 48,000, so the cap binds and the refund is exactly 17,007 — which is 85,037 // 5.

That distinction matters more than it looks. eth_estimateGas returns the gas you must make available; the receipt reports what you were charged. Sizing a limit from a previous receipt, which is a natural thing to do, under-funds every call that clears storage.

A correct estimate describes a block that has already gone

The vault's processQueue costs about 5,600 gas per queued item, and anyone can add items. So I estimated it with two items in the queue, let thirty-eight more arrive, and sent the transaction with the limit I had been given.

Measured gas against queue length from 0 to 40 items. The eth_estimateGas line rises from 28,395 at zero items to 252,357 at forty. The gas the receipt reports after refunds rises from about 26,000 to 201,886. A dashed horizontal rule marks the gas limit taken from the estimate at two items, 43,965, and the estimate line crosses it at four items.

Every sample was taken from the same snapshot, with the queue asserted empty again after each one. The blue line is what eth_estimateGas returns at that queue length; the orange line is what the receipt reports after refunds.

Two extra items are enough. The estimate taken at length two was 43,965; at length four the call already needs more than that.

At forty items the fresh estimate was 249,557 against the stale 41,165 — six times larger. The transaction I sent with the stale limit came back status: "reverted" with gasUsed equal to the full 41,165. That is the shape of an out-of-gas failure and it is worth recognising: the limit and the gas used are the same number, and the sender pays all of it for nothing.

A quieter version of the same effect hits first-time users. claimBonus() estimates at 49,808 for an account whose balance slot is still zero and 32,708 once it is not — a 17,100 gas difference on identical calldata. That is the gap EIP-2929 sets between a cold zero-to-nonzero SSTORE at 20,000 + 2,100 and a cold update at 2,900 + 2,100. Any limit derived from an existing user's transaction is too small for a new one.

Re-estimate immediately before signing, and add a buffer proportional to how much of the call's cost depends on state other people can change. A fixed 20% is a habit, not an analysis — it is generous for a transfer and useless for a loop over a queue.

The same request, seven public endpoints

Estimation itself turned out to be boringly consistent. The same USDC call returned exactly 40,035 gas from every endpoint, in every one of the 40 passes that produced a number — the other two of 42, both from 1rpc, were rate-limit responses. Sending a gas field of 4,294,967,295, far above any block limit, changed nothing anywhere: the RPC gas cap silently replaces it.

The differences are in everything around the estimate:

Endpoint Revert data eth_call Archive at block 16,777,216 Balance check, gasPrice eth_fillTransaction
mainnet.gateway.tenderly.co yes yes yes enforced, as error code 3 no
rpc.mevblocker.io yes yes yes enforced, two messages no
eth.drpc.org yes yes yes skipped no
xrpc.cl/eth never executed never executed yes skipped no
ethereum-rpc.publicnode.com yes yes no, token required enforced yes
1rpc.io/eth yes yes mixed enforced, with rate limits no
rpc.flashbots.net yes not whitelisted no, pruned enforced no

Four of those differences will change something you have written.

The balance check is optional, and so is the method that replaces it. drpc and xrpc.cl returned a normal estimate for the legacy-priced unfunded account that five other endpoints rejected, and publicnode is the one endpoint that answers eth_fillTransaction — which is enough to change the error a user sees for a problem none of these differences caused.

The error code is not reliable either. Every endpoint that executed the call used code 3 for the revert, as the specification requires. But tenderly returns the balance failure under code 3 as well, where everyone else uses -32000, and Anvil uses -32003. A client that branches on code 3 to mean "the contract rejected this" will tell a user with an empty wallet that their input was invalid.

eth_call is not guaranteed to be there. Flashbots answered eth_estimateGas in every pass and refused eth_call in every pass with -32601: rpc method is not whitelisted. The usual debugging advice — when the estimate fails, re-run it as a call to see the revert reason — does not work on an endpoint like that.

Some failures are not about your call at all. xrpc.cl answered the successful estimate fine but returned historical state d7f8974f… is not available for every request that would have reverted, in both runs and again twenty minutes later in an unrelated probe, quoting the same state root every time. That is an unhelpful answer to a question about your contract, and there is no way to tell it apart from a real problem except by asking a second endpoint.

Public endpoints are load balancers over heterogeneous backends, which is why a single pass is not evidence. On my first attempt, two endpoints looked as though they did not return revert data at all. Repeating each probe three times, twice, showed that one had been rate-limited and the other had never executed the call — neither was a fact about revert data. The same caution applies to the endpoint lists themselves, which rot quickly.

What to run when the estimate fails

From a terminal, with Foundry:

# 1. Does it fail without the fee check? A zero gas price removes the balance ceiling.
cast estimate $CONTRACT 'claimBonus()' --from $USER --gas-price 0 --rpc-url $RPC_URL

# 2. If it still fails, ask for the revert reason as a call.
cast call $CONTRACT 'deposit(uint256)' 100000000000000000000 --from $USER --rpc-url $RPC_URL

# 3. If anything prints a bare selector, decode it.
cast decode-error 0x6f7a52750000000000000000000000000000000000000000000000000000000000000000

Against an allowlisted account holding no ether, step one answers the question on its own:

--gas-price 0       49808
--gas-price 1gwei   Error: server returned an error response: error code -32003:
                    Out of gas: gas required exceeds allowance: 0

And step three names an error from four bytes plus its arguments:

NotAllowlisted(address)
0x0000000000000000000000000000000000000000

Step two is worth a version note. On Foundry 1.5.1, cast call prints the raw selector for a custom error:

execution reverted: custom error 0x91beda24: 0000…

On 1.8.3 it decodes:

execution reverted: InsufficientAllowance(0x7099…79C8, 0xe7f1…0512, 0, 100000000000000000000)

cast estimate does not decode on either release. Two of my three custom errors came back as mojibake — execution reverted: ozRu is the four selector bytes of NotAllowlisted(address) rendered as text. Copy the hex out of the data: field and run cast decode-error on it. That resolves names from a machine-local cache that forge build writes, so it works on your laptop and prints raw selectors in CI; the revert-debugging article has the details of that cache.

From application code, read the shape rather than the string. The two failures produce different cause chains, and the difference is the classification you want:

no allowance          ContractFunctionExecutionError
                       └─ ContractFunctionRevertedError   ← the revert data lives here
                           └─ EstimateGasExecutionError → ExecutionRevertedError → RpcRequestError

cannot pay the fee    ContractFunctionExecutionError
                       └─ EstimateGasExecutionError       ← no ContractFunctionRevertedError anywhere
                           └─ ExecutionRevertedError → TransactionRejectedRpcError → RpcRequestError

err.walk(e => e instanceof ContractFunctionRevertedError) returns something for the first and nothing for the second, which is a cleaner test than matching on words. It needs to be, because viem's own summary of the second one is Execution reverted with reason: Out of gas: gas required exceeds allowance: 0 — the library repeats the node's framing and calls an empty wallet a revert.

One thing that chain will not give you is the error's name, if the error belongs to a contract your ABI does not describe. InsufficientAllowance is the token's, not the vault's, so viem resolved it to AbiErrorSignatureNotFoundError and left errorName null. Decoding it needs the token's ABI as well, or cast decode-error.

Where I would look first

In order, because this is roughly the order of how often each one is the answer:

  1. Is there a from in the request? Log the request, not the wrapper. A missing sender is the single most common cause and it is invisible from the error text unless the contract happens to name the caller.
  2. Does the account have ether? If the message mentions an allowance with a number in it, you are reading a fee ceiling, not a gas requirement. Re-run with gasPrice: 0 to confirm.
  3. Does pending succeed where latest fails? Then you are estimating against a state that is missing a transaction you just sent.
  4. Does a second endpoint agree? Two of seven disagreed with the rest about whether an unfunded sender is even an error.
  5. Did the state move? Re-estimate and compare. If the two numbers differ, the call's cost depends on something you do not control, and a fixed buffer is not a plan.

What made all of this tractable, in the end, was giving up on reading the error text. eth_estimateGas is not a gas calculator that occasionally gets confused. It is the cheapest honest simulation available, and it fails for the same reasons the transaction would have, plus two that belong to the request rather than the chain: the sender the node picked when you sent none, and the ceiling it derived from a fee you did not mean to send. Both of those are visible in the request object. None of them are reliably visible in the message.