We hardcoded a gas limit. It was fine until it wasn’t.

2026-11-12来源 dev.to AIEnglish

The withdrawal function shipped eleven months ago. Thousands of successful withdrawals. Then one user could not withdraw, ever, and no amount of retrying helped.

Here is roughly what we had:

solidity
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}

If you have been writing Solidity for a while you already know where this goes. I knew about the 2300 gas stipend. I had read about it. I had simply never had it bite me, which is a different thing from understanding it.

What actually happened

transfer() forwards a fixed 2300 gas stipend to the recipient. That is enough for an externally owned account to receive ether and nothing more. It is deliberately small, originally as reentrancy protection.

Our user was withdrawing to a smart contract wallet. A multisig. Its receive() function does real work: it reads storage, emits an event, maybe updates a nonce. That costs considerably more than 2300 gas.

So the receive reverted. And because transfer() bubbles the revert up, the entire withdrawal reverted. The user’s balance was never actually zeroed, which was the one piece of luck in the whole thing, but from their side the function was simply broken with no explanation.

The error message they saw was useless. Just a failed transaction.

The part that made me uncomfortable

This code had worked for eleven months. Nothing in our contract changed. Nothing in our deployment changed. What changed was the kind of wallet people were using.

Smart contract wallets have gone from rare to ordinary. With account abstraction, a growing share of addresses are contracts with non-trivial receive logic. Our assumption was not that recipients would be EOAs. We never made an assumption at all, which is worse, because an assumption you have written down can be reviewed.

The same class of problem has happened at protocol level. EIP-1884 in the Istanbul fork raised the gas cost of SLOAD from 200 to 800, and contracts whose receive logic fit inside the 2300 stipend before the fork no longer did afterwards. Working code broke without anybody deploying anything. That is the thing to sit with: gas costs are not a property of your contract, they are a property of the chain, and the chain changes.

The fix we tried first, which was also wrong
solidity
(bool ok, ) = payable(msg.sender).call{value: amount, gas: 50000}("");
require(ok, "transfer failed");

More headroom. Handles the multisig. Ship it.

This is the same bug with a bigger number. Some wallet, somewhere, has a receive path costing more than 50,000 gas, and when that user arrives we are back here. I had not fixed the problem, I had moved the threshold and bought myself some time.

There is also a subtlety that makes precise gas forwarding harder than it looks. Under EIP-150, a call can forward at most 63/64 of the remaining gas. So even when you name a figure, what the callee receives depends on the gas remaining at that moment in the transaction. You are not controlling what you think you are controlling.

What we actually did

Forward all available gas, and handle reentrancy properly rather than relying on a gas limit to do it:

solidity
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");

balances[msg.sender] = 0; // state change before the call (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); 

}

Three things matter here and the gas limit is not one of them.

State is updated before the external call. Checks, effects, interactions. The balance is zero before control leaves the contract, so a reentrant call finds nothing to withdraw.

An explicit reentrancy guard. The 2300 stipend was never a real security control, it was a side effect that people treated as one. Use a guard and know it is there.

The call is allowed the gas it needs. If the recipient’s receive logic is expensive, that is the recipient spending the caller’s gas, which is correct. They pay for their own wallet’s complexity.

The off-chain half of the same mistake

While fixing the contract I went looking in the frontend, and found the sibling bug:

js
const tx = await contract.withdraw({ gasLimit: 200000 });

Someone had hardcoded this, almost certainly after hitting an estimation failure once and working around it.

This breaks on a code path nobody tested. A first-time storage write costs far more than an update, because setting a zero slot to non-zero is a different opcode price from changing an existing value. Under EIP-2929, cold storage and account access cost more than warm access within the same transaction. So the same function can cost very different amounts depending on whether this user has interacted before.

Estimate it instead, with a buffer, and handle the failure honestly:

js
let gasLimit;
try {
const est = await contract.withdraw.estimateGas();
gasLimit = (est * 120n) / 100n; // 20% headroom
} catch (err) {
// estimation reverted: the call would fail. Surface it, do not paper over it.
throw new Error(withdraw would revert: ${err.shortMessage ?? err.message});
}

const tx = await contract.withdraw({ gasLimit });

The buffer matters because estimation runs against current state, and state can change between your estimate and your execution. Someone else’s transaction lands first, a cold slot becomes warm or the reverse, and your tight estimate is no longer enough.

And when estimation reverts, that is information. It means the transaction would fail. Replacing it with a hardcoded limit converts a clear error into a user paying gas to watch their transaction fail on chain.

What I take from it

Any gas number you type by hand is a guess about the future, and the future includes wallets that do not exist yet and forks that reprice opcodes you are not thinking about.

Two rules I now apply without exception. Never hardcode gas for an external call in a contract; use a reentrancy guard and let the recipient spend what they need. Never hardcode a gas limit off chain; estimate with a buffer, and let estimation failures surface as errors rather than hiding them.

The uncomfortable part is not that we got it wrong. It is that it worked for eleven months, which meant nothing was telling us we were wrong. If your contract makes value transfers, go and look at how you do it now, before a user with an unusual wallet looks for you.

Has anyone handled this differently? I am particularly interested in how people deal with recipients whose receive logic is expensive enough to make a withdrawal uneconomical for them, since “let them spend their own gas” works until the gas exceeds the amount being withdrawn.

原文:https://www.a8q9.cn/en/article/we-hardcoded-a-gas-limit-it-was-fine-until-it-wasn-t-ea1d53.html

相关文章