Unchecked Return Value Attacks
How the attack works and how to prevent it
Unchecked Return Values in Solidity: Detection and Prevention
An unchecked return value lets a contract continue after an external operation fails. The code may compile and execute without reverting even though an ETH transfer, token operation, or low-level call did not complete.
The resulting state can record a payment or deposit that never occurred. Depending on the surrounding logic, this can lock funds, create false balances, or expose assets to withdrawal.
This guide explains the return behavior of send(), call(), and ERC-20 operations, revisits the King of the Ether Throne incident, and shows how to use explicit checks and SafeERC20.
What exactly is an unchecked return value vulnerability?
An unchecked return value vulnerability occurs when a contract makes an external call, such as sending ETH or calling another contract, without verifying whether the operation succeeded.
In Solidity, low-level functions such as .send() and .call() return false on failure instead of automatically reverting. If the caller ignores that value, subsequent state changes still execute.
The broken ATM analogy
Imagine an ATM that jams but still prints a receipt saying "Withdrawal Successful."
The account balance is reduced even though the cash never leaves the machine. A contract creates the same inconsistency when it updates internal accounting after an unchecked failed transfer.
Why unchecked return values matter
Unchecked return values have appeared in contracts since Ethereum's early years and remain represented in current vulnerability classifications:
| Classification | Identifier | Name |
|---|---|---|
| SWC Registry | SWC-104 | Unchecked Call Return Value |
| OWASP SC Top 10 | SC06 (2025) | Unchecked External Calls |
| DASP Top 10 | #4 | Unchecked Low Level Calls |
| CWE (MITRE) | CWE-252 | Unchecked Return Value |
Unchecked external calls moved from #10 to #6 in the 2025 OWASP Smart Contract Top 10, indicating that the pattern remains common in assessed systems.
SWC-104: Unchecked Call Return Value
SWC-104 is the Smart Contract Weakness Classification label for unchecked call return values. In plain terms, the contract asks another address to do something, receives a success or failure signal, and then ignores that signal.
The most common Solidity version looks like this:
(bool success, ) = recipient.call{value: amount}("");
// success is ignored
paid[recipient] = true;
That code can mark a payment as complete even when the ETH transfer failed. The same pattern appears with send, delegatecall, staticcall, raw ERC-20 transfer, and raw ERC-20 transferFrom.
For auditors, the important question is not "does this code compile?" It is: what state changes if the external operation fails? If accounting, ownership, votes, debt, shares, rewards, or payout status update after a failed call, the bug is no longer theoretical.
The two major attack surfaces
Frequency numbers for this bug class circulate widely - 10% of analyzed contracts in one dataset, 18% of audit findings in another - but the underlying samples are not published, so treat them as indicative rather than measured.

The King of the Ether Throne incident
King of the Ether Throne was an early Ethereum game affected by unchecked .send() return values. Its post-mortem became a widely cited example of this vulnerability.
What was King of the Ether?
Launched in February 2016, King of the Ether Throne allowed a player to become the current "King" by sending more ETH than the previous player. The contract then attempted to compensate the previous king automatically.
The payment flow depended on every compensation transfer succeeding.
The bug: February 6-8, 2016
During the "Turbulent Age," the contract used .send() to pay deposed monarchs without checking the return value.
// THE VULNERABLE LINE
currentMonarch.etherAddress.send(compensation);
// Return value completely ignored!
When a player's address was a contract wallet (like an Ethereum Mist wallet), the 2,300 gas stipend forwarded by .send() wasn't enough for the wallet's fallback function. The send failed silently, but the game continued - recording the payment as successful and crowning a new king.
The impact
-
Three transactions failed silently during the Turbulent Age
-
98.5 ETH had to be manually refunded (including a 7.77 ETH failed refund and a 42.273 ETH failed compensation)
-
Players lost their rightful ETH with no on-chain indication anything went wrong
-
The contract marked payments as complete even though money never moved
The King of the Ether post-mortem was published February 20, 2016, one of Ethereum's first public smart contract security disclosures and still the standard illustration of SWC-104.
The lesson
The direct fix was to check the return value of .send() and revert when it returned false.
How unchecked return value attacks work: step by step
The following sequence shows how a failed call can create inconsistent state.
Low-level call return behavior
| Function | Returns | On Failure | Gas Forwarded |
|---|---|---|---|
.send(amount) |
bool |
Returns false (no revert) |
2,300 gas |
.call{value: amount}("") |
(bool, bytes) |
Returns false (no revert) |
All remaining gas |
.delegatecall(data) |
(bool, bytes) |
Returns false (no revert) |
All remaining gas |
.transfer(amount) |
Nothing | Automatically reverts | 2,300 gas |
Unlike
transfer(), functions such as .send() and .call() return false on failure and allow execution to continue.The attack flow
| Step | What Happens | Contract State |
|---|---|---|
| 1 | User calls a function (e.g., sendPrize()) |
Normal state |
| 2 | Contract uses .send() or .call() to transfer ETH |
Pending... |
| 3 | Transfer fails (recipient rejects, gas limit, etc.) | Returns false |
| 4 | Return value is not checked | false is ignored |
| 5 | Contract continues executing next lines | State updates proceed |
| 6 | State records the transfer as complete | Inconsistent state |
| 7 | Funds are stuck or available for theft | Exploitable condition |
The attack phases
The attacker finds a contract that uses .send() or .call() without checking the return value - or uses IERC20.transfer() without SafeERC20.
The attacker deploys a contract whose receive() function deliberately reverts, or exploits gas limits to cause the transfer to fail. For ERC-20 attacks, they use a token that returns false on failure instead of reverting.
Because the contract didn't check the return value, it recorded the transfer as successful. The attacker can now exploit the mismatch - withdrawing funds that were never actually sent, or depositing tokens that never arrived.
Depending on the vulnerability, the attacker either steals funds (the contract pays out based on false state), locks funds permanently (the contract thinks it already paid), or gets free token deposits credited to their balance.
{
"title": "🎬 Unchecked send: 'paid' on paper, prize stays stealable",
"stage": { "width": 920, "height": 440 },
"nodes": [
{ "id": "attacker", "label": "Attacker", "role": "calls withdrawLeftOver", "emoji": "🧑💻", "x": 60, "y": 200, "color": "red" },
{ "id": "lotto", "label": "The Lotto", "role": "pays via .send()", "emoji": "🎟️", "x": 440, "y": 60, "color": "cyan" },
{ "id": "winner", "label": "Winner (contract)", "role": "needs > 2300 gas", "emoji": "📜", "x": 440, "y": 330, "color": "purple" }
],
"links": [
{ "from": "lotto", "to": "winner" },
{ "from": "attacker", "to": "lotto" },
{ "from": "lotto", "to": "attacker" }
],
"nets": [
{ "id": "atk", "label": "Attacker" },
{ "id": "vault", "label": "Lotto balance" }
],
"legend": [
{ "cls": "call", "label": "contract call" },
{ "cls": "token", "label": "ETH transfer" },
{ "cls": "sig", "label": "state write" },
{ "cls": "fail", "label": "failed / reverted" }
],
"scenarios": {
"Vulnerable (return value ignored)": [
{ "note": "The lotto holds a <b>10 ETH</b> prize and pays the winner with <b>.send()</b> - never checking the return value.", "hi": ["lotto"], "bal": { "lotto": "prize: 10 ETH", "winner": "0 ETH" }, "net": { "atk": "0", "vault": "10 ETH" } },
{ "note": "sendToWinner() runs. <b>.send()</b> forwards only 2,300 gas to the winner's contract.", "hi": ["lotto","winner"], "chip": { "from": "lotto", "to": "winner", "label": "📤 .send(10 ETH)", "cls": "token" } },
{ "note": "The winner's fallback needs more than 2,300 gas, so the send <b>fails and returns false</b> - the ETH never leaves.", "tone": "bad", "hi": ["winner"], "chip": { "from": "winner", "to": "lotto", "label": "✗ send failed", "cls": "fail" } },
{ "note": "But the boolean is ignored: the lotto sets <b>paidOut = true</b> anyway. The 10 ETH is still in the contract.", "tone": "bad", "hi": ["lotto"], "chip": { "from": "lotto", "to": "lotto", "label": "paidOut = true", "cls": "sig" }, "bal": { "lotto": "10 ETH (stuck)" } },
{ "note": "Now <b>withdrawLeftOver()</b> is unlocked (it only needs paidOut). Anyone calls it and drains the 'already paid' prize.", "tone": "bad", "hi": ["attacker","lotto"], "chip": { "from": "lotto", "to": "attacker", "label": "💸 withdraw 10 ETH", "cls": "token" }, "bal": { "lotto": "0 ETH", "attacker": "+10 ETH" }, "net": { "atk": "+10 ETH", "vault": "0 ETH" } }
],
"Fixed (require the result)": [
{ "note": "Same setup, but the payout is now guarded with <b>require(success)</b> after the send (or a pull-payment pattern).", "hi": ["lotto"], "bal": { "lotto": "prize: 10 ETH", "winner": "0 ETH" }, "net": { "atk": "0", "vault": "10 ETH" } },
{ "note": "sendToWinner() runs and the send to the contract wallet fails, returning false - exactly as before.", "hi": ["lotto","winner"], "chip": { "from": "lotto", "to": "winner", "label": "📤 .send(10 ETH)", "cls": "token" } },
{ "note": "Now <b>require(success)</b> catches the false and <b>reverts the whole transaction</b>. paidOut is never set.", "tone": "ok", "hi": ["lotto"], "chip": { "from": "lotto", "to": "lotto", "label": "require(success) ✗ revert", "cls": "fail" }, "bal": { "lotto": "prize: 10 ETH" } },
{ "note": "Nothing is recorded as paid, so withdrawLeftOver() stays locked. The prize can never be drained against a phantom payout.", "tone": "ok", "hi": ["lotto"], "bal": { "lotto": "10 ETH safe" }, "net": { "atk": "0", "vault": "10 ETH" } }
]
}
}
Unchecked return value vulnerable code examples
The examples progress from an unchecked .send() to token and delegatecall cases.
Example 1: Unchecked .send()
This is the basic pattern documented by SWC-104.
This contract is intentionally vulnerable. Never use this pattern in production.
// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;
contract VulnerableLotto {
bool public paidOut = false;
address public winner;
uint256 public winAmount;
constructor(address _winner) payable {
winner = _winner;
winAmount = msg.value;
}
function sendToWinner() public {
require(!paidOut, "Already paid");
// VULNERABILITY: Return value of send() is IGNORED!
payable(winner).send(winAmount);
// State updates even if send() failed
paidOut = true;
}
function withdrawLeftOver() public {
require(paidOut, "Winner not paid yet");
// Anyone can drain remaining funds after "payout"
payable(msg.sender).transfer(address(this).balance);
}
}
Why is this vulnerable?
-
.send()returnsfalseif the recipient is a contract whose fallback uses more than 2,300 gas -
The return value is completely ignored
-
paidOutis set totrueregardless of whether the winner actually received ETH -
Anyone can then call
withdrawLeftOver()and steal the funds
Example 2: Unchecked .call() for ETH transfer
Modern Solidity favors .call() over .send(), but it has the same unchecked return problem.
// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Update state first (good CEI practice)
balances[msg.sender] = 0;
// VULNERABILITY: Return value of .call() is IGNORED!
payable(msg.sender).call{value: amount}("");
// If call failed, user's balance is already zeroed out
// Their ETH is now permanently locked in the contract
}
}
Impact: If .call() fails (recipient contract reverts, out of gas, etc.), the user's balance is already set to 0. Their ETH is permanently locked in the contract - they can never withdraw it again.
Example 3: Unchecked ERC-20 return value
Missing ERC-20 return checks are a recurring finding in public audit contests such as Code4rena and Sherlock, largely because the code looks correct at first glance.
// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract VulnerableTokenVault {
mapping(address => mapping(address => uint256)) public tokenBalances;
function deposit(IERC20 token, uint256 amount) external {
// VULNERABILITY: Return value of transferFrom() is NOT checked!
// If token returns false instead of reverting, deposit is
// credited even though no tokens were transferred
token.transferFrom(msg.sender, address(this), amount);
tokenBalances[msg.sender][address(token)] += amount;
}
function withdraw(IERC20 token, uint256 amount) external {
require(tokenBalances[msg.sender][address(token)] >= amount, "Insufficient balance");
tokenBalances[msg.sender][address(token)] -= amount;
// VULNERABILITY: Same issue on withdrawal
token.transfer(msg.sender, amount);
}
}
Why is this dangerous?
-
Tokens like ZRX return
falseon failure instead of reverting. Thedeposit()credits the user's balance even when no tokens arrive. -
Tokens like USDT don't return a value at all. With Solidity >= 0.4.22, the ABI decoder expects return data and may revert on successful transfers - making the contract completely incompatible with Tether.
-
An attacker can call
deposit()with a token that returnsfalse, get credited, then callwithdraw()to drain real tokens deposited by other users.
Example 4: Unchecked .delegatecall()
Unchecked delegatecall return values are especially dangerous because failed delegatecalls can leave the contract in a completely inconsistent state.
// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;
contract VulnerableProxy {
address public implementation;
function forward(bytes memory _data) public {
// VULNERABILITY: delegatecall return value IGNORED!
implementation.delegatecall(_data);
// If the delegatecall fails, execution continues
// Contract state may be partially modified
}
}
OWASP cites this exact pattern under SC06:2025 as an example of unchecked external calls. The Punk Protocol hack exploited a similar vulnerability to drain $8.9 million in August 2021.
How to prevent unchecked return value vulnerabilities: best practices
Preventing unchecked return values requires different techniques depending on whether you're sending ETH or interacting with tokens.
1. Always check return values of low-level calls
This is the most fundamental fix. Every .call(), .send(), .delegatecall(), and .staticcall() must have its return value captured and verified:
// CORRECT: Always check the return value
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
2. Use OpenZeppelin's SafeERC20 for all token interactions
SafeERC20 handles tokens that return false on failure and non-standard tokens, such as USDT, that return no value.
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
using SafeERC20 for IERC20;
// Instead of: token.transfer(recipient, amount);
token.safeTransfer(recipient, amount);
// Instead of: token.transferFrom(sender, recipient, amount);
token.safeTransferFrom(sender, recipient, amount);
3. Use the checks-effects-interactions pattern
Structure every function so that state updates happen BEFORE external calls, and return values are always verified AFTER:
function withdraw(uint256 amount) external {
// CHECKS
require(balances[msg.sender] >= amount, "Insufficient balance");
// EFFECTS
balances[msg.sender] -= amount;
// INTERACTIONS (with return value check!)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Prevention effectiveness comparison
What it does: Wraps transfer(), transferFrom(), and approve() with low-level calls that handle both standard and non-standard token return values.
When to use: Use this wrapper for contracts that interact with ERC-20 tokens, especially tokens with non-standard return behavior.
Limitation: Does not protect against tokens with truly pathological behavior (e.g., Tether Gold returning false on success).
What it does: Captures the boolean return from .call(), .send(), or .delegatecall() and reverts if it's false.
When to use: Every low-level call in every contract.
Limitation: Requires developer discipline. Also, low-level calls return true if the target has no code (EVM design) - you must verify target code existence separately.
What it does: Slither has 4 dedicated detectors: unchecked-transfer, unchecked-lowlevel, unchecked-send, unused-return. Mythril detects SWC-104 via symbolic execution.
When to use: As part of CI/CD pipeline - catches issues before deployment.
Limitation: Cannot catch all edge cases. May produce false positives. Should be combined with manual review.
What it does: .transfer() auto-reverts on failure - no return value to check. But it only forwards 2,300 gas.
When to use: Legacy contracts only. Avoid in new development.
Limitation: The 2,300 gas limit broke after the Istanbul hard fork. Recipients that are smart contracts may not have enough gas to execute their receive logic. Consensys recommends using .call() instead.

Secure code examples
Secure ETH vault with return value checking
// SECURE CONTRACT - ETH transfer return check
pragma solidity ^0.8.20;
contract SecureVault {
mapping(address => uint256) public balances;
event Deposit(address indexed user, uint256 amount);
event Withdrawal(address indexed user, uint256 amount);
function deposit() public payable {
require(msg.value > 0, "Must deposit more than 0");
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw() public {
uint256 amount = balances[msg.sender];
// STEP 1: CHECKS
require(amount > 0, "No balance to withdraw");
// STEP 2: EFFECTS - Update state BEFORE external call
balances[msg.sender] = 0;
// STEP 3: INTERACTIONS - Check the return value!
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "ETH transfer failed");
emit Withdrawal(msg.sender, amount);
}
}
Security features at a glance
| Feature | Protection |
|---|---|
(bool success, ) capture |
Captures return value |
require(success, ...) |
Reverts on failed transfer |
| CEI pattern | State updated before external call |
| Events | Transparency and monitoring |
Secure token vault with SafeERC20
// SECURE CONTRACT - SafeERC20 example
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SecureTokenVault {
using SafeERC20 for IERC20;
mapping(address => mapping(address => uint256)) public tokenBalances;
event TokenDeposit(address indexed user, address indexed token, uint256 amount);
event TokenWithdrawal(address indexed user, address indexed token, uint256 amount);
function deposit(IERC20 token, uint256 amount) external {
// SafeERC20 handles:
// 1. Tokens that return false (e.g., ZRX)
// 2. Tokens that return nothing (e.g., USDT)
// 3. Tokens that revert on failure
token.safeTransferFrom(msg.sender, address(this), amount);
tokenBalances[msg.sender][address(token)] += amount;
emit TokenDeposit(msg.sender, address(token), amount);
}
function withdraw(IERC20 token, uint256 amount) external {
require(tokenBalances[msg.sender][address(token)] >= amount, "Insufficient balance");
tokenBalances[msg.sender][address(token)] -= amount;
token.safeTransfer(msg.sender, amount);
emit TokenWithdrawal(msg.sender, address(token), amount);
}
}
This pattern supports standard ERC-20 tokens as well as commonly encountered tokens with missing or false return values, including USDT, BNB, and OMG.
Non-standard ERC-20 return behavior
The ERC-20 ecosystem includes non-standard implementations that return false, return no data, or impose additional approval requirements.
The missing return value bug - 130+ tokens affected
In 2018, researcher Lukas Cremer discovered that at least 130 deployed ERC-20 tokens on Ethereum had missing return values on transfer(), transferFrom(), or approve(). These tokens were built from early OpenZeppelin templates that didn't return true on success.
Notable affected tokens:
| Token | Issue | Market Cap Impact |
|---|---|---|
| USDT (Tether) | transfer() returns void |
$80B+ market cap |
| BNB (Binance Coin) | transfer() returns void |
Major exchange token |
| OMG (OmiseGo) | transfer() returns void |
Top 50 at time of discovery |
| KNC (Kyber Network) | Missing returns on some functions | Major DeFi token |
Real-world impact: BNB tokens were stuck in Uniswap V1 because the DEX's contract couldn't handle the non-standard return value.
How SafeERC20 handles return data
SafeERC20 uses a three-way logic check that handles every edge case:
// Simplified internal logic of SafeERC20
function _callOptionalReturn(IERC20 token, bytes memory data) private {
// 1. Use low-level call to bypass Solidity's return data check
(bool success, bytes memory returndata) = address(token).call(data);
require(success, "SafeERC20: low-level call failed");
// 2. THE KEY CHECK: accept empty return OR decoded true
if (returndata.length > 0) {
require(abi.decode(returndata, (bool)),
"SafeERC20: ERC20 operation did not succeed");
}
// returndata.length == 0 && success = assumed successful (USDT path)
}
This handles:
-
Standard tokens (return
true) - decoded and verified -
Tokens returning
false- caught and reverted -
Non-standard tokens like USDT (return nothing) - accepted if call succeeded
-
Reverts - propagated upward
Comparing defense libraries
Raw IERC20 Calls
Calling token.transfer() directly breaks with USDT (no return value) and misses failures from tokens that return false instead of reverting. Never use directly.
Solmate SafeTransferLib
Handles return values correctly but does not check code size of the token address. If the token address has no code, safeTransfer() returns success even though nothing happened.
OpenZeppelin SafeERC20
Handles all edge cases: missing returns, false returns, reverts, and verifies code existence. Use forceApprove() for USDT's zero-first approval requirement.
The USDT approval trap
USDT has an additional non-standard behavior: it requires the allowance to be set to 0 before changing it to a new non-zero value. This means safeApprove() fails if the current allowance is non-zero. OpenZeppelin provides forceApprove() to handle this:
// This FAILS with USDT if current allowance is non-zero:
token.safeApprove(spender, newAmount); // REVERTS!
// This WORKS with USDT:
token.forceApprove(spender, newAmount);
// Internally: first sets allowance to 0, then to newAmount
Common misconceptions
"Solidity 0.8+ automatically handles unchecked return values."
Tap to revealSolidity 0.8+ only added overflow/underflow protection. Low-level calls (.call(), .send(), .delegatecall()) still return false on failure without reverting. You must manually check every return value.
"Using .transfer() instead of .call() eliminates the unchecked return problem."
Tap to reveal.transfer() auto-reverts, but only forwards 2,300 gas. After the Istanbul hard fork, this is too little for many smart contract recipients. Consensys recommends .call() with return value checking instead.
"If I call a function on a contract that doesn't exist, .call() will return false."
Tap to revealBy EVM design, .call() and .delegatecall() return true when called on an address with no code. Your contract will think the call succeeded when nothing happened. Always verify target.code.length > 0 before calling.
"All ERC-20 tokens revert on transfer failure, so checking return values is optional."
Tap to revealMany tokens (ZRX, BAT) return false instead of reverting. Others (USDT, BNB, OMG) return nothing at all. At least 130 tokens have this "missing return value bug." Always use OpenZeppelin's SafeERC20.
Related vulnerabilities
Unchecked return values relate to several external-call vulnerability classes.
Reentrancy attacks share a common root cause with unchecked return values: the dangers of external calls. While reentrancy exploits the callback mechanism during ETH transfers, unchecked returns exploit the silent failure of those same transfers. A contract vulnerable to one is often vulnerable to both.
Delegatecall and call attacks are directly connected - the delegatecall function is itself a low-level call whose return value can go unchecked. The Punk Protocol hack combined an unprotected initialize() function with unchecked delegatecall returns to drain $8.9 million.
Denial of service attacks can also be triggered by unchecked returns. When a contract uses require() on a .send() return without a fallback, a malicious recipient can force all transactions to revert - permanently blocking the contract for all users.
Test your unchecked return value knowledge
5 questions on return values, SafeERC20, and low-level calls
Frequently asked questions about unchecked return values
An unchecked return value is when your smart contract sends ETH or calls another contract but doesn't verify whether the operation actually succeeded. Low-level functions like .send() and .call() return false on failure instead of reverting. If you ignore that return value, your contract continues as if everything worked - even when it didn't.
No. Solidity 0.8 only added automatic overflow/underflow protection. Low-level calls (.call(), .send(), .delegatecall(), .staticcall()) still return false on failure without reverting. You must always manually check the return value with require(success).
Early ERC-20 implementations (before the standard was finalized) didn't consistently return a boolean. At least 130 tokens - including USDT, BNB, and OMG - were deployed with transfer() functions that return nothing. This creates a compatibility issue with Solidity contracts that expect a bool return. Use OpenZeppelin's SafeERC20 to handle all variants.
.send() returns false on failure (2,300 gas). .transfer() auto-reverts on failure (2,300 gas). .call() returns false on failure (forwards all remaining gas). In modern Solidity, .call() with explicit return value checking is the recommended approach, because the 2,300 gas limit of .send() and .transfer() can cause failures with smart contract recipients.
Use static analysis tools like Slither (run slither . --detect unchecked-transfer,unchecked-lowlevel,unchecked-send) or Mythril (detects SWC-104). The Solidity compiler also warns about unused return values from low-level calls. For token interactions, ensure you're using SafeERC20 everywhere - a simple grep for .transfer( and .transferFrom( without the safe prefix can reveal vulnerable patterns.
Quick reference: unchecked return value prevention checklist
Before deploying any contract that makes external calls or interacts with tokens:
-
Check every
.call()return value -(bool success, ) = addr.call{value: amount}(""); require(success); -
Check every
.send()return value - or better yet, replace.send()with.call() -
Check every
.delegatecall()return value - failed delegatecalls are especially dangerous -
Use SafeERC20 for ALL token interactions -
safeTransfer(),safeTransferFrom(),forceApprove() -
Verify target address has code before low-level calls -
require(target.code.length > 0) -
Run Slither with
unchecked-transfer,unchecked-lowlevel,unchecked-senddetectors -
Test with non-standard tokens - include USDT-like tokens in your test suite
-
Get professional audits before mainnet deployment
What to check in your own code
This is one of the cheapest bug classes to close. The Solidity compiler warns about unused return values from low-level calls, Slither flags them with four dedicated detectors, and the fix is usually one line: require(success).
For token operations, use SafeERC20 rather than assuming every token follows the same return convention.
Practice external-call review
External-call review becomes easier after testing failed ETH and token transfers against both vulnerable and fixed contracts.
The Smart Contract Hacking course includes:
-
320+ videos covering unchecked returns, reentrancy, flash loans, oracle manipulation, and related topics
-
40+ hands-on exercises exploiting and securing real contracts
-
Instruction from JohnnyTime, Trust, Pashov, and other security researchers
-
A 2,000+ member Discord for technical discussion and peer support
-
SSCH Certification for participants who complete the requirements
Review student outcomes and success stories to decide whether the training fits your goals.
Sources and editorial notes
Reviewed by JohnnyTime. Last updated .
Master Unchecked Return Value Attacks in a safe lab
Practice the exploit path, debug the vulnerable code, and learn the prevention workflow auditors use in real reviews.