🌳 Advanced ⏱️ 40 min

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.

🚨 Critical

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

MethodGas ForwardedFailure BehaviorReturn ValueRecommendation
address.transfer(amount)2300 (stipend)Reverts callerNoneAvoid — 2300 gas too restrictive
address.send(amount)2300 (stipend)Returns falseboolAvoid — easy to forget check
address.call{value:}("")All remaining gasReturns false(bool, bytes)Use this — but always check bool
⚠️ Unchecked .call() Return Value
// 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.

⚠️ Why 2300 Gas Stipend Is a Problem
// 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.

⚠️ Non-Standard ERC20 Return Values
// 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.

🔒 SafeERC20 Logic (Simplified)
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.

⚠️ Force-Sending ETH via selfdestruct
// 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

⚠️ Multi-Call Without Atomicity
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

🧪 Foundry Test — Unchecked External Calls
// 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

ProtocolYearLossBugRoot Cause
King of Ether2016~0.1 ETHUnchecked .send()When a king was set, old king's refund silently failed if they were a contract
BatchOverflow (BEC)2018$900M market capUnchecked returntransfer() returned false but caller didn't check — balance never deducted
Uniswap v12020$300kNon-standard ERC20ERC777 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

⚠️ Double-Spend via allowance Race Condition
// 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) instead

Audit Checklist: External Calls

CheckWhat to Look ForCorrect Pattern
ETH transfer method.transfer() or .send() in new codeUse .call{value:}("") with bool check
ERC20 transferDirect IERC20.transfer() without SafeERC20Use SafeERC20.safeTransfer()
Return valueDiscarded (bool success, ) from .call()require(success) after every .call()
Force ETHrequire(balance == 0) for initializationUse explicit bool initialized flag
Batch atomicityLoop of calls without all-or-nothingRequire each call succeeds or revert all
Gas forwarding2300 gas stipend to smart contract recipientsForward all gas via .call()
💡 Tip

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

📋 What Happens When a Call Fails Silently
// 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 BehaviorExamplesProblemFix
Returns false instead of revertingUSDT (mainnet), ZRXUnchecked call misses failureSafeERC20.safeTransfer()
No return value at allUSDT (pre-fix), BNBHigh-level call reverts on decodeSafeERC20 with assembly fallback
Fee on transferUSDT (configured), STA, PAXGReceived amount less than requestedCheck balance before/after
Rebase (supply changes)stETH, AMPL, aTokensStored balance stale after rebaseUse shares, not raw balances
Pausable transfersUSDC, many stablecoinsTransfers can be frozen mid-operationHandle transfer failure gracefully
BlacklistUSDC, USDTSpecific addresses cannot transferAvoid relying on guaranteed transferability
Approval required resetUSDT (must zero first)approve(max) fails if allowance nonzeroforceApprove() or zero then set
Transfer to selfSome tokens revertProtocol internal transfers breakSkip if from == to
Comprehensive Token Transfer Wrapper
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.

Multicall with Silent Failure Propagation
// ❌ 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 ETH
⚠️ Multicall + msg.value Double-Spend

If 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() or send() — the 2300 gas stipend breaks with any non-trivial recipient logic. Always use call{value: amount}("") with return value checking.
  • Use OpenZeppelin's SafeERC20 for 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 selfdestruct or coinbase can cause protocols that assert address(this).balance == internalBalance to permanently lock funds.
  • Multicall with delegatecall and 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.