Ethras Blog
Diagnose eth_estimateGas Before Increasing the Limit
Separate execution reverts, sender affordability, gas caps, and changing state to make estimation failures actionable.
A transaction builder receives an estimation error and replaces it with a fixed gas limit of 500,000. The form now advances to signing, but the underlying contract still reverts. A failure that could have identified a bad precondition has become an apparently valid transaction candidate. The gas number changed; the execution question was never answered.
This article separates three causes that often arrive through the same RPC error channel: execution rejection, an upper search bound, and sender affordability. We will run a storage-writing fixture and an unconditional revert on a temporary local node, then calculate an explicit affordability ceiling. The aim is to return evidence that tells an application what to change, not to produce a number at every cost.
Estimation searches an execution context
The eth_estimateGas reference describes an estimate of the gas needed for completion. Inputs still include sender, destination, value, calldata, fee-related fields, and selected state. An estimate is conditional on those inputs and on endpoint policy. It does not reserve state or guarantee that a later signed transaction encounters the same program path.
For a concrete client implementation, Geth v1.16.5's estimator begins with an upper allowance, applies fee/balance and RPC caps, tests execution, then narrows a successful range. A non-gas execution failure can terminate before a number is returned. This is versioned implementation evidence, not a universal specification of every client's search strategy.
The distinction explains why the same contract can fail estimation for different reasons. A successful execution may exist above the permitted ceiling. Alternatively, no allowed gas value repairs an unconditional revert. Returning “try more gas” for both cases throws away the branch that an engineer needs to inspect. Preserve the raw response and the immutable candidate before translating the failure into a user-facing category.
Set up two programs with different stopping conditions
The successful fixture contains PUSH1 42; PUSH0; SSTORE; STOP. It writes a previously zero storage slot. The failing fixture contains PUSH0; PUSH0; REVERT, which immediately returns an empty revert payload. These are raw byte sequences, so the result is independent of a Solidity compiler's target selection. Their small size makes the causal difference inspectable.
We use Node.js 24.22.1 and Anvil 1.5.1 with --hardfork cancun. The historical fork is explicit and compatible with the PUSH0 instruction defined in EIP-3855. No production network, private key, or signed transaction participates. Anvil's setup RPC methods modify only this disposable chain; they are not production capabilities to infer from normal execution API support.
The program first estimates the storage write and verifies that an eth_call with the returned limit succeeds. It then supplies an explicit 21,000 gas ceiling and requires failure. After replacing the runtime with unconditional revert, it tries estimation at 100,000 and direct simulation at 200,000. Both must fail. That is the negative control for the idea that a large multiplier repairs a rejected precondition.
Run the complete local diagnosis
Place the official Anvil executable on PATH, save the program as estimate-diagnosis.cjs, and run node estimate-diagnosis.cjs. Set ANVIL_BIN when using a nondefault executable path. Only Node's standard library is required. The child process binds an ephemeral loopback port and is terminated after the experiment.
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const net = require('node:net');
const pause = ms => new Promise(r => setTimeout(r, ms));
const word = n => '0x' + BigInt(n).toString(16).padStart(64, '0');
async function main() {
const port = await new Promise(resolve => {
const s = net.createServer();
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
});
const child = spawn(process.env.ANVIL_BIN || 'anvil', [
'--host', '127.0.0.1', '--port', String(port), '--hardfork', 'cancun', '--silent'
], { stdio: 'ignore' });
let serial = 0;
async function rpc(method, params = []) {
const id = ++serial;
const response = await fetch('http://127.0.0.1:' + port, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
signal: AbortSignal.timeout(3000)
});
const body = await response.json();
assert.equal(body.id, id);
if (body.error) throw Object.assign(Error(body.error.message), { rpc: body.error });
return body.result;
}
try {
let ready = false;
for (let i = 0; i < 50; i++) {
try { await rpc('eth_chainId'); ready = true; break; } catch { await pause(100); }
}
assert.equal(ready, true, 'Anvil did not start');
const [sender] = await rpc('eth_accounts');
const target = '0x' + '22'.repeat(20);
await rpc('anvil_setCode', [target, '0x602a5f5500']);
const call = { from: sender, to: target, data: '0x', value: '0x0' };
const estimate = BigInt(await rpc('eth_estimateGas', [call]));
assert.ok(estimate > 21000n && estimate < 100000n);
assert.equal(await rpc('eth_call', [{...call, gas:'0x'+estimate.toString(16)}, 'latest']), '0x');
await assert.rejects(() => rpc('eth_estimateGas', [{...call, gas:'0x5208'}]));
await rpc('anvil_setCode', [target, '0x5f5ffd']);
await assert.rejects(() => rpc('eth_estimateGas', [{...call, gas:'0x186a0'}]));
await assert.rejects(() => rpc('eth_call', [{...call, gas:'0x30d40'}, 'latest']));
const balance = BigInt(await rpc('eth_getBalance', [sender, 'latest']));
await assert.rejects(() => rpc('eth_estimateGas', [{...call, value:'0x'+(balance+1n).toString(16)}]));
function ceiling(balance, value, feeCap, requested, serverCap) {
if (balance < value || feeCap <= 0n) throw Error('funds input');
return [requested, serverCap, (balance-value)/feeCap].reduce((a,b)=>a<b?a:b);
}
const fundsLimited = ceiling(1000000n, 100000n, 30n, 100000n, 80000n);
const lowerFee = ceiling(1000000n, 100000n, 10n, 100000n, 80000n);
assert.equal(fundsLimited, 30000n); assert.equal(lowerFee, 80000n);
assert.ok(fundsLimited < estimate && lowerFee > estimate);
assert.throws(() => ceiling(1n, 2n, 1n, 100000n, 80000n));
console.log('storage-write estimate=' + estimate + '; call at estimate succeeded');
console.log('low explicit gas rejected; unconditional revert rejected at 100000 and 200000');
console.log('value above balance rejected');
console.log('model: feeCap=30 => ceiling=30000; feeCap=10 => ceiling=80000');
console.log('execution, cap, funds and negative-control assertions passed');
} finally { child.kill('SIGTERM'); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
Actual local output:
storage-write estimate=43105; call at estimate succeeded
low explicit gas rejected; unconditional revert rejected at 100000 and 200000
value above balance rejected
model: feeCap=30 => ceiling=30000; feeCap=10 => ceiling=80000
execution, cap, funds and negative-control assertions passed
The observed storage-write estimate was 43,105 gas on this exact fixture and implementation. It is an observed result, not a universal constant for storage writes. The program's assertion deliberately accepts an appropriate range and then checks behavior at the returned limit. This avoids treating one client's search margin as a protocol requirement while still verifying that the estimate is actionable.
Worked example: a search ceiling below successful execution
For the storage-write candidate, the initial state has slot zero equal to zero. A 100,000-scale budget permits execution, and the estimator returns 43,105. Applying that returned value to the same sender, destination, calldata, and state succeeds. The low explicit candidate has the same semantic fields but gas 21,000; it fails before completing the storage work.
The useful diagnosis is conditional: the lower candidate limit excludes the known successful execution found by the first experiment. It does not say that 43,105 will remain sufficient after a contract upgrade, another state transition, or a fork-rule change. Persist the candidate and state with the estimate so a later failure can be compared against the correct experiment rather than against an isolated integer.
A server gas cap creates a similar shape from the application's perspective, but the client may not know its exact value. If the endpoint refuses higher caps, stop increasing the requested gas after a bounded test and record the unresolved policy boundary. A separate qualified simulation route may investigate the execution, but its successful result does not prove the original endpoint will admit that workload.
Do not compare an estimate for one sender to a call made from another sender. A different role, allowance, or storage branch can change both success and gas use. Likewise, omitting the original value to simplify a reproducer changes execution. A useful minimized reproducer removes only fields proven irrelevant, while retaining a copy of the complete original request for comparison.
Worked example: affordability reduces the search space
Consider a constructed account balance of 1,000,000 wei, value transfer of 100,000 wei, requested ceiling 100,000 gas, and server ceiling 80,000 gas. At fee cap 30 wei per gas, the spendable amount for gas is 900,000 wei. Dividing by 30 yields a fundable ceiling of 30,000 gas. The minimum of requested, server, and fundable ceilings is therefore 30,000.
That ceiling falls below the fixture's observed 43,105 requirement. Lowering the fee cap in this arithmetic model to 10 gives 90,000 fundable gas, so the server ceiling of 80,000 becomes the minimum. The contract program did not become cheaper; the candidate's maximum fee exposure changed. The model tests this exact distinction and rejects value greater than balance as an invalid funding input.
This model intentionally assumes a positive fee cap and excludes blob gas and chain-specific fees. It mirrors only the ordinary balance-bound calculation relevant to the example. The actual Anvil experiment separately requests a value greater than the local account balance and requires estimation to reject it. We do not claim that Anvil and Geth expose identical errors or perform every preliminary check in the same order.
For a dynamic-fee transaction, a conservative ordinary-field exposure bound is value + gasLimit * maxFeePerGas. The eventual effective fee need not equal that cap; EIP-1559 defines the cap and effective pricing separately. Use exact integers with units in the field names, and keep the balance's block identity beside the calculation. A stale displayed balance can make a correct formula irrelevant to the estimator's state.
Preserve revert evidence before decoding it
The unconditional revert case fails at both tested budgets. Since there are no branches or storage preconditions in that runtime, the raw program gives a direct explanation for the observed result. In a real contract, a nested call may revert and its bytes may be propagated through several frames. A decoded message is an interpretation of output bytes, not authenticated attribution to the outer destination.
Keep error code, message, data, and the submitted candidate in a protected record. Classifying execution by matching a single English substring is fragile across clients and gateway adapters. Some failures have useful revert data; others do not. Empty revert bytes are still an execution observation, and an HTTP timeout is not equivalent to that observation even if both display as “simulation failed.”
When an ABI decoder is available, bind it to the contract/interface version used for the experiment. An unknown selector or truncated payload should remain unknown. The original bytes allow a later decoder correction without repeating the state-dependent call. Overwriting them with a friendly sentence removes the best evidence for distinguishing an application precondition from an endpoint problem.
A gas override in a specialist interface can remain an explicit unresolved override. It should retain the failed estimate and require the transaction builder to explain which invariant permits continuing. It must not change a red simulation result into a green success badge merely because a number was entered. The caller should be able to inspect exactly what evidence is missing.
Integrate the stopping rules into the builder
An estimator adapter can return a tagged result such as estimated, invalid-input, execution-rejected, resource-bound, or unresolved-transport. Those tags are application categories, not standardized RPC error codes. Store the original response underneath them so client-specific handling remains reviewable. Only estimated should supply a contextual limit automatically; the other tags need a documented recovery path.
Retries should preserve the original candidate until the experiment intentionally changes one field. If the first attempt used a fixed historical block and the second uses latest, label the second as a new experiment. If the gas cap changes, record both values. This turns a sequence of retries into evidence about causality rather than a collection of numbers that happened to succeed at different times.
Before signing, compare the final transaction fields against the candidate used for estimation. Differences in calldata, value, destination, access list, and sender can invalidate the original result. Fee changes also affect affordability even when they do not alter the contract's main logic. Make that comparison a builder invariant so an earlier successful estimate cannot accidentally bless a different payload.
The local program verifies successful execution, an explicit low ceiling, unconditional revert, excessive value, and the arithmetic effect of fee caps. It does not simulate future ordering or establish a live provider's policy. Its practical lesson is a set of stopping conditions with evidence: increase a limit only when the failure is consistent with a limit problem, and retain semantic failures as failures until the underlying precondition changes.
Share this article
Share on XReview your account and post on X. Opening X or copying text does not send a post.