Weakspot

send(), transfer(), call() — and the refund that silently fails

Solidity's send() returns false instead of reverting. A worked example on an auction refund, why the 2300 gas stipend breaks smart contract wallets, and the pull-payment fix.

Updated 2026-09-05

Solidity gives you three ways to send ETH and they fail in three different ways. Two of them fail loudly. One returns a boolean that your code is free to ignore — and ignoring it means the surrounding function carries on as though the money moved.

The vulnerable contract

An auction that refunds the previous highest bidder automatically when they are outbid.

AuctionHouse.sol — the return value of send() is discarded
function bid() external payable {
    require(!ended, "auction ended");
    require(msg.value > highestBid, "bid too low");

    if (highestBidder != address(0)) {
        payable(highestBidder).send(highestBid);
    }

    highestBidder = msg.sender;
    highestBid = msg.value;
}

Why it fails

send returns bool. It does not revert on failure. The result here is never assigned, never checked and never acted on — so when the refund fails, execution continues straight to the next line. highestBidder and highestBid are overwritten, and the contract now holds no record that anyone is owed anything. The previous bidder's ETH stays in the contract with no code path that returns it.

The failure is not hypothetical, because send forwards only a 2300 gas stipend. That is enough to accept ETH and do essentially nothing else. Any recipient whose receive() writes storage runs out of gas — which includes essentially every smart contract wallet: Safe, Argent, Coinbase Smart Wallet, any ERC-4337 account. A perfectly honest bidder using a multisig is refunded nothing.

A malicious bidder can do it deliberately: bid from a contract whose receive() reverts or burns gas, get outbid, and their "lost" ETH is stuck in the auction rather than returned — which, depending on the surrounding accounting, is either their problem or everyone's.

Why the 2300 stipend exists, and why it is now wrong

transfer and send cap gas at 2300 to prevent reentrancy, by making it impossible for the recipient to do anything meaningful. That was reasonable advice until EIP-1884 repriced SLOAD and other opcodes, breaking contracts whose receive() had previously fit inside the budget. A hardcoded gas constant is a bet that gas costs never change again, and that bet has already lost once.

The current consensus is to use call with the return value checked, and to handle reentrancy directly — through checks-effects-interactions and a nonReentrant guard — rather than by starving the callee of gas.

The fix

The minimal correction is to check the result and revert:

Checked — but see the caveat below
if (highestBidder != address(0)) {
    (bool refunded, ) = payable(highestBidder).call{value: highestBid}("");
    require(refunded, "refund failed");
}

That closes the silent-failure hole, but it opens a different one: a bidder whose receive() always reverts can now block every subsequent bid, because nobody can outbid them without triggering the failing refund. The auction freezes with them winning. Trading a lost refund for a griefable auction is not a fix.

The robust answer is the pull payment pattern: never push ETH as a side effect of someone else's action. Record the debt, and let the creditor come and collect it.

Pull payments — a failing recipient can only harm themselves
mapping(address => uint256) public pendingReturns;

function bid() external payable {
    require(!ended, "auction ended");
    require(msg.value > highestBid, "bid too low");

    if (highestBidder != address(0)) {
        pendingReturns[highestBidder] += highestBid;
    }

    highestBidder = msg.sender;
    highestBid = msg.value;
}

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

    pendingReturns[msg.sender] = 0;   // effects before interactions

    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "refund failed");
}

Now bid() cannot fail because of a third party, a reverting recipient blocks only their own withdrawal, and the gas budget is whatever the recipient chooses to spend on their own transaction. Note the state write before the external call — the pull pattern moves the ETH transfer into a function the attacker calls directly, so it needs reentrancy ordering too.

The rule

  • Never ignore the return value of send or a low-level call. The compiler emits a warning for exactly this; treat it as an error.
  • Prefer call to transfer and send, with the result checked and reentrancy handled explicitly.
  • Prefer pull to push whenever one user's transaction would otherwise send ETH to another user.

Keep reading