Unchecked Call Remediation
How to safely handle external call return values in Solidity to prevent silent failures from ignored CALL, SEND, and low-level call results.
Unchecked Call Remediation
Overview
Related Detector: Unchecked Call
Unchecked external calls silently fail when the callee reverts or runs out of gas. The program continues executing as if the call succeeded, leading to inconsistent state. The fix is to always check the return value of .call(), use SafeERC20 for token operations, or use transfer-pattern abstractions that revert on failure.
Recommended Fix
Before (Vulnerable)
contract VulnerableProtocol {
function processPayment(address payable recipient, uint256 amount) external {
// VULNERABLE: return value ignored — if call fails, execution continues
recipient.call{value: amount}("");
// State update proceeds even if the transfer failed
balances[recipient] = 0;
}
function transferToken(address token, address to, uint256 amount) external {
// VULNERABLE: ERC-20 transfer() returns bool but is unchecked
// Non-standard tokens (e.g., USDT) don't revert on failure
IERC20(token).transfer(to, amount);
}
}
After (Fixed)
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SafeProtocol {
using SafeERC20 for IERC20;
function processPayment(address payable recipient, uint256 amount) external {
// FIXED: check return value and revert on failure
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
// State update only occurs if transfer succeeded
balances[recipient] = 0;
}
function transferToken(address token, address to, uint256 amount) external {
// FIXED: SafeERC20.safeTransfer() checks return value AND
// handles non-standard tokens that return nothing (e.g., USDT)
IERC20(token).safeTransfer(to, amount);
}
}
Alternative Mitigations
1. SafeERC20 for All ERC-20 Operations
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract TokenProtocol {
using SafeERC20 for IERC20;
function deposit(IERC20 token, uint256 amount) external {
// safeTransferFrom handles:
// - Tokens that return false instead of reverting
// - Tokens that return nothing (non-standard USDT pattern)
// - Tokens that revert — propagates the revert
token.safeTransferFrom(msg.sender, address(this), amount);
}
function withdraw(IERC20 token, address to, uint256 amount) external {
token.safeTransfer(to, amount);
}
function approveSpend(IERC20 token, address spender, uint256 amount) external {
// safeApprove is deprecated — use safeIncreaseAllowance/safeDecreaseAllowance
token.safeIncreaseAllowance(spender, amount);
}
}
2. Pull Payment Pattern for ETH
Avoid pushing ETH entirely — let recipients pull:
contract PullPayment {
mapping(address => uint256) public pendingWithdrawals;
// Escrow ETH instead of pushing it
function _creditUser(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
// User pulls their own ETH — their failure only affects themselves
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Withdrawal failed");
}
}
3. Low-Level Call with Error Propagation
When calling unknown contracts that may return custom errors:
function callAndPropagate(address target, bytes calldata data) external {
(bool success, bytes memory returnData) = target.call(data);
if (!success) {
// Propagate the revert reason from the callee
if (returnData.length > 0) {
assembly {
revert(add(32, returnData), mload(returnData))
}
}
revert("External call failed");
}
}
Common Mistakes
Mistake 1: Checking Return Value but Not Reverting
// WRONG: checks return value but doesn't revert on failure
function transfer(address to, uint256 amount) external {
bool success = IERC20(token).transfer(to, amount);
// Missing: require(success, "Transfer failed");
// Execution continues as if transfer succeeded
}
Mistake 2: Using send() or transfer() for ETH
// OUTDATED: send() returns false on failure without reverting
// transfer() reverts but passes only 2300 gas stipend (insufficient for complex receivers)
address.send(amount); // Ignores failure silently
address.transfer(amount); // 2300 gas limit breaks modern receivers
Use .call{value: amount}("") with explicit return value check instead.
Mistake 3: Not Handling Non-Standard ERC-20 Return Values
// WRONG: assumes transfer() returns bool — USDT and others return nothing
bool success = IERC20(usdt).transfer(to, amount);
// Reverts with "Invalid return value" for non-standard tokens
// FIXED: use SafeERC20
IERC20(usdt).safeTransfer(to, amount);