Unchecked ERC20 Operations Remediation
How to safely handle ERC20 token transfers by using SafeERC20.safeTransfer, accounting for fee-on-transfer tokens, and avoiding the token approve-transferFrom race condition.
Unchecked ERC20 Operations Remediation
Overview
The ERC-20 standard specifies that transfer and transferFrom should return a bool indicating success, but many widely-used token implementations do not follow this convention. USDT (Tether) — one of the highest-volume tokens in DeFi — does not return a value from transfer or transferFrom. Calling require(token.transfer(...)) against USDT will cause an ABI decoding failure; calling token.transfer(...) without checking the return value will silently ignore a failed transfer. A third class of tokens, sometimes called “evil ERC20s,” actively returns false on failure rather than reverting — meaning a vault that credits a deposit without checking the return value will credit the user for tokens they never actually sent.
These three behaviours — no return value, returning false, and reverting — cannot be handled correctly with a plain token.transfer() call or a naive require(token.transfer()) call. The correct solution is to use a wrapper that handles all three cases.
Related Detector: Unchecked Call Detector
Recommended Fix
Before (Vulnerable)
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract VulnerableVault {
IERC20 public token;
mapping(address => uint256) public balances;
function deposit(uint256 amount) external {
// VULNERABLE 1: If token.transferFrom returns false (not reverts),
// the vault credits the user without receiving any tokens.
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
// VULNERABLE 2: If token.transfer returns false (USDT-like),
// the user's internal balance is reduced but no tokens are sent.
token.transfer(msg.sender, amount);
}
}
After (Fixed)
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SafeVault {
using SafeERC20 for IERC20;
IERC20 public token;
mapping(address => uint256) public balances;
function deposit(uint256 amount) external {
// safeTransferFrom handles tokens with no return value (USDT),
// tokens that return false on failure, and standard tokens that revert.
// In every failure case it reverts — no silent failures.
token.safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
token.safeTransfer(msg.sender, amount);
}
}
Alternative Mitigations
Balance-delta accounting for fee-on-transfer tokens — some tokens (e.g., STA, DEFLATIONARY) deduct a fee from every transfer, meaning the amount received is less than the amount requested. SafeERC20 prevents silent failure but does not automatically measure the actual received amount. Credit only what the vault actually received:
contract FeeOnTransferSafeVault {
using SafeERC20 for IERC20;
IERC20 public token;
mapping(address => uint256) public balances;
function deposit(uint256 requestedAmount) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), requestedAmount);
uint256 actualReceived = token.balanceOf(address(this)) - balanceBefore;
// Credit the actual amount received, not the amount requested.
// For standard tokens these are equal; for fee-on-transfer tokens they differ.
balances[msg.sender] += actualReceived;
emit Deposited(msg.sender, actualReceived);
}
}
Approve-then-transferFrom race condition mitigation — ERC-20’s approve function has a known race condition: if an allowance is changed from a non-zero value to another non-zero value, an attacker can front-run the second approval to spend the old allowance before the new one takes effect, then spend the new allowance too. Always reset to zero before setting a new non-zero allowance:
contract SafeApproval {
using SafeERC20 for IERC20;
function safelyUpdateAllowance(
IERC20 token,
address spender,
uint256 newAmount
) internal {
// 1. Reset allowance to zero first
token.safeApprove(spender, 0);
// 2. Set the new allowance
token.safeApprove(spender, newAmount);
// Alternatively, use forceApprove (OpenZeppelin 5.x) which handles
// tokens that revert on approve(X, 0):
// token.forceApprove(spender, newAmount);
}
}
Explicit return-value check without OpenZeppelin — if SafeERC20 cannot be used, implement the low-level call pattern manually:
function safeTransfer(address token, address to, uint256 amount) internal {
// Low-level call captures whether the call succeeded (ok) and the returned bytes
(bool ok, bytes memory data) = token.call(
abi.encodeWithSelector(IERC20.transfer.selector, to, amount)
);
// ok must be true (call did not revert)
// data must be empty (no return value, like USDT) or decode to true
require(
ok && (data.length == 0 || abi.decode(data, (bool))),
"Transfer failed"
);
}
Common Mistakes
Using token.transfer(recipient, amount) in a withdrawal path without checking the return value — if the token returns false without reverting, the user’s internal balance is debited but no tokens are sent. The next call to withdraw will revert with “Insufficient balance” — but the first caller receives nothing.
Crediting amount instead of the balance delta for fee-on-transfer tokens — protocols that integrate with fee-on-transfer tokens and credit the requested amount rather than the received amount accumulate a solvency deficit over time. Every deposit credits more than was received; eventually the vault cannot satisfy withdrawals.
Using safeApprove to set a non-zero allowance over an existing non-zero allowance — safeApprove in OpenZeppelin 4.x reverts if the current allowance is non-zero (by design, to prevent the race condition). Always reset to zero first, or use forceApprove in OpenZeppelin 5.x.
Assuming all ERC-20 tokens have 18 decimals — USDC and USDT use 6 decimals. Mixing amounts denominated in different decimal precisions without normalisation produces price calculations that are orders of magnitude wrong. Always query token.decimals() and normalise appropriately.