Back

SC06: Unchecked External Calls - Code Examples

Each pair below shows a vulnerable contract and the secure rewrite. The examples focus on the failures that dominate real findings: ignoring the boolean of a value transfer, assuming ERC-20 reverts on failure, masking a failed delegatecall, and bricking a push-payment loop.

1. ETH Withdrawal — Unchecked call

Vulnerable

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Vault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient");
        balances[msg.sender] -= amount;
        // BUG: return value ignored. If the recipient rejects ETH, the
        // transfer returns false, the ETH stays here, and the balance is gone.
        msg.sender.call{value: amount}("");
        emit Withdrawn(msg.sender, amount); // emitted even on silent failure
    }

    event Withdrawn(address indexed user, uint256 amount);
}

Secure

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Vault {
    mapping(address => uint256) public balances;

    error EthTransferFailed(address to, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient"); // Checks
        balances[msg.sender] -= amount;                          // Effects

        (bool ok, ) = msg.sender.call{value: amount}("");        // Interactions
        if (!ok) revert EthTransferFailed(msg.sender, amount);   // failure reverts
        // the revert rolls back the balance change atomically -> no stuck funds

        emit Withdrawn(msg.sender, amount);
    }
}

2. Token Deposit — Assuming transferFrom Reverts

Vulnerable

pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address, address, uint256) external returns (bool);
    function transfer(address, uint256) external returns (bool);
}

contract Staking {
    IERC20 public token;
    mapping(address => uint256) public staked;

    function stake(uint256 amount) external {
        // BUG: return value ignored. A non-standard token can return false
        // (or nothing) without reverting -> phantom stake credited for free.
        token.transferFrom(msg.sender, address(this), amount);
        staked[msg.sender] += amount;
    }

    function unstake(uint256 amount) external {
        require(staked[msg.sender] >= amount, "insufficient");
        staked[msg.sender] -= amount;
        token.transfer(msg.sender, amount); // return also ignored
    }
}

Secure

pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract Staking {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;
    mapping(address => uint256) public staked;

    constructor(IERC20 _token) { token = _token; }

    function stake(uint256 amount) external {
        // Measure the real delta so fee-on-transfer tokens can't inflate credit,
        // and safeTransferFrom reverts on a false/empty return.
        uint256 before = token.balanceOf(address(this));
        token.safeTransferFrom(msg.sender, address(this), amount);
        uint256 received = token.balanceOf(address(this)) - before;
        staked[msg.sender] += received;   // credit only what actually arrived
    }

    function unstake(uint256 amount) external {
        require(staked[msg.sender] >= amount, "insufficient");
        staked[msg.sender] -= amount;
        token.safeTransfer(msg.sender, amount); // reverts on failure
    }
}

3. Executor — Unchecked delegatecall

Vulnerable

pragma solidity ^0.8.20;

contract Executor {
    address public owner;
    bool public executed;

    constructor() { owner = msg.sender; }

    function run(address impl, bytes calldata data) external {
        require(msg.sender == owner, "not owner");
        // BUG: success flag discarded. A reverting or empty impl is treated
        // as a successful run; storage assumptions are corrupted.
        impl.delegatecall(data);
        executed = true;
    }
}

Secure

pragma solidity ^0.8.20;

contract Executor {
    address public immutable owner;
    bool public executed;

    error NotOwner();
    error NotAContract(address target);
    error DelegateCallFailed(bytes reason);

    constructor() { owner = msg.sender; }

    function run(address impl, bytes calldata data)
        external
        returns (bytes memory)
    {
        if (msg.sender != owner) revert NotOwner();
        if (impl.code.length == 0) revert NotAContract(impl); // empty addr returns true!

        (bool ok, bytes memory ret) = impl.delegatecall(data);
        if (!ok) revert DelegateCallFailed(ret);              // check success

        executed = true;                                      // only after real success
        return ret;                                           // caller validates data
    }
}

4. Reward Distribution — Push Loop vs. Pull

Vulnerable (push — one bad recipient bricks everyone)

pragma solidity ^0.8.20;

contract Airdrop {
    address[] public winners;

    function distribute(uint256 prize) external {
        for (uint256 i = 0; i < winners.length; i++) {
            // BUG: a single reverting/gas-heavy recipient blocks the whole loop,
            // and send()'s false return would be ignored anyway.
            payable(winners[i]).transfer(prize);
        }
    }
}

Secure (pull — each recipient bears their own risk)

pragma solidity ^0.8.20;

contract Airdrop {
    mapping(address => uint256) public credits;

    error NothingToClaim();
    error ClaimFailed();

    // Allocation only records the owed amount; it cannot be griefed.
    function allocate(address user, uint256 amount) external {
        credits[user] += amount;
    }

    function claim() external {
        uint256 amount = credits[msg.sender];
        if (amount == 0) revert NothingToClaim();
        credits[msg.sender] = 0;                          // effect first

        (bool ok, ) = msg.sender.call{value: amount}(""); // checked interaction
        if (!ok) revert ClaimFailed();                    // only the caller is affected
    }

    receive() external payable {}
}

What Changed, and Why

IssueVulnerableSecure
ETH transfercall return ignored; state pre-updatedCEI + require/revert on the success bool
Token transferAssumes revert-on-failure; ignores boolSafeERC20 + measured balance delta
delegatecallSuccess flag discardedCheck success, guard empty address, return data
DistributionPush loop bricked by one recipientPull pattern; each transfer checked and isolated

Adversarial Test Mocks

Route every value-moving path through these in tests to prove your checks work:

// Rejects all ETH -> makes send()/call() return false
contract Rejector { }

// Returns false without reverting -> catches "assumed revert" bugs
contract FalseToken {
    function transfer(address, uint256) external pure returns (bool) { return false; }
    function transferFrom(address, address, uint256) external pure returns (bool) { return false; }
}

// Returns NO data (USDT-class) -> catches strict-bool-decode bugs
contract NoReturnToken {
    function transfer(address, uint256) external { /* no return */ }
    function transferFrom(address, address, uint256) external { /* no return */ }
}

Next Steps