Medium severity

Ignoring the return value of a low-level call

Updated 2026-10-22

Solidity’s low-level call members — address.call, delegatecall, staticcall and the older send — do not revert when the call they make fails. They hand back a bool success flag and leave it entirely to you to look at it. This is the opposite of a normal high-level call (token.transfer(...), other.doThing()), where a revert in the callee bubbles up and undoes your transaction automatically. Drop the success flag on the floor and the two outcomes become indistinguishable: a call that reverted, ran out of gas, or hit a recipient with no payable fallback returns false, your function keeps going as if nothing went wrong, and whatever bookkeeping you did around the call is now a lie. The pattern matters precisely because call is the recommended way to send ether today — transfer and send forward only 2300 gas and can break against contracts whose receive logic costs more — so the modern “correct” money-sending idiom is also the one that silently swallows failures when you forget the check.

Vulnerable pattern

withdraw() sends ether with call but never looks at the result
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Payout {
    mapping(address => uint256) public owed;

    // BUG: zeroes the balance, then fires a low-level call whose
    // success flag is never captured or checked. If the send fails,
    // the ether stays in the contract but `owed` already says it left.
    function withdraw(address payable dst) external {
        uint256 amount = owed[dst];
        owed[dst] = 0;
        dst.call{value: amount}("");
    }
}

Follow the money when the call fails. Say dst is a smart-contract wallet whose receive() reverts under some condition, or simply a contract with no way to accept plain ether. The call returns false, but withdraw has already set owed[dst] = 0 and does not revert — so the transaction succeeds, the ether never leaves the contract, and the caller’s recorded balance is gone. The funds are stranded and the ledger is wrong. The same shape bites outside of payments: a low-level call used to invoke another contract’s function, with the result ignored, lets a required step fail quietly while the caller proceeds as though it had happened — a settlement that was never settled, a hook that never ran, an approval that never landed.

The fix

Capture the success flag and act on it: revert when the call fails, so the whole transaction — including the balance update — rolls back together.

capture the result and require success
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Payout {
    mapping(address => uint256) public owed;

    // FIX: a failed send reverts the whole call, so `owed` is only
    // cleared when the ether actually left the contract.
    function withdraw(address payable dst) external {
        uint256 amount = owed[dst];
        owed[dst] = 0;
        (bool ok, ) = dst.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

In production, prefer a vetted helper over hand-rolled checks: OpenZeppelin’s Address.sendValue and functionCall perform the low-level call and revert on failure (and on a call to a non-contract) for you. For paying out ether to arbitrary recipients, the sturdiest pattern is to avoid pushing at all — let users pull their own funds, so one failing recipient cannot wedge a batch and a revert only affects the party that caused it. Whichever you choose, the rule that kills the class is the same: a low-level call’s bool is a result you must handle, not noise you may ignore.

A note on scope

Slither’s unchecked-lowlevel detector fires when a low-level call’s return value is never read anywhere on a path after the call — as in the vulnerable example, where the result is not even captured. It is deliberately shallow: it checks only that you read the flag, not that you do anything sensible with it. We verified that capturing the result into (bool ok, ) and then never using ok already clears the detector to zero findings — even though that code is still just as broken, because it ignores the failure. So a clean result here means “the return value is referenced somewhere”, not “failures are handled”; the many real bugs that capture the flag and then forget to branch on it slip straight through. In the other direction, a low-level call routed through a library that already checks the result for you (such as Address.functionCall) is correctly not flagged. Read a finding in context, and treat a clean scan as the absence of an obvious unchecked call, not proof the call handling is sound.

Scan your contract for this

pactlint flags unchecked-lowlevel and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon, Solana, BNB Chain or Arbitrum with x402, or on Tempo with MPP, no signup; the first few scans each day are free.

How to scan →

An unchecked low-level call is reported by Slither’s stock unchecked-lowlevel detector (“Unchecked low-level calls”), rated medium impact at medium confidence, which pactlint surfaces at medium severity — it is not downgraded or denied in policy, no description rule touches it, and it carries no worker-side triage rule, so it surfaces exactly as mapped. We confirmed this on solc 0.8.19 with Slither 0.11.6: the Payout contract above fires unchecked-lowlevel at medium impact and medium confidence (“Payout.withdraw(address) ignores return value by dst.call{value: amount}()”); capturing the result and adding require(ok, "transfer failed") clears it to zero findings. Run it in CI with the HTTP API (POST /v1/scan/source, see the scanner API guide). This is the low-level-call sibling of the ERC20 case, where a token’s bool return is ignored (see unchecked ERC20 transfer). Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.