High severity

Unchecked ERC-20 transfer (missing SafeERC20)

The contract calls transfer, transferFrom or approve directly on an ERC20 token instead of going through SafeERC20 (or an equivalent low-level wrapper). When the boolean return value is ignored, a token that signals failure by returning false (rather than reverting) lets the call 'succeed' silently, so balances are credited for tokens that never moved. When the return value is checked through the interface, tokens that return nothing at all (USDT, BNB, OMG and others) make the ABI decoder revert, so every call fails and funds can be locked. Severity is high when the return value is ignored and medium when it is checked.

Vulnerable pattern

A minimal contract with the bug
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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);
}

contract Escrow {
    IERC20 public immutable token;
    mapping(address => uint256) public deposits;

    constructor(IERC20 _token) {
        token = _token;
    }

    function deposit(uint256 amount) external {
        // return value ignored: a token that returns false is credited anyway
        token.transferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] += amount;
    }

    function withdraw(uint256 amount) external {
        deposits[msg.sender] -= amount;
        // checked, but reverts for tokens that return nothing (USDT)
        require(token.transfer(msg.sender, amount), "transfer failed");
    }
}

The fix

Use OpenZeppelin SafeERC20 (safeTransfer, safeTransferFrom, forceApprove) or Solady SafeTransferLib for every interaction with an arbitrary ERC20 token.

The same contract, corrected
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

library SafeERC20 {
    function safeTransfer(IERC20 token, address to, uint256 amount) internal {
        _call(token, abi.encodeWithSelector(token.transfer.selector, to, amount));
    }

    function safeTransferFrom(IERC20 token, address from, address to, uint256 amount) internal {
        _call(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, amount));
    }

    function _call(IERC20 token, bytes memory data) private {
        (bool ok, bytes memory ret) = address(token).call(data);
        require(ok && (ret.length == 0 || abi.decode(ret, (bool))), "SafeERC20: failed");
    }
}

contract Escrow {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;
    mapping(address => uint256) public deposits;

    constructor(IERC20 _token) {
        token = _token;
    }

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

    function withdraw(uint256 amount) external {
        deposits[msg.sender] -= amount;
        token.safeTransfer(msg.sender, amount);
    }
}

Scan your contract for this

pactlint flags erc20-unsafe-transfer and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base with x402, no signup; the first few scans each day are free.

How to scan →

This detector is open source (MIT): see erc20-unsafe-transfer in tanod-labs/slither-detectors, or run it in CI with the pactlint GitHub Action. Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.