VRF7
HomeGuides › Unchecked External Calls and the Risks of call, send and transfer

Unchecked External Calls and the Risks of call, send and transfer

Updated 2026-06-18 · VRF7 Security Guides

An unchecked external call is one of the most common and consequential bugs found in Solidity contracts. When a low-level call fails silently and the caller never inspects the return value, the contract continues executing as though the operation succeeded. Funds go missing, invariants break, and the bug can compound into larger exploits. Understanding why this happens—and what to do instead—is foundational knowledge for any Ethereum developer.

How Low-Level Calls Work in Solidity

Solidity exposes three primitives for sending ETH or invoking external code: call, send, and transfer. They look similar but behave very differently under the hood.

call

address.call{value: amount}(data) is a fully general low-level opcode wrapper. It forwards all remaining gas by default (or a specified amount), executes the target, and returns a tuple (bool success, bytes memory returnData). The critical point: if the call reverts, success is false and execution continues in the caller. Nothing is thrown automatically.

send

address.send(amount) is a convenience wrapper that caps the forwarded gas at 2300 and returns a bare bool. It never reverts on failure; it just returns false. Ignoring that return value means the contract silently loses track of whether the transfer happened.

transfer

address.transfer(amount) also caps gas at 2300, but it does revert on failure instead of returning false. At first glance this sounds safer, but the 2300-gas ceiling creates its own serious problems described below.

The Silent-Failure Problem with Unchecked External Calls

Consider a withdrawal function that uses send without checking the result:

// VULNERABLE: unchecked external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    msg.sender.send(amount); // return value ignored
}

If the recipient is a smart contract whose fallback function reverts—or if the call fails for any other reason—the user's balance is decremented but no ETH is sent. The funds are effectively locked. In more complex flows, the contract might credit an NFT, update a ledger, or emit an event after the send, all predicated on a transfer that never happened.

The same issue applies to call used without inspecting success:

// VULNERABLE: return value discarded
(bool success, ) = recipient.call{value: amount}("");
// no check on success

Slither flags this pattern as unchecked-send or unchecked-lowlevel, and Aderyn and Semgrep both have rules targeting ignored return values from low-level calls.

The 2300-Gas Stipend and Smart Wallet Breakage

The 2300-gas limit was introduced after the DAO hack as a way to prevent reentrancy by ensuring that a recipient contract cannot do much during a fallback call. The intention was sound, but the mechanism has aged poorly.

Modern smart wallets—Safe (formerly Gnosis Safe), Argent, ERC-4337 account abstraction wallets—execute non-trivial logic in their receive or fallback functions. Operations like emitting an event, updating a storage slot for accounting, or calling into a module can easily exceed 2300 gas. When a contract uses transfer or send to pay such a wallet, the call reverts or returns false, and the recipient simply cannot receive ETH from that contract.

This is a real-world integration failure, not a theoretical one. DeFi protocols have had to deploy patches specifically because their ETH distribution logic used transfer and became incompatible with smart contract recipients after EIP-1884 raised the cost of certain opcodes—pushing otherwise-lightweight fallbacks over the 2300-gas threshold.

Using transfer for reentrancy protection gives developers a false sense of security. The correct tool for reentrancy prevention is the checks-effects-interactions pattern combined with a reentrancy guard. For a detailed treatment of that topic, see the guide on Reentrancy Attacks in Solidity: How They Work and How to Prevent Them.

Gas Griefing via Forwarded Calls

Gas griefing is an attack pattern that becomes possible when a contract passes a user-controlled target and does not validate the gas budget before the call. Consider a relayer or meta-transaction pattern:

// VULNERABLE: griefable forwarded call
function relay(address target, bytes calldata data) external {
    (bool success, ) = target.call(data);
    require(success, "call failed");
    // reward the relayer
    token.transfer(msg.sender, REWARD);
}

A malicious target can consume nearly all forwarded gas, causing the require to revert after the call, denying the relayer their reward while still burning their gas. Alternatively, a target that returns false (without reverting) combined with an unchecked call allows the attacker to make the relay function believe the call succeeded when it did not.

Mitigations include specifying an explicit gas limit for the forwarded call (call{gas: limit}(...)), performing the reward accounting before the external call, and using the EIP-2771 trusted forwarder pattern when building relayer infrastructure.

Safe ETH Transfer Patterns

The community-accepted fix is to use call with an explicit value, check the return value, and apply the checks-effects-interactions pattern:

// SAFE: explicit call with return-value check
function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;          // effects first
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "ETH transfer failed"); // check the result
}

For projects that want a reusable, audited helper, OpenZeppelin's Address.sendValue wraps exactly this pattern and is widely trusted. Using it removes the temptation to reach for transfer or send:

import "@openzeppelin/contracts/utils/Address.sol";

using Address for address payable;

function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    payable(msg.sender).sendValue(amount); // reverts on failure, no gas cap
}

When You Cannot Revert on Failure

Batch payment contracts—airdrop distributors, reward claimers—sometimes legitimately need to continue processing even when one recipient fails. The correct approach is to record the failure and allow the recipient to claim later (pull-over-push), rather than silently skipping or carrying on as if the transfer succeeded:

// Pull-over-push pattern for batch distributions
mapping(address => uint256) public pendingWithdrawals;

function distributeRewards(address[] calldata recipients, uint256[] calldata amounts) external onlyOwner {
    for (uint256 i = 0; i < recipients.length; i++) {
        pendingWithdrawals[recipients[i]] += amounts[i]; // push accounting, pull withdrawal
    }
}

function claim() external nonReentrant {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "Nothing to claim");
    pendingWithdrawals[msg.sender] = 0;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Unchecked Calls to Non-ETH Functions

The return-value problem is not limited to ETH transfers. Any low-level call that invokes contract logic can fail silently. This is especially dangerous when calling ERC-20 tokens that do not revert on failure but return false (USDT on some chains, older BEP-20 tokens). Using OpenZeppelin's SafeERC20 wrapper, which checks the return value and handles missing return data via assembly, is the standard solution.

Access control logic can also be undermined when external authorization calls are not checked. If a contract calls an ACL registry to verify a role and ignores whether that call succeeded, an attacker who makes the registry revert might bypass the check entirely. This connects to broader access control weaknesses covered in the guide on Access Control Vulnerabilities in Smart Contracts.

Tooling Detection

Automated tools are effective at catching unchecked external calls. Slither's unchecked-lowlevel and unchecked-send detectors flag return values that are never read. Aderyn reports unchecked call return values as a medium-severity finding. Semgrep rules can match .send( calls where the result is not assigned. Solhint warns on low-level calls without result validation. Mythril uses symbolic execution to trace paths where a failed call leaves state inconsistent.

No single tool catches every variant—combined coverage is significantly better than any one detector alone. If you want to see all of these findings for your own contract, you can run an automated scan that runs these tools in parallel and explains each result in plain language.

Summary

Scan your contract before you ship

Run an automated, transparent security scan — seven industry tools in parallel, every finding labeled with its source tool. It is not a substitute for a full manual audit, but it is a fast first line of defense.

Scan a contract

Frequently asked questions

What is an unchecked external call in Solidity?

An unchecked external call occurs when a contract invokes another address using low-level primitives—call, send, or delegatecall—without inspecting the boolean return value that indicates success or failure. Because these primitives do not revert automatically on failure, the caller continues executing with incorrect assumptions about the state of the interaction.

Why is transfer considered unsafe for sending ETH?

The transfer function caps forwarded gas at 2300 units, which was originally intended to prevent reentrancy. However, smart contract wallets and ERC-4337 accounts require more than 2300 gas in their receive or fallback logic for ordinary bookkeeping. When transfer is used to pay such wallets, the call reverts, making the contract incompatible with a large class of recipients. The correct approach is to use call{value: amount}("") with an explicit return-value check and a reentrancy guard.

What is gas griefing and how does it relate to external calls?

Gas griefing is an attack in which a malicious contract deliberately consumes nearly all of the gas forwarded by a caller, causing the caller's subsequent operations—such as reward distribution or state updates—to fail out of gas. It is most dangerous in relayer and meta-transaction patterns where the target address is user-supplied. Mitigations include specifying an explicit gas limit on the forwarded call and applying the pull-over-push payment model so the caller's reward logic is not dependent on the external call's gas consumption.

Does the checks-effects-interactions pattern eliminate the need to check call return values?

No. Checks-effects-interactions is a structural pattern that prevents reentrancy by ensuring state changes occur before external calls. It does not remove the obligation to verify that the external call itself succeeded. Both practices are necessary: apply effects before the call, and then check the return value after the call to confirm the operation completed as expected.

Which automated tools detect unchecked external calls?

Slither detects them with its unchecked-lowlevel and unchecked-send detectors. Aderyn reports unchecked call return values as a medium-severity issue. Semgrep can match send calls where the result is not assigned. Solhint lints for low-level calls missing result validation. Mythril uses symbolic execution to identify state inconsistencies caused by ignored call failures. Using multiple tools in combination catches more variants than any single tool can.