Token Standards & Weird ERC20s
The ERC20 standard was designed to be simple and flexible. Those properties made it wildly successful — and wildly dangerous. Hundreds of tokens deviate from the naive implementation that most protocol developers assume when they write token-handling code. These "weird ERC20s" have caused dozens of protocol exploits. As an auditor, your job is to ask: "What happens if this token has a transfer fee? What if its balance changes without a transfer? What if transfer() returns false instead of reverting?" This lesson catalogs every major ERC20 deviation and its protocol-breaking implications.
Most protocol developers write code assuming: transfer(amount) always transfers exactly amount, balances never change without a transfer, transfer() reverts on failure, decimals is 18, and there is no callback on transfer. Every single one of these assumptions can be violated by real, widely-used tokens. The d-mmit/weird-erc20 repository documents 30+ known token behaviors that break these assumptions.
Category 1: Fee-on-Transfer Tokens
Fee-on-transfer tokens deduct a percentage from every transfer. USDT on BSC charges 0.3%. The STA token charges 1%. PAXG charges 0.02%. Any protocol that records the amount parameter instead of the actual received balance will have its accounting break immediately.
// ❌ BROKEN: Naive token accounting
mapping(address => mapping(address => uint256)) public userDeposits;
function deposit(address token, uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(this), amount);
userDeposits[msg.sender][token] += amount; // Records 1000, got 997!
}
function withdraw(address token, uint256 amount) external {
userDeposits[msg.sender][token] -= amount;
IERC20(token).transfer(msg.sender, amount); // Tries to transfer 1000
// Contract only has 997 → fails, OR steals from other users' deposits
}
// ✅ CORRECT: Always measure balance delta
function deposit(address token, uint256 amount) external {
uint256 before = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 after = IERC20(token).balanceOf(address(this));
userDeposits[msg.sender][token] += (after - before); // Records 997 ✅
}
Category 2: Rebasing Tokens
Rebasing tokens like stETH (Lido staked ETH) and AMPL (Ampleforth) change all balances simultaneously without emitting Transfer events. stETH balances increase by ~4% per year as staking rewards accrue. AMPL rebases daily to target a price. Any protocol that caches a user's balance will have stale data.
// stETH balance changes every day (rebase oracle update)
// A protocol that caches stETH balance at deposit time will have stale data
// ❌ VULNERABLE: Snapshot deposit amount
mapping(address => uint256) public depositedStETH;
function deposit(uint256 amount) external {
stETH.transferFrom(msg.sender, address(this), amount);
depositedStETH[msg.sender] = amount; // Cached: 100 stETH
}
// One year later: stETH has rebased 4% — contract holds 104 stETH
// But depositedStETH[user] still says 100
// The 4 extra stETH are stuck — or worse, claimable by another user
// ✅ CORRECT: Track shares (the non-rebasing unit)
// stETH has a separate concept: "shares" that don't rebase
// 100 stETH = 95.something shares (at 5% premium)
// Next year: those same shares = 104 stETH
mapping(address => uint256) public depositedShares;
function deposit(uint256 amount) external {
uint256 sharesBefore = stETH.sharesOf(address(this));
stETH.transferFrom(msg.sender, address(this), amount);
uint256 sharesAfter = stETH.sharesOf(address(this));
depositedShares[msg.sender] += sharesAfter - sharesBefore; // ✅
}
Category 3: Tokens with Blacklists
// USDC and USDT both have blacklist functionality
// Centre (USDC issuer) can blacklist any address
// Transfers to/from blacklisted addresses revert
// Attack: Protocol has a reward distribution function
function distributeRewards(address[] memory recipients) external {
for (uint256 i = 0; i < recipients.length; i++) {
USDC.transfer(recipients[i], rewardPerUser); // ← REVERTS if blacklisted!
// Entire loop fails — no one gets their rewards
}
}
// ❌ Any user who becomes blacklisted bricks the payout loop
// ✅ CORRECT: Pull-over-push pattern
mapping(address => uint256) public pendingRewards;
function accrueRewards(address[] memory recipients) external {
for (uint256 i = 0; i < recipients.length; i++) {
pendingRewards[recipients[i]] += rewardPerUser; // Just update mapping
}
}
function claimReward() external {
uint256 amount = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0;
USDC.transfer(msg.sender, amount);
// If this user is blacklisted, only THEIR claim fails — others unaffected
}
Category 4: Tokens That Return False on Failure
// BNB token (ERC20 non-compliant) returns false on failed transfer
// ZRX token returns false instead of reverting
// ERC20 spec allows this — transfer() MAY return bool
// ❌ VULNERABLE: Ignoring return value
function badPayout(address recipient, uint256 amount) external {
IERC20(token).transfer(recipient, amount); // Returns false silently!
// No revert — execution continues as if transfer succeeded
// Balance state updated, funds not actually sent
}
// ✅ CORRECT: Use OpenZeppelin SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function safePayout(address recipient, uint256 amount) external {
IERC20(token).safeTransfer(recipient, amount);
// safeTransfer: checks return value, reverts if false, handles missing return
}
// What SafeERC20.safeTransfer() does internally:
// 1. Makes a low-level call to token.transfer()
// 2. Checks: if no return data was provided, treat as success (for old tokens)
// 3. If return data provided, require it decodes to true
// 4. Revert if transfer failed for any reason
Category 5: ERC777 Reentrancy via Callbacks
// ERC777 is ERC20 compatible but adds hooks:
// tokensToSend() — called on sender before transfer
// tokensReceived() — called on recipient after transfer
// ❌ VULNERABLE: ERC777 used in a lending protocol
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
// If token is ERC777, this calls tokensReceived() on recipient
IERC777(token).send(msg.sender, amount, "");
balances[msg.sender] -= amount; // Updated AFTER → classic reentrancy!
}
// Attacker's tokensReceived() hook:
function tokensReceived(...) external {
if (victim.balanceOf(address(this)) > 0) {
victim.withdraw(amount); // Reenter before balance is updated
}
}
// Fix: nonReentrant modifier + CEI pattern
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // Update BEFORE transfer
IERC777(token).send(msg.sender, amount, ""); // Transfer after
}
Category 6: Decimal Edge Cases
// Standard assumption: all ERC20 tokens have 18 decimals
// Reality:
// USDC/USDT: 6 decimals
// WBTC: 8 decimals
// GUSD (Gemini): 2 decimals
// Yam Finance: attempted 24 decimals (rebase bug)
// ❌ BROKEN: Hardcoded 1e18 for all tokens
function calculateValue_BROKEN(address token, uint256 amount) returns (uint256) {
uint256 priceInUSD = oracle.getPrice(token); // 18 decimal price
return amount * priceInUSD / 1e18; // WRONG for USDC (6 decimals)!
// 1 USDC = 1e6 units, not 1e18
// This returns 1e12 times too small a value
}
// ✅ CORRECT: Normalize decimals
function calculateValue_SAFE(address token, uint256 amount) returns (uint256) {
uint8 decimals = IERC20Metadata(token).decimals();
uint256 priceInUSD = oracle.getPrice(token); // 18 decimal price (USD)
// Normalize amount to 18 decimals before calculation
uint256 normalizedAmount = amount * 10 ** (18 - decimals);
return normalizedAmount * priceInUSD / 1e18;
}
ERC721 Security: safeTransferFrom and Callbacks
// safeTransferFrom() calls onERC721Received() on the recipient if it's a contract
// This is a reentrancy vector in NFT protocols
// ❌ VULNERABLE: NFT marketplace listing vulnerability
function fulfillListing(uint256 tokenId, uint256 price) external payable {
require(msg.value == price, "Wrong price");
// safeTransferFrom triggers onERC721Received on msg.sender
nft.safeTransferFrom(listing.seller, msg.sender, tokenId);
// Attacker's onERC721Received() can call fulfillListing() again!
listings[tokenId].seller.call{value: price}(""); // Pay seller
delete listings[tokenId]; // Updated too late
}
// ✅ CORRECT: CEI + nonReentrant
function fulfillListing(uint256 tokenId, uint256 price) external payable nonReentrant {
require(msg.value == price);
address seller = listings[tokenId].seller;
delete listings[tokenId]; // Clear state FIRST
nft.safeTransferFrom(seller, msg.sender, tokenId); // Then external calls
seller.call{value: price}("");
}
Token Compatibility Reference Table
| Weird Behavior | Example Tokens | Protocols at Risk | Defense |
|---|---|---|---|
| Fee-on-transfer | USDT (BSC), STA, PAXG | DEXes, lending, vaults | Balance delta measurement |
| Rebasing | stETH, AMPL, OHM | Any caching balance | Track shares, not amounts |
| Blacklist | USDC, USDT, BUSD | Push-style payouts | Pull pattern (claim-based) |
| False on failure | BNB, ZRX, EURS | Any token transfer | SafeERC20 |
| No return value | USDT (mainnet), old tokens | Any token transfer | SafeERC20 |
| ERC777 callbacks | imBTC, GLM | Lending, DEXes | CEI + nonReentrant |
| Non-18 decimals | USDC (6), WBTC (8), GUSD (2) | Price/value calculations | Read decimals(), normalize |
| Upgradeable | USDC, DAI, USDT | All protocols using them | Accept the risk, monitor |
| Pausable | USDC, USDT | Protocols with locked liquidity | Emergency withdrawal paths |
For every function that handles ERC20 tokens, ask these questions in order: (1) What if this is a fee-on-transfer token? (2) What if this token's balance changes between blocks? (3) What if transfer() returns false instead of reverting? (4) What if transfer() triggers a callback? (5) What if this token has non-standard decimals? If the code handles all five correctly, the token handling is likely sound.
USDC, USDT, and many other major stablecoins are upgradeable proxies. Their behavior can change after an audit. A protocol that is perfectly safe with today's USDC implementation might be vulnerable after Centre upgrades it. This risk cannot be fully mitigated through code — it requires legal agreements, monitoring, and governance procedures to respond if behavior changes.
ERC1155 Multi-Transfer Callbacks
// ERC1155.safeBatchTransferFrom() calls onERC1155BatchReceived() on recipient
// This is a reentrancy vector in multi-token protocols (NFT marketplaces, gaming)
// ❌ VULNERABLE: NFT fractional protocol with batch redemption
function redeemBatch(uint256[] memory ids, uint256[] memory amounts) external {
// Calculate total value
uint256 totalValue = calculateValue(ids, amounts);
// Burn ERC1155 tokens — triggers onERC1155BatchReceived if msg.sender is contract
nft1155.safeBatchTransferFrom(msg.sender, address(this), ids, amounts, "");
// State update AFTER external call → reentrancy possible
totalRedeemed += totalValue;
ETH.transfer(msg.sender, totalValue);
}
// ✅ CORRECT: Use nonReentrant + CEI
function redeemBatch(uint256[] memory ids, uint256[] memory amounts)
external
nonReentrant
{
uint256 totalValue = calculateValue(ids, amounts);
totalRedeemed += totalValue; // Effect BEFORE interaction
nft1155.safeBatchTransferFrom(msg.sender, address(this), ids, amounts, "");
ETH.transfer(msg.sender, totalValue);
}
The ERC4626 Vault Share Inflation Attack
// ERC4626 standard vault: shares = assets * totalShares / totalAssets
// When totalShares = 0 and totalAssets = 0: first depositor gets 1:1 shares
// ❌ VULNERABLE: No minimum shares / virtual offset
function deposit_BUGGY(uint256 assets) external returns (uint256 shares) {
// When totalSupply=0: shares = assets (safe)
// First depositor deposits 1 wei → gets 1 share
if (totalSupply() == 0) {
shares = assets;
} else {
shares = assets * totalSupply() / totalAssets();
}
_mint(msg.sender, shares);
asset.transferFrom(msg.sender, address(this), assets);
}
// Attack sequence (share inflation / first depositor attack):
// 1. Attacker deposits 1 wei → gets 1 share
// 2. Attacker sends 1000 USDC directly to vault (NOT via deposit)
// 3. Now: totalShares=1, totalAssets=1000e6+1 ≈ $1000
// 4. Victim deposits 1999 USDC:
// shares = 1999e6 * 1 / (1000e6 + 1) = 1 share (rounds down to 1)
// 5. Victim gets 1 share, attacker also has 1 share
// 6. Total assets: ~3000 USDC, 2 shares → each worth ~1500 USDC
// 7. Attacker withdraws their 1 share → gets ~1500 USDC (profit: +$500)
// 8. Victim loses $500 of their $1999 deposit
// ✅ FIX 1: OpenZeppelin's virtual offset (ERC4626 in OZ 5.0)
// Add a virtual 1 share and 1 asset at genesis to prevent 0/0 state
function totalAssets() public view override returns (uint256) {
return asset.balanceOf(address(this)) + 1; // Virtual offset
}
function totalSupply() public view override returns (uint256) {
return super.totalSupply() + 1; // Virtual offset
}
// ✅ FIX 2: Minimum initial deposit burned to dead address
// First depositor must deposit at least 1000 shares
// 1000 shares are burned to dead address — expensive to manipulate
Token Integration Checklist
| Integration Pattern | Specific Check | Tool |
|---|---|---|
| Any ERC20 deposit | Balance delta measurement instead of amount parameter | Manual review |
| Any ERC20 transfer | SafeERC20 used (safeTransfer, safeTransferFrom) | Slither unchecked-transfer detector |
| Price calculation | Decimals read dynamically via decimals() | Manual + grep for 1e18 hardcoded |
| ERC777 token | CEI pattern + nonReentrant on all transfer recipients | Manual review of receive functions |
| ERC721 safe transfer | nonReentrant on functions that call safeTransferFrom | Manual review |
| ERC4626 vault | Virtual offset or minimum initial deposit to prevent inflation | Manual review of totalAssets/totalSupply |
| Rebasing token | Track shares not amounts; verify shares() function exists | Manual review |
| Unknown token whitelist | Is there a whitelist? Fee-on-transfer tokens blocked? | Manual review of token validation |
Before approving any protocol for production that handles arbitrary ERC20 tokens, the auditor must answer: "What happens if someone deploys a malicious ERC20 that (1) returns false from transfer(), (2) calls the protocol back via tokensReceived(), (3) has 0 decimals, (4) deducts 100% as a fee, and (5) changes its behavior after being whitelisted?" If the protocol breaks under any of these scenarios, it is not safe for arbitrary token support and should require a strict whitelist with documented assumptions about each whitelisted token.
Token Approval Attacks and Unlimited Approvals
The ERC20 allowance mechanism has its own attack surface. Protocols that request maximum approvals, fail to reset approvals after use, or do not handle the race condition in the original approve() function expose users to ongoing risk even after they stop using the protocol.
// The ERC20 approval race condition (original design flaw):
// Alice approves Bob for 100 tokens
// Alice changes approval to 50 tokens (Alice submits tx)
// Bob sees Alice's pending tx in mempool — front-runs it
// Bob spends 100 tokens BEFORE new approval takes effect
// Alice's new approval goes through → Bob can spend 50 MORE
// Bob spent 150 tokens total instead of 50
// Fix: use increaseAllowance()/decreaseAllowance() or EIP-2612 permits
// Protocol-side approval issues:
// ❌ Infinite approval to external contract
function initialize() external {
token.approve(externalProtocol, type(uint256).max);
// If externalProtocol is compromised → all protocol tokens drained
}
// ✅ Just-in-time approval
function swapAndDeposit(uint256 amount) external {
token.safeApprove(router, amount); // Only approve what's needed
router.swapExactTokensForTokens(amount, ...);
token.safeApprove(router, 0); // Reset to 0 after use
}
// EIP-2612: Permit-based approvals (no separate tx needed)
function depositWithPermit(
uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// User signs approval off-chain → no front-running possible
token.permit(msg.sender, address(this), amount, deadline, v, r, s);
token.safeTransferFrom(msg.sender, address(this), amount);
// Audit: is permit() validated to prevent signature replay?
// EIP-2612 uses nonces — but check that nonce is actually incremented
}
| Token Type | Key Risk | Detection Pattern | Severity if Unhandled |
|---|---|---|---|
| Fee-on-Transfer | Amount received != amount sent; protocol assumes equality | Missing balance delta pattern | High — protocol math breaks |
| Rebasing (positive) | Accrued yield stays in contract, not distributed to users | stETH, aToken tracked by amount not shares | Medium — user loss |
| Rebasing (negative) | User balances decrease unexpectedly; health factor miscalculated | Not tracking shares for collateral | High — undercollateralization |
| Blacklistable | Push payments to blacklisted address DoS loop | for loop with transfer() | Medium — protocol DoS |
| False-on-failure | Silent transfer failure; protocol thinks transfer succeeded | Missing SafeERC20 | Critical — loss of funds |
| ERC777 | Reentrancy via tokensReceived callback | transfer/transferFrom without nonReentrant | Critical — full drain |
| Non-standard decimals | Price calculation overflow or severe truncation | Missing decimal normalization | High — incorrect valuations |
| Upgradeable token | Token behavior changes after whitelisting | TransparentProxy or UUPS in token contract | High — trust assumption violated |
Key Takeaways
- Never trust the
amountparameter of a token transfer — always measure received amounts using before/after balance checks. - Use
SafeERC20.safeTransfer()andsafeTransferFrom()for all token transfers — they handle missing return values and false returns correctly. - Rebasing tokens require tracking shares (the invariant unit), not token amounts (which change every rebase).
- Blacklistable tokens (USDC, USDT) make push-style payment loops vulnerable to DoS — always use pull (claim-based) patterns.
- ERC777 callbacks create reentrancy vectors in any function that transfers ERC777 tokens — apply CEI and nonReentrant.
- Always query
decimals()dynamically and normalize all token amounts before price calculations. - ERC4626 vaults are vulnerable to share inflation attacks when totalSupply is 0 — use virtual offsets or burn minimum shares at initialization.
- The
safeTransferFromin ERC721 and ERC1155 triggers recipient callbacks — treat these as potential reentrancy vectors in any protocol that handles NFTs.