Free snapshot slots open this week info@inversez.com
DetectionVulnerabilitiesToolsAuditsPricingBlogFree monitoring assessmentContact
Home/Vulnerabilities/Unchecked call return value
SWC-104 · Typically high

Unchecked call return value

Low-level calls in Solidity do not revert when they fail. They return false. If you ignore that boolean, your contract continues as though the transfer succeeded: updating balances, emitting events, and releasing collateral for money that never moved.

Vulnerable code

Do not ship this
function payOut(address to, uint256 amt) external onlyOwner {
    to.call{value: amt}("");       // return value discarded
    paid[to] += amt;                    // recorded regardless
}

// The ERC-20 variant, equally common:
function deposit(uint256 amt) external {
    token.transferFrom(msg.sender, address(this), amt);
    balance[msg.sender] += amt;         // credited even if transfer failed
}

Why it breaks

If the recipient is a contract whose receive() reverts, or is out of gas, the call returns false and execution carries on. Your paid mapping now claims money was sent that wasn't. The ERC-20 case is worse: some widely-used tokens return false instead of reverting on failure, and some (famously USDT) return nothing at all, so the call succeeds while transferring zero.

Watch the attack run

The same bug as a stepped sequence. Play it, or walk through with the arrow keys.

The fix

Corrected
function payOut(address to, uint256 amt) external onlyOwner {
    (bool ok, ) = to.call{value: amt}("");
    require(ok, "payout failed");
    paid[to] += amt;
}

// For tokens, use SafeERC20 — it handles both
// false-returning and void-returning implementations.
using SafeERC20 for IERC20;

function deposit(uint256 amt) external {
    token.safeTransferFrom(msg.sender, address(this), amt);
    balance[msg.sender] += amt;
}

The invariant

The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.

InvariantEVM · unchecked call return

The change in internal accounting equals the change in the actual token balance held.

invariant  delta(internal_accounting)
           == delta(token.balanceOf(address(this)))
  per block, tolerance 0
Backtest: not yet run. This entry carries the reasoning, not the evidence. When the invariant has been replayed against the real transactions around the exploit, the firing block, how far ahead it fired, and its false-positive rate over a clean control period will be published here, along with the recording of the run.

How to test for it

A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.

contract RejectingReceiver {
    receive() external payable { revert("no thanks"); }
}

function testPayoutRevertsOnFailure() public {
    RejectingReceiver r = new RejectingReceiver();
    vm.expectRevert("payout failed");
    vault.payOut(address(r), 1 ether);
}

Where it has caused real losses

Fee-on-transfer and rebasing tokens compound this problem. A vault that credits amt rather than the actual balance delta will over-credit every deposit of a fee-taking token, and the shortfall is drained by whoever withdraws last. Several yield aggregators have lost funds this way.

What we check during review

The robust pattern for any token deposit is to measure the balance before and after and credit the difference, rather than trusting the amount parameter:

uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amt);
uint256 received = token.balanceOf(address(this)) - before;
balance[msg.sender] += received;

Want us to check your contract for this?

A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.

Free monitoring assessment

Found this useful?

We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.

  • Reply written by the auditor who read your code
  • No sales sequence, no drip campaign, no retargeting
  • We’ll tell you if you don’t need a paid audit yet
  • Report published only with your written permission

Request your snapshot

We read every submission. If it doesn’t fit the free tier we’ll say so and quote you instead, no obligation.