Unchecked External Calls
Every time a Solidity contract communicates with another address — whether it's sending ETH or calling a token's transfer() — that communication can fail. The question is: does your contract know it failed? Unchecked external calls silently swallow failures, leaving contracts with incorrect state, stuck funds, and frozen functionality. Non-standard ERC20 tokens add an additional layer of complexity that has caught dozens of protocols off guard.
Unchecked external calls are among the most common bugs in production contracts. The failure to check a return value from .call(), .send(), or a non-standard ERC20 transfer() can silently corrupt accounting, strand funds permanently, or enable denial-of-service attacks.
The Three ETH Transfer Methods
| Method | Gas Forwarded | Failure Behavior | Return Value | Recommendation |
|---|---|---|---|---|
address.transfer(amount) | 2300 (stipend) | Reverts caller | None | Avoid — 2300 gas too restrictive |
address.send(amount) | 2300 (stipend) | Returns false | bool | Avoid — easy to forget check |
address.call{value:}("") | All remaining gas | Returns false | (bool, bytes) | Use this — but always check bool |
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract UncheckedCallVulnerable {
mapping(address => uint256) public balances;
// ❌ Return value from .call() is completely ignored
// If the call fails, balance is zeroed but no ETH was sent
// User loses their funds permanently
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
// ❌ Call result discarded — no error if ETH transfer fails
msg.sender.call{value: amount}(""); // Silently fails!
}
// ❌ Using .send() — returns false on failure but nothing checks it
function withdrawSend(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
msg.sender.send(amount); // ❌ Returns false but result ignored
}
// ✅ CORRECT: Always check the bool return value of .call()
function withdrawSafe(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "ETH transfer failed");
}
}The 2300 Gas Stipend Problem
.transfer() and .send() forward only 2300 gas to the recipient. This was originally a "safety" measure to prevent reentrancy — with only 2300 gas, the recipient cannot do much in their receive() function. However, this design assumption broke down for two reasons.
// Problem 1: Smart wallets and multisigs need more than 2300 gas
// Gnosis Safe's receive() requires ~6000-20000 gas for event emission + accounting
// Using .transfer() to a Gnosis Safe will ALWAYS revert
// Problem 2: EIP-1884 (Istanbul) raised SLOAD cost from 200 to 800 gas
// Some receive() functions that used storage were suddenly too expensive
contract PushPaymentContract {
// ❌ Will revert if recipient is a smart wallet (Gnosis Safe, Argent, etc.)
function payWithTransfer(address payable recipient) external payable {
recipient.transfer(msg.value); // ❌ 2300 gas — too little for smart wallets
}
// ✅ Forward all remaining gas — compatible with smart wallets
function payWithCall(address payable recipient) external payable {
(bool ok,) = recipient.call{value: msg.value}("");
require(ok, "Payment failed");
}
}Non-Standard ERC20 Tokens: The USDT Problem
The ERC20 standard says transfer() should return a bool indicating success or failure. However, many widely-used tokens — including USDT (Tether), the largest stablecoin — do not return any value. Calling USDT's transfer() with a standard Solidity interface will revert because Solidity expects a return value and finds none.
// Standard ERC20 interface expects bool return
interface IERC20Standard {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract UncheckedERC20 {
// ❌ Problem 1: Calling USDT.transfer() reverts because USDT returns nothing
// ABI decoder expects (bool) — finds empty return data — panics
function buggyTransfer(address token, address to, uint256 amount) external {
IERC20Standard(token).transfer(to, amount); // ❌ Reverts for USDT!
}
// ❌ Problem 2: Some tokens return false instead of reverting
// If you don't check the return value, you think transfer succeeded
function uncheckedTransfer(address token, address to, uint256 amount) external {
bool success = IERC20Standard(token).transfer(to, amount);
// ❌ If success is false, nothing prevents this from continuing
}
// ✅ CORRECT: Use SafeERC20 from OpenZeppelin
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function safeTransfer(IERC20 token, address to, uint256 amount) external {
token.safeTransfer(to, amount); // ✅ Handles all ERC20 variants
}
}SafeERC20 Internals: What It Does Differently
SafeERC20 is a wrapper library that makes ERC20 calls robust against non-standard token implementations. It uses low-level .call() internally and manually inspects the return data — accepting both true returns and empty returns (like USDT), but reverting on explicit false returns.
library SafeERC20 {
function safeTransfer(IERC20 token, address to, uint256 amount) internal {
_callOptionalReturn(token, abi.encodeCall(token.transfer, (to, amount)));
}
function _callOptionalReturn(IERC20 token, bytes memory data) private {
// Low-level call — captures returndata even if empty
(bool success, bytes memory returndata) = address(token).call(data);
// Must succeed at the call level
require(success, "SafeERC20: low-level call failed");
// If returndata exists, it must decode to true
// If returndata is empty (USDT pattern), we accept it
if (returndata.length > 0) {
require(abi.decode(returndata, (bool)), "SafeERC20: operation did not succeed");
}
}
}Force-Sending ETH: Bypassing receive() Checks
Some contracts use address(this).balance == 0 as an invariant or initialization check. This assumption can be broken because ETH can be forced into any contract — even one with no receive() or fallback() — via selfdestruct(target). The target contract's code is not executed during a selfdestruct send.
// Vulnerable: checks balance == 0 as a state guard
contract BalanceGuardVulnerable {
bool public initialized;
// ❌ An attacker can force ETH into this contract via selfdestruct
// After that, initialize() can never be called again
function initialize() external {
require(address(this).balance == 0, "Already has ETH");
initialized = true;
}
}
// Attacker contract — uses selfdestruct to force ETH
contract ForceFeeder {
constructor(address payable target) payable {
// Sends ETH to target even if target has no receive() function
// Target's receive() is NOT called — ETH arrives silently
selfdestruct(target);
}
}
// ✅ FIX: Never rely on address(this).balance for security checks
// Use an explicit initialized bool or a tracked deposit counter instead
contract BalanceGuardFixed {
bool public initialized;
uint256 public trackedDeposits; // Track deposits explicitly, not via balance
function initialize() external {
require(!initialized, "Already initialized");
initialized = true;
}
}Multi-Call Atomicity: One Failure Should Revert All
contract BatchPayer {
// ❌ If any payment fails, others already sent are not rolled back
function batchPay(address[] calldata recipients, uint256[] calldata amounts)
external payable {
for (uint256 i = 0; i < recipients.length; ) {
(bool ok,) = recipients[i].call{value: amounts[i]}("");
// ❌ If ok is false here, previous payments are NOT undone
// Partial payment — some get paid, some don't
unchecked { ++i; }
}
}
// ✅ FIXED: Require all payments succeed or revert everything
function batchPaySafe(address[] calldata recipients, uint256[] calldata amounts)
external payable {
for (uint256 i = 0; i < recipients.length; ) {
(bool ok,) = recipients[i].call{value: amounts[i]}("");
require(ok, "Payment failed — reverting all"); // ✅ Atomic
unchecked { ++i; }
}
}
}Foundry Test: Unchecked Call Vulnerability
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
// Contract that rejects all ETH (simulates a contract-only recipient)
contract RejectETH {
receive() external payable {
revert("I reject ETH");
}
}
contract UncheckedCallTest is Test {
UncheckedCallVulnerable public vulnContract;
UncheckedCallFixed public fixedContract;
RejectETH public rejecter;
function setUp() public {
vulnContract = new UncheckedCallVulnerable();
fixedContract = new UncheckedCallFixed();
rejecter = new RejectETH();
vm.deal(address(vulnContract), 10 ether);
vm.deal(address(fixedContract), 10 ether);
}
/// @notice Vulnerable contract silently loses ETH when recipient rejects
function test_uncheckedCall_silentlyFails() public {
// Register a deposit for the rejecter address
vulnContract.balances[address(rejecter)] = 1 ether;
// Trigger withdraw — call to rejecter fails silently
vm.prank(address(rejecter));
vulnContract.withdraw(1 ether);
// Balance was zeroed but ETH never arrived — user lost funds!
assertEq(vulnContract.balances[address(rejecter)], 0);
assertEq(address(rejecter).balance, 0); // No ETH received
}
/// @notice Fixed contract reverts when recipient rejects
function test_checkedCall_revertsOnFailure() public {
fixedContract.balances[address(rejecter)] = 1 ether;
vm.prank(address(rejecter));
vm.expectRevert("ETH transfer failed");
fixedContract.withdraw(1 ether);
// Balance unchanged — state preserved on revert
assertEq(fixedContract.balances[address(rejecter)], 1 ether);
}
}Real-World Exploits
| Protocol | Year | Loss | Bug | Root Cause |
|---|---|---|---|---|
| King of Ether | 2016 | ~0.1 ETH | Unchecked .send() | When a king was set, old king's refund silently failed if they were a contract |
| BatchOverflow (BEC) | 2018 | $900M market cap | Unchecked return | transfer() returned false but caller didn't check — balance never deducted |
| Uniswap v1 | 2020 | $300k | Non-standard ERC20 | ERC777 reentrancy via tokensToSend() hook not anticipated |
Key Takeaways
- Always check the bool return of .call(). An unchecked call that silently fails will corrupt your accounting — users lose funds, your state becomes inconsistent.
- Never use .transfer() or .send() in new code. The 2300 gas stipend breaks smart wallet compatibility and provides false security. Use .call() with a checked return value.
- Always use SafeERC20 for token transfers. USDT, BNB, and other widely-used tokens do not conform to the standard ERC20 interface. SafeERC20 handles all variants correctly.
- Never rely on address(this).balance for security checks. ETH can be force-sent via selfdestruct, bypassing receive(). Use an explicit tracking variable instead.
- Treat every external call as a potential failure. Networks fail, contracts have bugs, gas limits are hit. Design your contract to handle all failure modes correctly.
ERC20 allowance() and approve() Interaction Bugs
// Classic ERC20 approve() race condition:
// Alice approves Bob for 10 tokens, then wants to change to 20
// If Bob is watching: he spends the 10 before Alice's new tx,
// then spends the 20 after. Bob got 30 instead of either 10 or 20.
// ❌ Sequential approve calls are not safe against racing spenders
token.approve(bob, 20); // Bob front-runs: spend old 10, then spend new 20
// ✅ SAFE PATTERN 1: Always set to 0 first, then set new value
token.approve(bob, 0); // Revoke first
token.approve(bob, 20); // Then set new amount
// ✅ SAFE PATTERN 2: Use increaseAllowance / decreaseAllowance
// These are atomic and don't have the race condition
token.increaseAllowance(bob, 10); // Safely adds 10 to existing
token.decreaseAllowance(bob, 5); // Safely subtracts 5
// Note: increaseAllowance/decreaseAllowance were removed from OZ 5.x
// because they don't solve all race conditions either
// The fundamental recommendation: use permit() (EIP-2612) insteadAudit Checklist: External Calls
| Check | What to Look For | Correct Pattern |
|---|---|---|
| ETH transfer method | .transfer() or .send() in new code | Use .call{value:}("") with bool check |
| ERC20 transfer | Direct IERC20.transfer() without SafeERC20 | Use SafeERC20.safeTransfer() |
| Return value | Discarded (bool success, ) from .call() | require(success) after every .call() |
| Force ETH | require(balance == 0) for initialization | Use explicit bool initialized flag |
| Batch atomicity | Loop of calls without all-or-nothing | Require each call succeeds or revert all |
| Gas forwarding | 2300 gas stipend to smart contract recipients | Forward all gas via .call() |
When auditing, search for every occurrence of .transfer(, .send(, and .call{value in the codebase. For each one: check if the return value is handled, check if the recipient could be a contract, and check if failure is possible and how it's handled. A single missed return value check in a payout function can permanently strand user funds.
Real-World Consequences of Unchecked Calls
// Scenario: Token distribution contract with unchecked .call()
// Protocol: distributes yield to 100 depositors every week
contract YieldDistributorBroken {
address[] public depositors;
mapping(address => uint256) public owed;
function distribute() external {
for (uint256 i = 0; i < depositors.length; ) {
address dep = depositors[i];
uint256 amount = owed[dep];
if (amount > 0) {
owed[dep] = 0; // Cleared ✓
dep.call{value: amount}(""); // ❌ Return ignored
// If dep is a contract without receive(): call fails silently
// owed[dep] is now 0 and ETH was NOT sent — funds permanently lost
}
unchecked { ++i; }
}
}
// Consequences of silent failure:
// 1. User's balance cleared — they can't claim again
// 2. ETH remains in contract — unattributed, unrecoverable
// 3. Protocol appears to have distributed but didn't
// 4. No event logged for the failed payment — invisible bug
// ✅ FIXED: Use pull pattern — users claim their own funds
function claim() external {
uint256 amount = owed[msg.sender];
require(amount > 0, "Nothing owed");
owed[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
}Token Return Value Inconsistencies in the Wild
Beyond USDT, dozens of ERC20 tokens have non-standard behavior that surprises developers. Understanding the full landscape of token quirks is essential for building integrations that work correctly with any token.
| Token Behavior | Examples | Problem | Fix |
|---|---|---|---|
| Returns false instead of reverting | USDT (mainnet), ZRX | Unchecked call misses failure | SafeERC20.safeTransfer() |
| No return value at all | USDT (pre-fix), BNB | High-level call reverts on decode | SafeERC20 with assembly fallback |
| Fee on transfer | USDT (configured), STA, PAXG | Received amount less than requested | Check balance before/after |
| Rebase (supply changes) | stETH, AMPL, aTokens | Stored balance stale after rebase | Use shares, not raw balances |
| Pausable transfers | USDC, many stablecoins | Transfers can be frozen mid-operation | Handle transfer failure gracefully |
| Blacklist | USDC, USDT | Specific addresses cannot transfer | Avoid relying on guaranteed transferability |
| Approval required reset | USDT (must zero first) | approve(max) fails if allowance nonzero | forceApprove() or zero then set |
| Transfer to self | Some tokens revert | Protocol internal transfers break | Skip if from == to |
library TokenHelper {
using SafeERC20 for IERC20;
/// @notice Transfer tokens and return actual amount received
/// @dev Handles fee-on-transfer tokens by measuring balance delta
function safeTransferFromAndMeasure(
IERC20 token,
address from,
address to,
uint256 amount
) internal returns (uint256 received) {
uint256 balBefore = token.balanceOf(to);
token.safeTransferFrom(from, to, amount);
received = token.balanceOf(to) - balBefore;
require(received > 0, "Zero received after transfer");
}
/// @notice Approve with reset for tokens that require it (USDT pattern)
function safeApproveMax(IERC20 token, address spender) internal {
uint256 current = token.allowance(address(this), spender);
if (current > 0) {
token.forceApprove(spender, 0);
}
token.forceApprove(spender, type(uint256).max);
}
}Multicall Atomicity Bugs
Multicall patterns allow batching multiple operations into a single transaction, but they introduce subtle atomicity risks. When one call in the batch involves an external call that silently fails, subsequent operations in the batch may operate on stale or incorrect state — the transaction as a whole succeeds, but the outcome is wrong.
// ❌ VULNERABLE: Multicall ignores individual call failures
function multicall(bytes[] calldata calls) external {
for (uint256 i; i < calls.length; ++i) {
(bool success,) = address(this).call(calls[i]);
// BUG: success not checked — failed calls silently propagate
}
}
// Attacker can craft: [withdraw(1000 ETH), anotherAction()]
// If withdraw() fails silently, anotherAction() executes with wrong state
// ✅ FIXED: Revert entire batch if any call fails
function multicall(bytes[] calldata calls)
external
returns (bytes[] memory results)
{
results = new bytes[](calls.length);
for (uint256 i; i < calls.length; ++i) {
(bool success, bytes memory result) = address(this).delegatecall(calls[i]);
if (!success) {
// Bubble up revert reason from inner call
assembly { revert(add(result, 32), mload(result)) }
}
results[i] = result;
}
}
// ⚠️ WARNING: delegatecall-based multicall + msg.value is dangerous
// Multiple calls share msg.value → each call can spend the full ETHIf a multicall uses delegatecall and accepts payable functions, the same msg.value is visible across all calls in the batch. An attacker can call [deposit(1 ETH), deposit(1 ETH)] with only 1 ETH attached, effectively doubling their deposit. This exact pattern was used in the Opyn hack. Only allow payable in multicall if exactly one call in the batch uses msg.value, or track consumed value explicitly.
Key Takeaways
- Never use
transfer()orsend()— the 2300 gas stipend breaks with any non-trivial recipient logic. Always usecall{value: amount}("")with return value checking. - Use OpenZeppelin's
SafeERC20for all token interactions — it handles missing return values, false returns, and the USDT approval reset pattern. - For fee-on-transfer tokens, always measure the actual balance delta after a transfer — the amount you asked for is not the amount you receive.
- Force-sending ETH via
selfdestructor coinbase can cause protocols that assertaddress(this).balance == internalBalanceto permanently lock funds. - Multicall with
delegatecalland payable functions is a well-known double-spend vector — validate this pattern carefully in any protocol that implements it. - Pull payment patterns (users withdraw their own funds) are safer than push patterns (protocol pushes to users) — they eliminate single-point failure and reentrancy risk from ETH distributions.