ERC20 Violations Remediation
How to eliminate ERC20 integration vulnerabilities by using SafeERC20 for all token operations, measuring actual received amounts for fee-on-transfer tokens, and handling non-standard token behaviors such as missing return values and rebase mechanics.
ERC20 Violations Remediation
Overview
ERC20 integration vulnerabilities arise when a protocol assumes all ERC20 tokens conform strictly to the EIP-20 standard. In practice, a significant portion of production tokens deviate from the standard in ways that cause silent failures or accounting exploits.
USDT (Tether), BNB, and OMG do not return a boolean from transfer and transferFrom. Protocols that wrap these calls in require(token.transfer(...)) revert on every interaction because Solidity interprets empty return data as false. Conversely, if the require is omitted, failures pass silently. Fee-on-transfer tokens (SAFEMOON, many reflection tokens) deduct a percentage during the transfer, so the amount received is less than the amount specified. Protocols that record the specified amount rather than the received amount accumulate a shortfall per deposit. Rebase tokens (AMPL, stETH) have balances that change without explicit transfers, breaking share-based accounting.
The fix is to use OpenZeppelin’s SafeERC20 for all token calls, and to measure balanceOf before and after any transfer to determine the actual amount received.
Related Detector: Access Control 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 {
mapping(address => uint256) public deposits;
// BUG 1: Reverts with USDT because USDT returns no bool
// BUG 2: If require is omitted, silent failure credits tokens never received
function deposit(IERC20 token, uint256 amount) external {
require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");
// BUG 3: Records specified amount, not actual received amount
// For fee-on-transfer tokens, contract receives less than amount
deposits[msg.sender] += amount;
}
function withdraw(IERC20 token, uint256 amount) external {
require(deposits[msg.sender] >= amount, "Insufficient deposit");
deposits[msg.sender] -= amount;
require(token.transfer(msg.sender, amount), "Transfer failed");
// For fee-on-transfer tokens, user receives less but full amount was deducted
}
}
After (Fixed)
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SafeVault {
using SafeERC20 for IERC20;
mapping(address => mapping(IERC20 => uint256)) public deposits;
function deposit(IERC20 token, uint256 amount) external {
require(amount > 0, "Amount must be positive");
// Measure balance BEFORE transfer
uint256 balanceBefore = token.balanceOf(address(this));
// SafeERC20 handles:
// - USDT-style missing return values (wraps in a low-level call)
// - Reverts on failure regardless of return data format
token.safeTransferFrom(msg.sender, address(this), amount);
// Measure balance AFTER transfer — actual received amount
uint256 actualReceived = token.balanceOf(address(this)) - balanceBefore;
require(actualReceived > 0, "No tokens received");
// Credit actual received amount — handles fee-on-transfer tokens correctly
deposits[msg.sender][token] += actualReceived;
emit Deposited(msg.sender, address(token), actualReceived);
}
function withdraw(IERC20 token, uint256 amount) external {
require(amount > 0, "Amount must be positive");
require(deposits[msg.sender][token] >= amount, "Insufficient deposit");
deposits[msg.sender][token] -= amount;
// Measure before/after for withdrawal too
uint256 balanceBefore = token.balanceOf(msg.sender);
token.safeTransfer(msg.sender, amount);
uint256 actualSent = token.balanceOf(msg.sender) - balanceBefore;
emit Withdrawn(msg.sender, address(token), actualSent);
}
}
Alternative Mitigations
Token allowlist — for protocols that cannot safely handle arbitrary ERC20 tokens, maintain an allowlist of tokens that have been audited and are known to conform to the standard:
mapping(address => bool) public allowedTokens;
modifier onlyAllowedToken(address token) {
require(allowedTokens[token], "Token not supported");
_;
}
function deposit(IERC20 token, uint256 amount) external onlyAllowedToken(address(token)) {
// Trusted tokens reduce but do not eliminate the need for SafeERC20
token.safeTransferFrom(msg.sender, address(this), amount);
deposits[msg.sender][token] += amount; // Safe for non-fee-on-transfer tokens
}
Share-based accounting for rebase tokens — track user positions as shares of the total pool rather than as absolute token amounts. This ensures positions automatically reflect balance changes from rebasing:
contract ShareVault {
IERC20 public immutable token; // stETH or other rebase token
uint256 public totalShares;
mapping(address => uint256) public shares;
function deposit(uint256 amount) external {
uint256 totalBalance = token.balanceOf(address(this));
uint256 sharesToMint;
if (totalShares == 0 || totalBalance == 0) {
sharesToMint = amount;
} else {
// Shares proportional to current pool: new shares / total shares = amount / total balance
sharesToMint = amount * totalShares / totalBalance;
}
token.safeTransferFrom(msg.sender, address(this), amount);
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
}
function withdraw(uint256 sharesToBurn) external {
require(shares[msg.sender] >= sharesToBurn, "Insufficient shares");
uint256 totalBalance = token.balanceOf(address(this));
// Amount reflects current rebase-adjusted balance
uint256 amount = sharesToBurn * totalBalance / totalShares;
shares[msg.sender] -= sharesToBurn;
totalShares -= sharesToBurn;
token.safeTransfer(msg.sender, amount);
}
}
Explicit nonReentrant protection on all token interactions — combine SafeERC20 with OpenZeppelin’s ReentrancyGuard to prevent reentrancy through ERC-777 token hooks or callback-enabled token contracts:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract ReentrantSafeVault is ReentrancyGuard {
function deposit(IERC20 token, uint256 amount) external nonReentrant {
// ...
}
function withdraw(IERC20 token, uint256 amount) external nonReentrant {
// ...
}
}
Integration tests with non-standard tokens — include integration tests that use actual non-standard token implementations in your test suite:
// Test with a USDT-style token that returns no bool
// Test with a 2% fee-on-transfer token
// Test with a rebase token that changes balances between deposit and withdraw
// Verify the vault remains solvent across all token types
Common Mistakes
Using raw IERC20.transfer() instead of SafeERC20.safeTransfer() — OpenZeppelin’s SafeERC20 wraps the low-level call to handle missing return values. Using the interface directly fails silently for USDT and reverts for some non-standard tokens. Always use SafeERC20.
Recording the specified amount instead of the received amount — this is the single most common ERC20 integration bug. Always measure balanceOf before and after any transferFrom and credit the difference, not the argument passed to the function.
Not testing with USDT on mainnet — USDT’s non-standard interface has caused more DeFi protocol failures than almost any other individual token. Test all token-handling code against a mainnet-fork USDT before deployment.
Treating all stablecoins as equivalent — USDC has 6 decimals, DAI has 18, USDT has 6. A price calculation that assumes 18 decimals will produce wildly wrong results with USDC or USDT. Always query decimals() and normalize accordingly.
Ignoring pausable tokens — USDC and many other stablecoins can be paused by their issuer. A protocol that relies on a single token and has no pause mechanism of its own can be permanently blocked by the token issuer’s pause.