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.
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.
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:
- Is there a
fromin 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. - 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: 0to confirm. - Does
pendingsucceed wherelatestfails? Then you are estimating against a state that is missing a transaction you just sent. - Does a second endpoint agree? Two of seven disagreed with the rest about whether an unfunded sender is even an error.
- 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.