Unchecked External Call Return Value
Detects external calls whose boolean return value is not checked, allowing silent failures that leave the contract in an inconsistent state.
Unchecked External Call Return Value
Overview
Remediation Guide: How to Fix Unchecked External Call Return Value
The unchecked call detector identifies external calls (CALL, CALLCODE, DELEGATECALL, STATICCALL) whose boolean return value (success) is not tested in a subsequent conditional branch. When an external call fails — due to insufficient gas, a reverting callee, or sending Ether to a non-payable recipient — it returns false. If the caller ignores this return value, it continues executing under the assumption that the call succeeded, potentially leading to inconsistent state, double-payments, or failed transfers that are never retried.
Sigvex inspects the CFG of decompiled EVM bytecode. For each CALL opcode, it checks whether the top-of-stack return value is subsequently used in a JUMPI (conditional jump) instruction before execution moves to the next relevant statement.
Why This Is an Issue
Unchecked call return values are listed in SWC-104 and have been consistently exploited. The King of the Ether Throne contract (2016) suffered a critical bug: when the contract tried to refund a dethroned king, it used send() which returns false on failure — the old king’s address was a contract that rejected ETH. The refund silently failed while the game continued, permanently locking funds.
Modern .call{}() syntax makes it even easier to forget to check the return value since no revert automatically occurs.
How to Resolve
// Before: Vulnerable — return value not checked
function sendReward(address recipient, uint256 amount) internal {
recipient.call{value: amount}(""); // Return value ignored
// Execution continues even if transfer failed
}
// After: Fixed — always check return value
function sendReward(address recipient, uint256 amount) internal {
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
}
For ERC-20 tokens, use OpenZeppelin’s SafeERC20:
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function sendTokens(IERC20 token, address recipient, uint256 amount) internal {
// safeTransfer reverts if transfer returns false
token.safeTransfer(recipient, amount);
}
Examples
Vulnerable Code
contract VulnerableKing {
address public currentKing;
uint256 public claimPrice;
function claim() external payable {
require(msg.value >= claimPrice);
address previousKing = currentKing;
uint256 refund = claimPrice;
currentKing = msg.sender;
claimPrice = msg.value;
// VULNERABLE: if previousKing is a contract that reverts on ETH receipt,
// the call fails silently and ETH is permanently locked
previousKing.call{value: refund}(""); // Return value IGNORED
}
}
Fixed Code
contract SecureKing {
address public currentKing;
uint256 public claimPrice;
mapping(address => uint256) public pendingReturns;
function claim() external payable {
require(msg.value >= claimPrice);
pendingReturns[currentKing] += claimPrice;
currentKing = msg.sender;
claimPrice = msg.value;
// No direct transfer — let previous king withdraw
}
function withdraw() external {
uint256 amount = pendingReturns[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingReturns[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Sample Sigvex Output
{
"detector_id": "unchecked-call",
"severity": "critical",
"confidence": 0.87,
"description": "CALL at offset 0x6c in function claim() has its return value discarded (no JUMPI on stack top after CALL). Failure of the external transfer will not revert execution.",
"location": { "function": "claim()", "offset": 108 }
}
Detection Methodology
- External call identification: Locates
CALL,CALLCODE,DELEGATECALL, andSTATICCALLopcodes in each function’s CFG. - Return value tracking: Checks whether the return value (top of stack after the opcode) is popped and discarded (
POPinstruction) or flows into a conditional branch (JUMPI). - Context awareness: Distinguished between calls to trusted fixed addresses (e.g., known safe precompiles) where unchecked returns are acceptable, and calls to dynamic targets where the return must be checked.
- Severity classification: Direct Ether transfers with unchecked returns are critical; calls to unknown external contracts are also critical; calls to known ERC-20 tokens without SafeERC20 are high severity.
Limitations
False positives:
- Precompile calls (e.g.,
ecrecover,sha256viaCALLto addresses 1-9) do not need return value checks — these are whitelisted. - Intentional “fire and forget” calls (e.g., notifying a callback without caring about the result) may generate false positives.
False negatives:
transfer()andsend()use a limited 2300 gas stipend and revert on failure in older Solidity — but these are now deprecated and their behavior changed in post-Istanbul EVM.- Calls wrapped in a try/catch block may not be recognized as properly checked.
Related Detectors
- Reentrancy — external calls that also expose reentrancy
- Access Control — detects missing authorization on functions that make external calls