Why return value assumptions are dangerous

The ERC-20 specification says functions such as transfer and transferFrom should return bool, but many deployed tokens deviate from that pattern. Older tokens may not return anything. Others may return non-standard values or revert under certain conditions.

If your contract assumes that a token call succeeded just because it did not revert, you may end up crediting users, releasing collateral, or updating internal state without actually receiving tokens.

Common failure patterns

PatternRisk
Ignoring the return value of transfer or transferFromSilent failure if the token returns false
Decoding a missing return value as boolRevert or undefined behavior in low-level integrations
Updating state before verifying token movementAccounting drift or asset loss
Using raw IERC20(token).transfer(...) with non-standard tokensIncompatibility with tokens that do not return a boolean

This issue is especially relevant in vaults, payment routers, staking contracts, bridges, and any protocol that accepts arbitrary tokens.


Understanding ERC-20 behavior in Solidity

A standard ERC-20 interface in Solidity looks like this:

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

The problem is that Solidity’s interface syntax expresses your expectation, not the token’s actual runtime behavior. If a token contract does not conform exactly, a direct interface call may fail even if the token is widely used and economically important.

This is why many production systems use a compatibility wrapper that tolerates both of these cases:

  1. The token returns true on success.
  2. The token returns no data but does not revert.

A vulnerable example

Consider a deposit function that credits shares after calling transferFrom:

function deposit(address token, uint256 amount) external {
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    balances[msg.sender] += amount;
}

At first glance, this looks fine. But if transferFrom returns false instead of reverting, the function still continues and credits the user anyway. The contract now believes it received tokens when it did not.

Even worse, if the token is non-standard and returns no data, the call may revert unexpectedly, breaking deposits for legitimate users.


Safe patterns for token interactions

The safest approach is to use a well-tested wrapper that handles return-data edge cases consistently. In production Solidity, this is commonly done with OpenZeppelin’s SafeERC20.

Using SafeERC20

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TokenVault {
    using SafeERC20 for IERC20;

    mapping(address => uint256) public balances;

    function deposit(IERC20 token, uint256 amount) external {
        token.safeTransferFrom(msg.sender, address(this), amount);
        balances[msg.sender] += amount;
    }

    function withdraw(IERC20 token, uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");
        balances[msg.sender] -= amount;
        token.safeTransfer(msg.sender, amount);
    }
}

SafeERC20 performs a low-level call and checks whether the operation succeeded. It supports tokens that:

  • return true
  • return no data
  • revert on failure

This wrapper is the default choice for most applications.


Why low-level compatibility matters

A direct interface call assumes ABI compliance. A low-level call can inspect the returned bytes and decide whether the token behaved acceptably.

The key idea is not to trust the token’s return type blindly. Instead, verify one of the following:

  • the call reverted? treat as failure
  • the call returned true? treat as success
  • the call returned no data? accept only if the token is known to be compatible with that pattern

This is exactly the kind of defensive coding that prevents integration bugs across heterogeneous token ecosystems.


When to prefer explicit checks

There are cases where you may want to implement your own wrapper rather than relying on a library. For example:

  • you need custom error messages
  • you want to restrict supported tokens
  • you are building a minimal contract and want to avoid extra dependencies
  • you need to support unusual token behavior in a controlled environment

A minimal compatibility helper might look like this:

function _safeTransfer(address token, address to, uint256 amount) internal {
    (bool success, bytes memory data) =
        token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));

    require(success, "TOKEN_CALL_FAILED");

    if (data.length > 0) {
        require(abi.decode(data, (bool)), "TOKEN_TRANSFER_FAILED");
    }
}

This pattern is useful, but it should be used carefully. If you are not experienced with ABI decoding and token edge cases, a battle-tested library is usually safer.


Design your accounting around actual asset movement

A common mistake is to update internal balances based on the requested amount rather than the amount actually received. This is dangerous even when return values are handled correctly, because some tokens may have transfer fees, rebasing behavior, or other mechanics that affect the final received amount.

For return-value safety specifically, the rule is simple:

  1. call the token transfer function
  2. verify success
  3. only then update internal state

Good ordering

function pay(address token, uint256 amount) external {
    IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
    credits[msg.sender] += amount;
}

Bad ordering

function pay(address token, uint256 amount) external {
    credits[msg.sender] += amount;
    IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
}

If the transfer fails, the second version leaves the contract in an inconsistent state.


Handling approvals safely

The same return-value issue applies to approve. A contract that assumes approval succeeded may later attempt a transferFrom and fail unexpectedly.

However, approval logic has an additional nuance: some tokens require setting allowance to zero before changing it to a new value. For that reason, use a wrapper and follow token-specific guidance where needed.

function approveSpender(IERC20 token, address spender, uint256 amount) external {
    token.safeApprove(spender, amount);
}

In modern code, many protocols avoid holding long-lived approvals altogether. Instead, they use:

  • exact approvals for a single operation
  • permit-based flows where supported
  • router contracts that pull tokens only when needed

This reduces the blast radius if a spender is compromised.


Practical checklist for production contracts

Use the following checklist when your Solidity contract interacts with ERC-20 tokens:

PracticeWhy it helps
Use SafeERC20 for all token transfersHandles standard and non-standard return behavior
Treat token calls as external untrusted callsPrevents assumptions about success
Update internal state only after successful transferAvoids accounting inconsistencies
Prefer exact-amount approvalsLimits exposure from stale allowances
Test with non-standard tokensReveals compatibility issues early
Document supported token behaviorPrevents integration surprises

A good testing strategy includes at least one token mock that:

  • returns true
  • returns no data
  • returns false
  • reverts on failure

That combination catches most integration mistakes before deployment.


Example: a robust payment contract

The following example shows a simple merchant contract that accepts ERC-20 payments safely.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Merchant {
    using SafeERC20 for IERC20;

    address public immutable treasury;

    event Paid(address indexed payer, address indexed token, uint256 amount);

    constructor(address _treasury) {
        require(_treasury != address(0), "zero treasury");
        treasury = _treasury;
    }

    function pay(IERC20 token, uint256 amount) external {
        require(amount > 0, "zero amount");

        token.safeTransferFrom(msg.sender, treasury, amount);

        emit Paid(msg.sender, address(token), amount);
    }
}

This contract is intentionally simple, but it demonstrates the correct pattern:

  • validate inputs
  • perform the token transfer safely
  • emit the event only after success

If the token call fails, the transaction reverts and no payment is recorded.


Testing strategies that catch return-value bugs

Unit tests should not only verify happy paths. They should also simulate broken or non-standard token behavior.

Recommended test cases

  1. Standard token success
  • transferFrom returns true
  • deposit succeeds
  1. Token returns no data
  • deposit still succeeds if wrapper supports it
  1. Token returns false
  • deposit reverts
  1. Token reverts
  • deposit reverts
  1. Token transfer succeeds but amount is wrong
  • internal accounting should not assume more than was actually moved

A mock token contract can be used to emulate these behaviors. This is particularly useful in integration tests for vaults and payment systems that must support multiple assets.


Best practices summary

The main lesson is straightforward: do not assume ERC-20 tokens behave identically, and do not assume a call succeeded just because it did not obviously fail.

Follow these rules:

  • use SafeERC20 for token interactions
  • verify transfer success before updating state
  • avoid raw interface calls unless the token is tightly controlled
  • test against non-standard tokens
  • document which token behaviors your contract supports

These practices are low-cost and significantly reduce the risk of silent asset-accounting bugs.

Learn more with useful resources