🌳 Advanced ⏱️ 45 min

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.

📖 The ERC20 Assumption Problem

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.

🔍 Fee-on-Transfer Breaking Protocol Accounting
// ❌ 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.

🔍 Rebasing Token — Cached Balance Attack
// 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

🔍 Blacklist Token DoS Attack
// 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

🔍 Missing Return Value Check
// 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 tokensReceived() Reentrancy
// 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

🔍 Decimal Mismatch Bugs
// 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

🔍 ERC721 onERC721Received Reentrancy
// 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 BehaviorExample TokensProtocols at RiskDefense
Fee-on-transferUSDT (BSC), STA, PAXGDEXes, lending, vaultsBalance delta measurement
RebasingstETH, AMPL, OHMAny caching balanceTrack shares, not amounts
BlacklistUSDC, USDT, BUSDPush-style payoutsPull pattern (claim-based)
False on failureBNB, ZRX, EURSAny token transferSafeERC20
No return valueUSDT (mainnet), old tokensAny token transferSafeERC20
ERC777 callbacksimBTC, GLMLending, DEXesCEI + nonReentrant
Non-18 decimalsUSDC (6), WBTC (8), GUSD (2)Price/value calculationsRead decimals(), normalize
UpgradeableUSDC, DAI, USDTAll protocols using themAccept the risk, monitor
PausableUSDC, USDTProtocols with locked liquidityEmergency withdrawal paths
💡 The Auditor's Universal Token Question

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.

⚠️ Upgradeable Tokens: The Invisible Risk

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 Batch Transfer Reentrancy
// 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

🔍 First Depositor Share Inflation
// 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 PatternSpecific CheckTool
Any ERC20 depositBalance delta measurement instead of amount parameterManual review
Any ERC20 transferSafeERC20 used (safeTransfer, safeTransferFrom)Slither unchecked-transfer detector
Price calculationDecimals read dynamically via decimals()Manual + grep for 1e18 hardcoded
ERC777 tokenCEI pattern + nonReentrant on all transfer recipientsManual review of receive functions
ERC721 safe transfernonReentrant on functions that call safeTransferFromManual review
ERC4626 vaultVirtual offset or minimum initial deposit to prevent inflationManual review of totalAssets/totalSupply
Rebasing tokenTrack shares not amounts; verify shares() function existsManual review
Unknown token whitelistIs there a whitelist? Fee-on-transfer tokens blocked?Manual review of token validation
🚨 The Universal Token Question

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.

🔍 Approval Security Patterns
// 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 TypeKey RiskDetection PatternSeverity if Unhandled
Fee-on-TransferAmount received != amount sent; protocol assumes equalityMissing balance delta patternHigh — protocol math breaks
Rebasing (positive)Accrued yield stays in contract, not distributed to usersstETH, aToken tracked by amount not sharesMedium — user loss
Rebasing (negative)User balances decrease unexpectedly; health factor miscalculatedNot tracking shares for collateralHigh — undercollateralization
BlacklistablePush payments to blacklisted address DoS loopfor loop with transfer()Medium — protocol DoS
False-on-failureSilent transfer failure; protocol thinks transfer succeededMissing SafeERC20Critical — loss of funds
ERC777Reentrancy via tokensReceived callbacktransfer/transferFrom without nonReentrantCritical — full drain
Non-standard decimalsPrice calculation overflow or severe truncationMissing decimal normalizationHigh — incorrect valuations
Upgradeable tokenToken behavior changes after whitelistingTransparentProxy or UUPS in token contractHigh — trust assumption violated

Key Takeaways

  • Never trust the amount parameter of a token transfer — always measure received amounts using before/after balance checks.
  • Use SafeERC20.safeTransfer() and safeTransferFrom() 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 safeTransferFrom in ERC721 and ERC1155 triggers recipient callbacks — treat these as potential reentrancy vectors in any protocol that handles NFTs.