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 success
  • bytes 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

PrimitiveTypical useRisk if unchecked
callGeneric external invocationSilent failure, unexpected return data
delegatecallProxy execution, library-style code reuseStorage corruption if execution fails or is misused
staticcallRead-only external queriesIncorrect assumptions about fetched data
sendEther transfer with limited gasPayment 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

  1. Check success first.
  2. Only decode return data when the call succeeded.
  3. 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

ScenarioUnsafe patternSafer pattern
Ether payoutIgnore call resultrequire(success, "transfer failed")
External queryDecode returndata without checking successCheck success, then decode
Proxy executionIgnore delegatecall resultRevert on failure and bubble reason
Legacy transferUse send and continuePrefer 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 true or false,
  • 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, or staticcall
  • Using send as 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 than send.
  • 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.

Learn more with useful resources