
Preventing Unchecked Low-Level Call Failures in Solidity
Why low-level call failures are dangerous
High-level Solidity calls are type-checked and revert automatically when the callee reverts. Low-level calls are different: they return a success flag and optional return data. If you do not inspect that flag, your contract may:
- mark a payment as completed when the transfer failed,
- update internal state before an external action actually happened,
- silently ignore a failed token transfer,
- continue after a failed
delegatecall, leaving storage in an inconsistent state.
This issue is especially relevant in integrations with older contracts, proxy patterns, payment forwarding, and token interactions where developers intentionally use low-level calls for flexibility.
How low-level calls work
A low-level call returns a tuple:
bool successbytes memory returndata
For example:
(bool success, bytes memory data) = target.call(payload);The call itself does not revert unless you explicitly require it to. If success is false, the external operation failed. If the call succeeded, data may contain ABI-encoded return values.
Common low-level primitives
| Primitive | Typical use | Risk if unchecked |
|---|---|---|
call | Generic external invocation | Silent failure, unexpected return data |
delegatecall | Proxy execution, library-style code reuse | Storage corruption if execution fails or is misused |
staticcall | Read-only external queries | Incorrect assumptions about fetched data |
send | Ether transfer with limited gas | Payment failure if recipient needs more gas |
A vulnerable example
Consider a contract that tries to forward Ether to a beneficiary:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract UnsafePayout {
mapping(address => uint256) public credits;
function deposit() external payable {
credits[msg.sender] += msg.value;
}
function withdraw(address payable beneficiary, uint256 amount) external {
require(credits[msg.sender] >= amount, "insufficient balance");
credits[msg.sender] -= amount;
// Vulnerable: return value is ignored
beneficiary.call{value: amount}("");
// Contract assumes payment succeeded
}
}At first glance, this looks reasonable. The contract deducts the user’s balance and sends Ether to the beneficiary. But if the call fails—because the recipient reverts, runs out of gas, or is a contract that rejects Ether—the function still completes. The user’s balance is reduced, but the payment never happened.
That is a serious accounting bug.
Safe handling pattern: check success and revert on failure
The simplest defense is to require success explicitly:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SafePayout {
mapping(address => uint256) public credits;
function deposit() external payable {
credits[msg.sender] += msg.value;
}
function withdraw(address payable beneficiary, uint256 amount) external {
require(credits[msg.sender] >= amount, "insufficient balance");
credits[msg.sender] -= amount;
(bool success, ) = beneficiary.call{value: amount}("");
require(success, "ether transfer failed");
}
}This version is much safer because the transaction reverts if the transfer fails. Since the revert unwinds state changes, the user’s balance is restored automatically.
Handling return data correctly
Some low-level calls are expected to return data. For example, a contract may call a token contract or a custom service contract and decode the result.
(bool success, bytes memory returndata) = target.staticcall(payload);
require(success, "query failed");
uint256 value = abi.decode(returndata, (uint256));However, decoding without validating the call outcome is unsafe. If success is false, returndata may contain a revert reason or be empty. Decoding it as a normal response can cause misleading behavior or a secondary revert that hides the original failure.
Best practice
- Check
successfirst. - Only decode return data when the call succeeded.
- Validate that the returned data has the expected shape and type.
When send is especially risky
The send function forwards only 2300 gas and returns a boolean instead of reverting. It was historically used for Ether transfers, but it is fragile because many recipient contracts need more gas than that to accept funds safely.
bool ok = payable(recipient).send(amount);
require(ok, "send failed");Even with the require, send is often a poor choice for modern Solidity code. Prefer call{value: amount}("") with explicit success checking. It is more flexible and less likely to fail due to gas constraints.
delegatecall deserves extra caution
delegatecall executes code from another contract in the caller’s storage context. If the call fails and you ignore the result, your contract may continue as if the delegated logic completed successfully, even though state was not updated as intended.
(bool success, ) = implementation.delegatecall(data);
require(success, "delegatecall failed");This is the minimum safe pattern. In practice, you should also ensure:
- the implementation address is trusted,
- the calldata is validated,
- storage layout is compatible,
- failures are surfaced clearly to callers.
Unchecked delegatecall failures are particularly dangerous in upgradeable systems because they can break initialization, migration, or administrative operations.
A practical comparison of safe and unsafe patterns
| Scenario | Unsafe pattern | Safer pattern |
|---|---|---|
| Ether payout | Ignore call result | require(success, "transfer failed") |
| External query | Decode returndata without checking success | Check success, then decode |
| Proxy execution | Ignore delegatecall result | Revert on failure and bubble reason |
| Legacy transfer | Use send and continue | Prefer call and handle failure explicitly |
Bubbling revert reasons
When a low-level call fails, you often want to preserve the original revert reason. This is useful for debugging and for giving upstream callers useful feedback.
A common helper pattern is:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library CallUtils {
function verifyCallResult(
bool success,
bytes memory returndata,
string memory errorMessage
) internal pure returns (bytes memory) {
if (success) {
return returndata;
}
if (returndata.length > 0) {
assembly {
let returndata_size := mload(returndata)
revert(add(32, returndata), returndata_size)
}
}
revert(errorMessage);
}
}This helper either returns the successful return data or reverts with the original reason if one exists. If no revert reason is available, it falls back to a custom message.
Why this matters
Without bubbling, you may lose the actual cause of failure. That makes integration bugs harder to diagnose and can hide important operational problems such as:
- token contracts rejecting transfers,
- recipient contracts reverting on receipt,
- misconfigured proxy targets,
- failed initialization steps.
Real-world example: token interaction wrapper
Suppose you integrate with a non-standard token contract using low-level calls. You should not assume the token always returns a boolean, and you should not ignore failure.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract TokenAdapter {
function safeTransfer(address token, address to, uint256 amount) external {
bytes memory payload = abi.encodeWithSignature(
"transfer(address,uint256)",
to,
amount
);
(bool success, bytes memory returndata) = token.call(payload);
require(success, "token call failed");
if (returndata.length > 0) {
require(abi.decode(returndata, (bool)), "token rejected transfer");
}
}
}This pattern handles both styles of token behavior:
- tokens that return
trueorfalse, - tokens that revert on failure and return no data.
If you omit the checks, your contract may assume a transfer occurred when it did not.
Design guidelines for safer low-level calls
1. Prefer high-level calls when possible
If you know the target contract interface, use a typed interface instead of raw call. High-level calls are easier to read and safer by default.
IERC20(token).transfer(to, amount);This is preferable to manual ABI encoding unless you need dynamic dispatch or compatibility with unknown interfaces.
2. Treat every low-level call as fallible
Never assume success. Always inspect the boolean return value and decide how your contract should respond.
3. Revert on critical failure
If the external operation is required for correctness, revert immediately. Do not continue with partially updated state.
4. Keep state changes before external effects
Even when you check success, structure your code so internal accounting is updated before the external call where appropriate. This reduces the chance of inconsistent state if the call fails.
5. Validate return data length and type
A successful call can still return unexpected data. Decode only when the return format is known and appropriate.
6. Use clear error messages
A precise revert string or custom error makes debugging much easier:
error ExternalCallFailed(address target);
if (!success) revert ExternalCallFailed(target);7. Test failure paths explicitly
Unit tests should cover:
- recipient reverts,
- empty return data,
- malformed return data,
- gas-sensitive recipients,
- proxy target failures.
Many bugs only appear when the external contract misbehaves.
Common mistakes to avoid
- Ignoring the result of
call,delegatecall, orstaticcall - Using
sendas if it were a guaranteed transfer primitive - Decoding return data before checking success
- Assuming all token contracts follow the same return conventions
- Continuing execution after a failed external operation
- Swallowing revert reasons in proxy or adapter code
A secure checklist
Before shipping code that uses low-level calls, verify the following:
- Every low-level call checks
success. - Critical failures revert the transaction.
- Return data is decoded only after success.
- Ether transfers use
call{value: ...}("")rather thansend. - Proxy and adapter code bubbles revert reasons when useful.
- Tests cover both success and failure cases.
Conclusion
Unchecked low-level call failures are easy to overlook because the code often looks correct at a glance. The contract compiles, the call is made, and the rest of the function continues. But if the external operation failed and you ignored the result, your contract may have already entered an invalid state.
The safest approach is straightforward: treat low-level calls as fallible, check their return values, and revert when the operation is essential. When you need flexibility, use low-level calls deliberately and wrap them with strong validation, clear errors, and thorough tests.
