Unchecked Call Return Value: The Silent Failure Draining Vaults
Your contract thinks it sent 100 ETH. It didn't. The recipient call reverted, returned false, and your code kept marching forward like nothing happened. SWC-104 — unchecked call return value — is one of the oldest Solidity bugs in the book, and it still ships to mainnet weekly. Here's the bug, the…
The Bug in One Sentence
Solidity's low-level calls — `call`, `send`, `delegatecall`, `staticcall` — do **not** revert on failure. They return a `bool`. If you ignore that bool, your contract happily proceeds as if the call succeeded, even when it didn't.
That's it. That's the whole bug. And it has been emptying vaults since 2016.
Why It Still Happens in 2025
Developers coming from higher-level languages assume that "if the call failed, it would throw." Solidity disagrees. The EVM treats `CALL` as a value-returning opcode, not an exception-propagating one. Combine that with the deprecated-but-still-used `address.send()` (which returns false on failure with a 2300 gas stipend) and you get a footgun that compiles cleanly, passes most unit tests, and breaks in production.
It gets worse: many ERC-20 tokens (USDT being the famous offender) do not return a boolean at all on `transfer`. A naive `require(token.transfer(...))` will revert on USDT because there's no return data to decode. A naive `token.transfer(...)` with no check will silently fail on tokens like ZRX that return `false` instead of reverting on insufficient balance.
The Vulnerable Pattern
```solidity
// VULNERABLE
contract Auction {
address public highestBidder;
uint256 public highestBid;
function bid() external payable {
require(msg.value > highestBid, "bid too low");
// Refund the previous highest bidder
highestBidder.send(highestBid); // <-- return value ignored
highestBidder = msg.sender;
highestBid = msg.value;
}
}
```
If `highestBidder` is a contract whose fallback reverts, runs out of the 2300 gas stipend, or simply doesn't exist as payable, the `send` returns `false`. The previous bidder is never refunded — but the state still updates, and their ETH is permanently locked in the contract.
This is almost verbatim the **King of the Ether (2016)** bug. The contract crowned new monarchs and tried to pay out the previous one with `send()`. When the previous king was a contract with an expensive fallback, the payout silently failed. The throne updated. The ETH stayed put. Hundreds of ETH were stuck — a meaningful sum then, a fortune now.
The Fix
Pattern 1: Check the return value
```solidity
(bool success, ) = highestBidder.call{value: highestBid}("");
require(success, "refund failed");
```
Better, but introduces a denial-of-service vector: a malicious previous bidder can revert in their receive function and block all future bids.
Pattern 2: Pull payments (recommended)
```solidity
// FIXED
contract Auction {
address public highestBidder;
uint256 public highestBid;
mapping(address => uint256) public pendingReturns;
function bid() external payable {
require(msg.value > highestBid, "bid too low");
if (highestBidder != address(0)) {
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdraw() external {
uint256 amount = pendingReturns[msg.sender];
require(amount > 0, "nothing to withdraw");
pendingReturns[msg.sender] = 0; // CEI: effects before interaction
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "withdraw failed");
}
}
```
This is the OpenZeppelin `PullPayment` pattern. The recipient pulls funds when they choose; one failing recipient cannot grief the rest of the system.
ERC-20 Silent Failures
The token version of this bug is even nastier:
```solidity
// VULNERABLE — many things wrong here
function deposit(IERC20 token, uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
```
If the token returns `false` (like older OMG, ZRX), the user gets credited without paying. If the token is USDT and you wrap it in `require(...)`, it reverts because USDT returns no data. Use `SafeERC20`:
```solidity
using SafeERC20 for IERC20;
function deposit(IERC20 token, uint256 amount) external {
token.safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
```
`safeTransferFrom` handles both the boolean-returning and the no-return-data cases, and reverts on either form of failure. There is no excuse to write raw ERC-20 transfers in 2025.
Real-World Carnage
**King of the Ether (Feb 2016)** — ~98 ETH stuck due to unchecked `send` to contract recipients. The canonical case study.
- **Akropolis (Nov 2020, $2M)** — While the headline cause was a reentrancy/flash-loan combo, the exploit chain relied on assumed token transfer success that was never validated against the resulting balance delta. Same family of mistake.
- **Countless rugpull-adjacent ERC-20 forks** — Tokens with broken `transfer` implementations are routinely listed on DEXes that don't check return values, leading to phantom liquidity provisions and stuck LP positions.
If you're auditing a protocol that touches arbitrary tokens (DEX, lending market, bridge), every single external call needs return-value handling. Run our [free AI audit](https://www.cryptohawking.com/audit) — it flags every unchecked low-level call and raw ERC-20 transfer in your codebase in minutes.
Detection Checklist
Grep your codebase for:
`.call(` / `.call{` without `(bool` capture
- `.send(` anywhere (just remove it; it's deprecated for good reason)
- `.transfer(` on `address payable` (2300 gas stipend will break post-Istanbul recipients)
- Raw `IERC20.transfer` / `transferFrom` without `SafeERC20`
- Inline assembly `call` opcodes — easy to miss the returned status
Static analyzers like Slither catch most of these (`unchecked-lowlevel`, `unchecked-send`, `unchecked-transfer`). They will not catch logic bugs where you check the return value but then ignore it semantically — for that you need humans. Our [manual audit](https://www.cryptohawking.com/audit/manual) ($5,000, 3 business days, paid in ETH/SOL/USDT) reviews every cross-contract interaction for exactly this class of subtle failure.
The Rule
If a function returns a value, you handle it. No exceptions. If the language designer bothered to put `bool` in the return signature, the language designer is telling you something. Listen.
Never write `someContract.call(...)` and walk away. Never write `token.transfer(...)` without `SafeERC20`. Never use `send`. And when in doubt, use pull payments — your users can withdraw on their own gas, on their own time, with their own failure modes.
Your vault will thank you.
FAQ
Why doesn't Solidity revert automatically when a low-level call fails?
Because the underlying EVM `CALL` opcode doesn't throw — it pushes a success flag (0 or 1) onto the stack. Solidity exposes that flag as the boolean return value of `.call()`, `.send()`, etc. High-level Solidity calls (like calling a function on a typed contract reference) *do* auto-revert on failure, but low-level calls preserve the raw EVM semantics so you can implement custom error handling, try/catch patterns, or intentional fire-and-forget flows. The tradeoff: if you forget to check, the EVM doesn't care.
Is `.transfer()` safe to use instead of `.call()`?
No. `address.transfer()` forwards a fixed 2300 gas stipend, which was fine pre-Istanbul but breaks recipients whose `receive`/`fallback` does anything beyond a SLOAD — including proxies, multisigs like Gnosis Safe, and any contract using EIP-2929 storage access pricing. It also still reverts on failure, which can DoS your contract. Consensys and OpenZeppelin both recommend `.call{value: x}("")` with a return value check, combined with the checks-effects-interactions pattern or pull payments.
How do I handle ERC-20 tokens that don't return a boolean, like USDT?
Use OpenZeppelin's `SafeERC20` library. It uses inline assembly to inspect the `returndatasize` after the call: if zero bytes are returned, it treats success as the absence of a revert; if 32 bytes are returned, it decodes and checks the boolean. This handles compliant tokens, non-compliant tokens (USDT, BNB legacy), and tokens that return `false` instead of reverting. Never call raw `transfer` or `transferFrom` on an arbitrary token — you have no idea what convention it follows.
Can checking return values introduce a denial-of-service?
Yes, and this is a critical second-order concern. If your contract pushes ETH to user-supplied addresses and reverts on failure, a single malicious recipient can grief everyone else (the `auction.bid()` example in the post). The fix is the pull-payment pattern: credit the user's balance in a mapping, and let them withdraw themselves. Failures only affect the withdrawing user, not the protocol. This also aligns with checks-effects-interactions and eliminates most reentrancy surface.
What tools detect unchecked return values automatically?
Slither catches the most cases via the `unchecked-lowlevel`, `unchecked-send`, and `unchecked-transfer` detectors. Mythril and Securify flag related patterns. Foundry's `forge inspect` plus property-based fuzzing can surface logic-level cases where the return value is checked but mishandled. None of these catch every variant — especially when calls are wrapped in helper libraries or assembly. A static-analysis pass is necessary but not sufficient; a manual review of every external call site is the only way to be sure.