Settlement dApp Suffers Reverts and Failed Historical Queries
A settlement dApp submits about 180,000 transactions per week through a load balancer in front of two Besu nodes. It calls eth_estimateGas with the default latest block tag, multiplies the result by 1.0, signs, and sends. Over the last week 2.1% of transactions either reverted or ran out of gas, concentrated in a batch-settlement contract that calls several token contracts in one transaction, and users see pending nonces after each failure. Separately, a monthly audit job replays eth_call against blocks from six months ago on the same nodes and now fails with missing-state errors for some accounts. A monitoring job also polls eth_getLogs from block 0 to latest every 30 seconds; the node's heap is 16 GB and it has been restarted twice by the OOM killer this month. The team wants to raise the gas limit and increase the heap size.
1. Explain why estimating gas and multiplying by 1.0 fails for the batch-settlement contract, and what the correct estimation strategy is.
Read the worked answer
eth_estimateGas is a simulation against a specific state - latest unless told otherwise - and the result is only valid for that state. The signed transaction is executed later, at a different state.
Three effects break the 1.0 multiplier:
- State drift between estimation and inclusion. Another transaction can move the contract's state (a token balance, an allowance, a liquidity pool ratio, a batch counter) so the mined execution takes a different path with more storage writes, more external calls, or an outright revert. A revert is not a gas-sizing problem at all - raising the limit just makes it fail more expensively.
- Pending transactions are invisible to
latest. If the sender has an earlier pending transaction that changes a nonce or a state the batch depends on, estimation runs against state that will not exist when the transaction is mined. - Nested call gas forwarding. The batch calls several token contracts. Because of the 63/64 forwarding rule, an inner call can be starved, and any single out-of-gas in an inner frame reverts the whole batch. Contracts that branch on
gasleft()also need headroom.
Strategy: estimate on pending when the submission path allows it, add a margin (20–30% is typical, not 0%), and - more importantly - simulate with eth_call at the exact block and state the transaction will execute against, using state overrides if needed to pin the inputs. Treat estimation failure as a signal to fix the payload, not to raise the limit: check the revert reason via eth_call and the receipt of the failed transaction before resubmitting. Finally, on out-of-gas, retry with the same nonce and a larger limit rather than submitting a new nonce, or the pending-nonce complaints continue.
2. The audit job's historical `eth_call` requests now fail with missing-state errors. Explain why, and design a fix that does not compromise the production nodes.
Read the worked answer
Besu's default Bonsai configuration prunes historical state. The node keeps enough recent state to serve latest and a short window of recent blocks, but state for blocks from six months ago has been pruned, so a historical eth_call cannot resolve the account or storage slots it needs and fails with a missing-trie-node style error. Increasing heap does nothing; the data is not on disk.
Options, in order of preference:
- Dedicated archive node. Run a separate node with full sync and pruning disabled, so all historical state is retained. Point only the audit job at it. Sizing is the constraint - full state on a busy chain is several times the pruned footprint - so give it its own disk and do not put it behind the production load balancer.
- Retain what is needed. If the audit only needs a handful of accounts, capture the data at request time going forward (store the
eth_callresults, or snapshot the relevant storage proofs) instead of relying on replay months later. This fails for look-back requests already in scope. - Query an external indexer that has retained historical state, accepting the trust shift.
Do not solve this by disabling pruning on a production validator: it inflates disk and can affect block-production latency. Also confirm that the archive node's genesis and chain ID match exactly, and that the audit path uses an explicit block number rather than latest - the audit's value depends on reproducible block pins, and latest silently changes the answer.
3. The `eth_getLogs` job is OOM-killing nodes. Diagnose the mechanism and propose an approach that keeps the monitoring functional.
Read the worked answer
eth_getLogs from block 0 to latest over roughly 2.3 million blocks is not a query - it is a full-chain scan executed every 30 seconds, materializing every matching log in memory before serializing the response. With a 16 GB heap, two overlapping scans are enough to trigger the OOM killer. The restart then makes the next poll scan an even larger range, so the failure feeds itself.
Fix:
- Bound the range. Query only the blocks produced since the last successful poll (30 seconds at a 2-second block period is roughly 15 blocks), with a small overlap and deduplication by
(blockHash, logIndex). Persist the last processed block so a restart resumes rather than rescans. - Respect the node's cap. Besu enforces a maximum block range for log queries; a request beyond it is rejected. Size the monitoring window well below the configured cap and alert if the gap ever approaches it.
- Prefer filters or subscriptions. Use
eth_newFilter+eth_getFilterChangesfor polling oreth_subscribeover WebSocket for push, so the node maintains the cursor instead of rescanning. - Move indexing off the producers. Use a dedicated indexer/RPC tier, and if it must be Besu, put it behind the load balancer with the production nodes protected by connection and request limits.
- Set memory deliberately. Size the heap to the largest legitimate response, and monitor per-request memory, not just process liveness. Raising the heap alone only postpones the same failure.