How Tatum engineers helped fix a subtle race condition that could return dangerously low gas estimates
Gas estimation is one of the most important RPC operations in a blockchain application.
Before submitting a transaction, wallets, dApps, exchanges, and infrastructure providers often call eth_estimateGas to determine how much gas the transaction needs to execute successfully.
When the same transaction is simulated against the same blockchain state, developers expect the result to be consistent.
However, under specific timing conditions, Erigon could return different estimates for identical requests. In some cases, the returned value was lower than the minimum gas required for successful execution, meaning a transaction could later revert with an out-of-gas error despite using the recommended estimate.
A recent contribution from the Tatum team, especially Shinto C V addresses this issue by fixing a race condition involving cancellation of a shared EVM instance.
The fix was merged into Erigon’s release/3.6 branch through PR #22968, which cherry-picks the original fix from PR #22877.
Why reliable gas estimation matters
Gas estimation is designed to answer a simple question:
What is the smallest gas limit at which this transaction will successfully execute?
Applications use the response to prepare transactions before they are sent to the network.
A reliable estimate helps applications:
-Reduce unexpected out-of-gas reverts
-Avoid asking users to overpay for gas
-Improve transaction success rates
-Provide more predictable wallet and dApp experiences
-Simplify debugging when transactions fail
If the estimate is too high, users may allocate more gas than necessary. If it is too low, the transaction can fail during execution.
The issue fixed in Erigon affected the second case.
How Erigon estimates gas
Gas estimation typically works by executing a transaction multiple times with different gas limits.
A simplified version of the process looks like this:
How Gas Estimation Converges on a Value
The node tries different gas limits, narrowing the range with each attempt, until it finds the minimum limit that succeeds.
The estimator continues narrowing the range until it identifies the smallest gas limit at which the call succeeds.
This process depends on one important assumption:
Every execution probe must be classified correctly as either a success or a failure.
If a failed execution is mistakenly treated as successful, the binary search can converge on a gas limit that is too low.
The problem: a stale cancellation signal
The issue occurred in ReusableCaller.DoCallWithNewGas, where Erigon reuses a shared EVM instance across multiple gas-estimation probes.
Each probe needs a cancellation mechanism. If the request context expires while the EVM is executing, Erigon must cancel that execution.
Before the fix, this cancellation was handled by a watcher goroutine.
The watcher waited for either:
-The execution context to be cancelled, or
-The current probe to finish
In simplified form, the behavior looked like this:
The problem was that, on a normal return, both cases could be ready at approximately the same time:
- The gas-estimation probe completed.
- The
donechannel was closed. - The deferred context cancellation also ran.
- The watcher goroutine could still select the context cancellation path.
evm.Cancel()was called after the probe had already finished.
Because the EVM instance was shared and reused, that cancellation could affect the next probe.
How the race produced incorrect estimates
The sequence below illustrates the problem:
The most important detail is that the interrupted frame could return with err == nil.
That meant the gas estimator could interpret the aborted execution as a successful one.
Once that incorrect result entered the binary search, the estimator could lower its result below the true minimum required gas limit.
In practical terms, eth_estimateGas could return a value that looked valid but was not sufficient to execute the transaction successfully.
Reproducing the issue
The fix introduced a dedicated regression test called TestEstimateGasDeterminism.
The test sends 200 identical serial requests against a fixed blockchain head and checks whether the same gas estimate is returned every time.
The test uses a contract call that writes zero to previously non-zero storage slots. This creates a large gas refund and widens the gap between the amount of gas used and the true minimum gas required for successful execution.
That wider window makes the stale-cancellation race easier to reproduce.
Before the fix
Before the fix, the test returned:
-20 distinct gas estimates
-Across 200 identical requests
-With the estimates being lower than the correct minimum
This demonstrated that the result was not deterministic, even though:
-The request was identical
-The requests were executed serially
-The blockchain state remained fixed
After the fix
After the fix, repeated requests returned a deterministic result.
The test fails if more than one distinct estimate is observed:
This provides a permanent regression check against the issue returning in the future.
eth_estimateGas - 200 Identical Requests
Same transaction, same parameters. Watch what changes.
The fix: replacing the watcher goroutine
The first part of the fix replaced the custom watcher goroutine with context.AfterFunc.
context.AfterFunc schedules a callback to run when the context is cancelled:
The callback still performs the required behavior: if the context genuinely expires during execution, the shared EVM is cancelled.
The difference is that the callback can be explicitly stopped when the probe completes normally:
The deferred cleanup is ordered so that it runs before the context’s deferred cancel() call.
As a result, during normal execution:
-The callback is stopped.
-The probe completes without cancelling the EVM afterward.
-The shared EVM can safely be reused by the next probe.
Closing the remaining timing window
The code review identified one additional edge case.
Calling stop() does not wait for a callback that has already started executing.
That means a callback could theoretically begin just before stop() is called. If the next probe immediately reused the EVM, the in-flight callback could still call r.evm.Cancel() at the wrong time.
To close this window, the final implementation coordinates with the cancellation callback through a cancelled channel:
If stop() returns true, the callback was successfully prevented from running.
If it returns false, the callback has already started or completed. The code waits for the callback to finish before allowing the next probe to continue.
This ensures that a late cancellation cannot leak into the next execution.
Preserving genuine timeout behavior
The fix changes the cleanup mechanism, but it does not remove timeout handling.
If the context genuinely expires while the EVM is executing:
- The
AfterFunccallback runs. timedOutis set.- The EVM is cancelled.
- The execution returns the appropriate timeout behavior.
In other words, the fix prevents cancellation after a successful probe has already completed, while preserving cancellation when a probe actually times out.
Testing and validation
The contribution included both the implementation change and a regression test.
The changes were validated with:
go test ./...
make lint
Both checks passed.
The pull request received review from the Erigon maintainers, including feedback on the residual callback race. The final version incorporated that feedback before being merged into the release/3.6 branch.
The work was co-authored by:
The original fix was committed by yperbasis as part of the release branch update.
Why this fix matters for blockchain developers
A race condition inside an RPC implementation may sound highly specialized, but its effects can reach every application that relies on gas estimation.
This includes:
-DeFi applications
-Exchanges
-NFT marketplaces
-Smart contract dashboards
-Blockchain APIs and RPC providers
For these applications, deterministic gas estimation means fewer discrepancies between simulation and execution.
It also helps reduce situations where:
-A transaction appears ready to submit but later runs out of gas
-Two identical requests return different values
-Developers cannot reproduce a failed transaction
-Users retry transactions unnecessarily
-Applications add excessive safety buffers to compensate for unreliable estimates
Reliable infrastructure is not only about supporting more chains or processing more requests. It is also about ensuring that core operations return consistent and trustworthy results.
A broader contribution to blockchain infrastructure
This Erigon fix was part of Tatum’s broader work on improving blockchain infrastructure and supporting compatibility across clients.
The team has also been working on the migration path between Erigon and Polygon Bor, including the implementation of missing tracing APIs such as:
trace_transactiontrace_replayTransactiontrace_replayBlockTransactionstrace_blocktrace_calltrace_callMany
These efforts share the same objective: making blockchain infrastructure more compatible, predictable, and easier for developers to operate.
Open-source contributions are an important part of that process. When infrastructure providers identify issues in the clients they work with, fixing and contributing those improvements upstream can benefit the wider ecosystem.
Conclusion
Gas estimation is a small but essential part of the blockchain transaction lifecycle.
The Erigon issue was caused by a subtle race condition: a stale cancellation from one gas-estimation probe could affect a later probe using the same EVM instance. That could result in an incorrect success classification and cause the binary search to return a gas limit below the true minimum.
The fix replaces the watcher goroutine with context.AfterFunc, safely coordinates callbacks that have already started, and adds a regression test to guarantee deterministic results across repeated requests.
With the fix merged into Erigon’s release/3.6 branch, developers can rely on more predictable eth_estimateGas behavior.
It is a good example of how careful debugging, targeted testing, code review, and open-source collaboration can improve the foundations that blockchain applications depend on every day.
.jpg)