Part 2 — Module 1: Token Mechanics in Practice
Difficulty: Beginner
Estimated reading time: ~30 minutes | Exercises: ~2 hours
📚 Table of Contents
ERC-20 Core Patterns & Weird Tokens
- The Approval Model
- Decimal Handling — The Silent Bug Factory
- Read: OpenZeppelin ERC20 and SafeERC20
- Read: The Weird ERC-20 Catalog
- Intermediate Example: Minimal Safe Deposit
Advanced Token Behaviors & Protocol Design
- Advanced Token Behaviors That Break Protocols
- Read: WETH
- Token Listing Patterns
- Token Evaluation Checklist
- Build Exercises: Token Interaction Patterns
💡 ERC-20 Core Patterns & Weird Tokens
Why this matters: Every DeFi protocol moves tokens. AMMs swap them, lending pools custody them, vaults compound them. Before you build any of that, you need to deeply understand how token interactions actually work at the contract level — not just the ERC-20 interface, but the real-world edge cases that have caused millions in losses.
Real impact: Hundred Finance hack ($7M, April 2023) — exploited lending pool that didn’t account for ERC-777 reentrancy hooks. SushiSwap MISO incident ($3M, September 2021) — malicious token with transfer() that silently failed but returned true, draining auction funds.
Note: Permit (EIP-2612) and Permit2 patterns are covered in Part 1 Module 3. This module focuses on the ERC-20 edge cases and safe integration patterns that will affect every protocol you build in Part 2.
💡 Concept: The Approval Model
Why this matters: The approve/transferFrom two-step isn’t just a design pattern — it’s the foundation that every DeFi interaction is built on. Understanding why it exists and how it shapes protocol architecture is essential before building anything.
The core problem: A smart contract can’t “pull” tokens from a user without prior authorization. Unlike ETH (which can be sent with msg.value), ERC-20 tokens require the user to first call approve(spender, amount) on the token contract, granting the spender permission. The protocol then calls transferFrom(user, protocol, amount) to actually move the tokens.
This creates the foundational DeFi interaction pattern:
User → Token.approve(protocol, amount) // tx 1: grant permission
User → Protocol.deposit(amount) // tx 2: protocol calls transferFrom internally
Every DeFi protocol you’ll ever build begins here. Uniswap V2, Aave V3, Compound V3 — all use this exact pattern.
Deep dive: EIP-20 specification defines the standard, but see Weird ERC-20 catalog for what the standard doesn’t cover.
🔗 DeFi Pattern Connection
Where the approve/transferFrom pattern shapes protocol architecture:
- AMMs (Module 2): Uniswap V2’s “pull” pattern — users approve the Router, Router calls
transferFromto move tokens into Pair contracts. V4 replaces this with flash accounting - Lending (Module 4): Users approve the Pool contract to pull collateral. Aave V3 and Compound V3 both use this for deposits
- Vaults (Module 7): ERC-4626 vaults call
transferFromon deposit — the entire vault standard is built on this two-step pattern - Alternative: Permit (Part 1 Module 3) eliminates the separate approve transaction by using EIP-712 signatures
💡 Concept: Decimal Handling — The Silent Bug Factory
Why this matters: Tokens have different decimal places: USDC and USDT use 6, WBTC uses 8, DAI and WETH use 18. Incorrect decimal normalization is one of the most common sources of DeFi bugs. When your protocol compares 1 USDC (1e6) with 1 DAI (1e18), you’re comparing numbers that differ by a factor of 10^12. Get this wrong and your protocol is either giving away money or locking up funds.
The core problem:
// ❌ WRONG: Comparing raw amounts of different tokens
// 1 USDC = 1_000_000 (6 decimals)
// 1 DAI = 1_000_000_000_000_000_000 (18 decimals)
// This makes 1 DAI look like 1 trillion USDC
uint256 totalValue = usdcAmount + daiAmount; // Meaningless!
// ✅ CORRECT: Normalize to a common base (e.g., 18 decimals)
uint256 normalizedUSDC = usdcAmount * 10**(18 - 6); // Scale up to 18 decimals
uint256 normalizedDAI = daiAmount; // Already 18 decimals
uint256 totalValue = normalizedUSDC + normalizedDAI; // Now comparable
How production protocols handle this:
Aave V3 normalizes all asset amounts to 18 decimals internally using a reserveDecimals lookup:
// From Aave V3's ReserveLogic — all internal math uses normalized amounts
// See: https://github.com/aave/aave-v3-core/blob/master/contracts/protocol/libraries/logic/ReserveLogic.sol
uint256 normalizedAmount = amount * 10**(18 - reserve.configuration.getDecimals());
Chainlink price feeds return prices with varying decimals — ETH/USD uses 8 decimals, but other feeds may differ. You must always call decimals() on the feed:
// ❌ BAD: Hardcoding 8 decimals
uint256 price = uint256(answer) * 1e10; // Assumes 8 decimals — breaks on some feeds
// ✅ GOOD: Dynamic decimal handling
uint8 feedDecimals = priceFeed.decimals();
uint256 price = uint256(answer) * 10**(18 - feedDecimals);
Edge case — extreme decimals: Most tokens use 6, 8, or 18, but outliers exist. GUSD uses 2 decimals, and some tokens use >18 (e.g., 24). Normalizing with
10**(18 - decimals)underflows whendecimals > 18. Always guard:require(decimals <= 18)or handle both directions withdecimals > 18 ? amount / 10**(decimals - 18) : amount * 10**(18 - decimals).
Common pitfall: Hardcoding Chainlink feed decimals to 8. While ETH/USD and BTC/USD use 8, the ETH/BTC feed uses 18. Always call
priceFeed.decimals()and normalize dynamically. See Chainlink feed registry.
Compound V3 (Comet) stores an explicit baseTokenDecimals and uses scaling factors throughout:
// From Compound V3 — explicit scaling
// See: https://github.com/compound-finance/comet/blob/main/contracts/Comet.sol
uint256 internal immutable baseScale; // = 10 ** baseToken.decimals()
Real impact: Decimal bugs are among the most common critical findings in Code4rena contests. A recurring pattern: protocol assumes 18 decimals for all tokens, then someone deposits USDC (6 decimals) or WBTC (8 decimals) and the math is off by factors of 10^10 or 10^12 — either giving away funds or locking them. Midas Finance ($660K, January 2023) was exploited partly because a newly listed collateral token’s decimal handling wasn’t properly validated.
💻 Quick Try:
Test this in your Foundry console to feel the difference:
// In a Foundry test
uint256 oneUSDC = 1e6; // 1 USDC (6 decimals)
uint256 oneDAI = 1e18; // 1 DAI (18 decimals)
uint256 oneWBTC = 1e8; // 1 WBTC (8 decimals)
// Normalize all to 18 decimals
uint256 normUSDC = oneUSDC * 1e12; // 1e6 * 1e12 = 1e18 ✓
uint256 normWBTC = oneWBTC * 1e10; // 1e8 * 1e10 = 1e18 ✓
assertEq(normUSDC, oneDAI); // Both represent "1 token" at 18 decimals
Deep dive: OpenZeppelin Math.mulDiv — when scaling involves multiplication that could overflow, use
mulDivfor safe precision handling. Covered in Part 1 Module 1.
📖 Read: OpenZeppelin ERC20 and SafeERC20
Source: @openzeppelin/contracts v5.x
Read the OpenZeppelin ERC20 implementation end-to-end. Pay attention to:
- The
_update()function (v5.x replaced_beforeTokenTransfer/_afterTokenTransferhooks with a single_updatefunction — this is a design change you’ll encounter when reading older protocol code vs newer code) - How
approve()andtransferFrom()interact through the_allowancesmapping - The
_spendAllowance()helper and its special case fortype(uint256).max(infinite approval)
Then read SafeERC20 carefully. This is not optional — it’s mandatory for any protocol that accepts arbitrary tokens.
The key insight: The ERC-20 standard says transfer() and transferFrom() should return bool, but major tokens like USDT don’t return anything at all. SafeERC20 handles this by using low-level calls and checking both the return data length and value.
Key functions to understand:
safeTransfer/safeTransferFrom— handles non-compliant tokens that don’t return boolforceApprove— replaces the deprecatedsafeApprove, handles USDT’s “must approve to 0 first” behavior
Used by: Uniswap V3 NonfungiblePositionManager, Aave V3 Pool, Compound V3 Comet — every major protocol uses SafeERC20.
📖 How to Study SafeERC20:
- Read the interface first —
IERC20.soldefines what tokens should do - Read
safeTransfer— See how it usesfunctionCallWithValueto handle missing return values - Read
forceApprove— Understand the USDT “approve to zero first” workaround - Compare with Solmate’s
SafeTransferLib— Solmate’s version skipsaddress.code.lengthchecks for gas savings (trade-off: no empty address detection) - Don’t get stuck on: The assembly-level return data parsing — understand what it does (check return bool or accept empty return), not every opcode
📖 Read: The Weird ERC-20 Catalog
Source: github.com/d-xo/weird-erc20
Why this matters: This repository documents real tokens with behaviors that break naive assumptions. As a protocol builder, you must design for these. The critical categories:
1. Missing return values
USDT, BNB, OMG don’t return bool. If your protocol does require(token.transfer(...)), it will fail on these tokens. SafeERC20 exists specifically for this.
Common pitfall: Writing
require(token.transfer(to, amount))without SafeERC20. This compiles fine with standard ERC-20 but silently reverts with USDT. Always usetoken.safeTransfer(to, amount).
2. Fee-on-transfer tokens
STA, PAXG, and others deduct a fee on every transfer. If a user sends 100 tokens, the protocol might only receive 97.
The standard pattern to handle this:
uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balanceBefore;
// Use `received`, not `amount`
This “balance-before-after” pattern adds ~2,000 gas but is essential when supporting arbitrary tokens. You’ll see this in Uniswap V2’s swap() function and many lending protocols.
Real impact: Early AMM forks that assumed
amount== received balance got arbitraged to death when fee-on-transfer tokens were added to pools. The fee was extracted by the token contract but the AMM credited the full amount, leading to protocol insolvency.
💻 Quick Try:
Deploy this in Foundry to see the balance-before-after pattern catch a fee-on-transfer token:
// In a Foundry test file
function test_FeeOnTransferCaughtByBalanceCheck() public {
FeeOnTransferToken feeToken = new FeeOnTransferToken();
feeToken.mint(alice, 1000e18);
vm.startPrank(alice);
feeToken.approve(address(vault), 1000e18);
// Alice deposits 100 tokens, but 1% fee means vault receives 99
uint256 vaultBalBefore = feeToken.balanceOf(address(vault));
feeToken.transfer(address(vault), 100e18);
uint256 received = feeToken.balanceOf(address(vault)) - vaultBalBefore;
// Without balance-before-after: would credit 100e18 (WRONG)
// With balance-before-after: correctly credits 99e18
assertEq(received, 99e18); // 100 - 1% fee = 99
vm.stopPrank();
}
Run it and see the 1% difference. This is why received != amount.
3. Rebasing tokens
stETH, AMPL, OHM change user balances automatically. A protocol that stores balanceOf at deposit time may find the actual balance has changed by withdrawal.
Protocols either:
- (a) Wrap rebasing tokens into non-rebasing versions (wstETH for stETH)
- (b) Explicitly exclude them (Uniswap V2 explicitly warns against rebasing tokens)
Used by: Aave V3 treats stETH specially by wrapping to wstETH, Curve has dedicated pools for rebasing tokens with special accounting.
4. Approval race condition
If Alice has approved 100 tokens to a spender, and then calls approve(200), the spender can front-run to spend the original 100, then spend the new 200, getting 300 total.
Solutions:
- USDT’s brute-force: “approve to zero first” requirement
- Better: use
increaseAllowance/decreaseAllowance(removed from OZ v5 core but still available in extensions) - Best: use Permit (EIP-2612) or Permit2 (covered in Part 1 Module 3)
Common pitfall: Calling
approve(newAmount)directly without first checking if existing approval is non-zero. With USDT, this reverts. UseforceApprovewhich handles the zero-first pattern automatically.
5. Tokens that revert on zero transfer
LEND and others revert when transferring 0 tokens. Your protocol logic needs to guard against this:
if (amount > 0) {
token.safeTransfer(to, amount);
}
Common pitfall: Batch operations that might include zero amounts (e.g., claiming rewards when no rewards are due). Always guard against zero transfers when supporting arbitrary tokens.
6. Tokens with multiple entry points
Some proxied tokens have multiple addresses pointing to the same contract. Don’t use address(token) as a unique identifier without care.
Used by: Aave V3 uses internal assetId mappings rather than relying solely on token addresses.
💼 Job Market Context
What DeFi teams expect you to know:
- “A user reports they deposited 100 tokens but only got credit for 97. What happened?”
- Good answer: “Fee-on-transfer token”
- Great answer: “Almost certainly a fee-on-transfer token like STA or PAXG. The fix is the balance-before-after pattern — measure
balanceOfbefore and aftertransferFrom, credit the delta not the input amount. If this is a new finding, we also need to audit all other deposit/transfer paths for the same bug. This is why testing withFeeOnTransferTokenmocks is essential.”
Interview Red Flags:
- 🚩 Not knowing what
SafeERC20is or whytoken.transfer()needs wrapping - 🚩 Never heard of fee-on-transfer tokens
- 🚩 Treating all ERC-20 tokens as identical in behavior
Pro tip: Mention the Weird ERC-20 catalog by name in interviews — it shows you’ve studied real-world token edge cases, not just the EIP-20 spec.
🔗 DeFi Pattern Connection
Where weird token behaviors break real protocols:
- AMMs (Module 2): Fee-on-transfer tokens cause accounting drift in constant product pools — Uniswap V2’s
_update()syncs from actual balances to handle this - Lending (Module 4): Rebasing tokens break collateral accounting — Aave V3 wraps stETH to wstETH before accepting as collateral
- Yield (Module 7): Fee-on-transfer in ERC-4626 vault deposits requires balance-before-after to compute correct share amounts
🎓 Intermediate Example: Building a Minimal Safe Deposit Function
Before diving into the advanced token behaviors below, let’s bridge from basic SafeERC20 to a production-ready pattern. This combines everything from topics 1-6 above:
/// @notice Handles deposits for ANY ERC-20, including weird ones
function deposit(IERC20 token, uint256 amount) external nonReentrant {
// 1. Guard zero amounts (some tokens revert on zero transfer)
require(amount > 0, "Zero deposit");
// 2. Balance-before-after (handles fee-on-transfer tokens)
uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balanceBefore;
// 3. Credit what we actually received, not what was requested
balances[msg.sender] += received;
emit Deposit(msg.sender, address(token), received);
}
Why each line matters:
nonReentrant→ guards against ERC-777 hooks (topic 7 below)require(amount > 0)→ guards against tokens that revert on zero transfer (topic 5)safeTransferFrom→ handles tokens with no return value like USDT (topic 1)receivedvsamount→ handles fee-on-transfer tokens (topic 2)
This 8-line function handles 90% of weird token edge cases. The remaining 10% (rebasing, pausable, upgradeable) requires architectural decisions covered below.
📋 Summary: ERC-20 Core Patterns & Weird Tokens
✓ Covered:
- The approve/transferFrom two-step and how it shapes all DeFi interactions
- Decimal handling — normalization to common base, dynamic
decimals()lookups - SafeERC20 — why it exists (USDT),
safeTransfer,forceApprove, Solmate comparison - Weird ERC-20 catalog — 6 critical categories: missing return values, fee-on-transfer, rebasing, approval race, zero-transfer revert, multiple entry points
- Balance-before-after pattern — the universal defense against fee-on-transfer tokens
- Intermediate example synthesizing all patterns into a production-ready deposit function
Next: Advanced token behaviors (ERC-777, upgradeable, pausable, flash-mintable), WETH, token listing strategies, and the build exercise.
💡 Advanced Token Behaviors & Protocol Design
⚠️ Advanced Token Behaviors That Break Protocols
Beyond the “weird ERC-20” edge cases above, several token categories introduce behaviors that fundamentally affect protocol architecture. You won’t encounter these on every integration, but when you do, not knowing about them leads to exploits.
7. 🔄 ERC-777 Hooks — Reentrancy Through Token Transfers
Why this matters: ERC-777 is a token standard that adds tokensToSend and tokensReceived hooks — callback functions that execute during transfers. This means every token transfer can trigger arbitrary code execution on the sender or receiver, creating reentrancy vectors that don’t exist with standard ERC-20.
How it works:
Normal ERC-20 transfer:
Token.transfer(to, amount) → updates balances → done
ERC-777 transfer:
Token.transfer(to, amount)
→ calls sender.tokensToSend() ← arbitrary code runs HERE
→ updates balances
→ calls receiver.tokensReceived() ← arbitrary code runs HERE
The receiver’s tokensReceived hook fires after the balance update but before the calling contract’s state is fully updated. This is the classic reentrancy window.
Real exploits:
Real impact: imBTC/Uniswap V1 exploit (~$300K, April 2020) — The attacker used imBTC (an ERC-777 token) on Uniswap V1, which had no reentrancy protection. The
tokensToSendhook was called duringtokenToEthSwap, allowing the attacker to re-enter the pool before reserves were updated, extracting more ETH than deserved.
Real impact: Hundred Finance exploit ($7M, April 2023) — Similar pattern on a Compound V2 fork. The ERC-777 hook allowed reentrancy during the borrow flow.
How to guard against it:
// Option 1: Reentrancy guard (from Part 1 Module 2 — use transient storage version!)
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1)
}
_;
assembly { tstore(0, 0) }
}
// Option 2: Checks-Effects-Interactions pattern (update state BEFORE external calls)
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // Effect BEFORE interaction
token.safeTransfer(msg.sender, amount); // Interaction LAST
}
Common pitfall: Assuming reentrancy only happens through ETH transfers (
call{value: ...}). ERC-777 hooks create reentrancy through any token transfer — includingtransferFromcalls within your protocol. This is why the Checks-Effects-Interactions pattern matters even for token-only protocols.
Used by: Uniswap V2 added a reentrancy lock specifically because of this risk. Aave V3 uses reentrancy guards on all token-moving functions.
Deep dive: EIP-777 specification, SWC-107: Reentrancy, OpenZeppelin ERC777 implementation (removed from OZ v5 — a signal that the standard is falling out of favor).
8. 🔀 Upgradeable Tokens (Proxy Tokens)
Why this matters: Some of the most widely used tokens — USDC ($30B+ market cap), USDT — are deployed behind proxies. The token issuer can upgrade the implementation contract, potentially changing behavior that your protocol depends on.
What can change after an upgrade:
- Transfer logic (adding fees, blocking addresses)
- Approval behavior
- New functions or modified interfaces
- Gas costs of operations
- Return value behavior
Real-world example — USDC V2 → V2.1 upgrade: Circle upgraded USDC in August 2020 to add gasless sends (EIP-3009) and blacklisting improvements. While this was benign, the capability to modify the token’s behavior means your protocol must consider:
// Your protocol assumption:
// "transfer() always moves exactly `amount` tokens"
// After an upgrade, this could become false if Circle adds a fee
// Defensive approach: balance-before-after even for "known" tokens
// when the token is upgradeable
uint256 balanceBefore = usdc.balanceOf(address(this));
usdc.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = usdc.balanceOf(address(this)) - balanceBefore;
Common pitfall: Treating upgradeable tokens as static. If your protocol hardcodes assumptions about USDC’s behavior (e.g., exact transfer amounts, no hooks, no fees), an upgrade could break your protocol without any code changes on your end. Defensive coding treats all proxy tokens as potentially changing.
How protocols manage this risk:
- Monitoring: Watch for proxy upgrade events (
Upgraded(address indexed implementation)) on critical tokens - Governance response: Aave’s risk service providers monitor token changes and can pause markets
- Conservative assumptions: Use balance-before-after pattern even for “trusted” tokens
Used by: MakerDAO’s collateral onboarding evaluates whether tokens are upgradeable as a risk factor. Aave’s risk framework considers proxy risk in asset ratings.
9. 🔒 Pausable & Blacklistable Tokens
Why this matters: USDC and USDT have admin functions that can freeze your protocol’s funds:
- Pause: The issuer can pause ALL transfers globally (USDC has
pause(), USDT haspause) - Blacklist: The issuer can block specific addresses from sending/receiving tokens (USDC has
blacklist(address), USDT hasaddBlackList(address))
The stuck funds scenario:
1. User deposits 1000 USDC into your protocol
2. Your protocol holds USDC at address 0xProtocol
3. OFAC sanctions 0xProtocol (or a user who deposited)
4. Circle blacklists 0xProtocol
5. Your protocol can NEVER transfer USDC again — all user funds are stuck
This is not theoretical — Tornado Cash sanctions (August 2022) led to Circle freezing ~$75K in USDC held in Tornado Cash contracts.
How protocols handle this:
// Pattern 1: Allow withdrawal in alternative token
// If USDC is frozen, users can claim equivalent in another asset
function emergencyWithdraw(address user) external {
uint256 amount = balances[user];
balances[user] = 0;
// Try USDC first
try usdc.transfer(user, amount) {
// Success
} catch {
// USDC frozen — pay in ETH or protocol token at oracle price
uint256 ethEquivalent = getETHEquivalent(amount);
payable(user).transfer(ethEquivalent);
}
}
// Pattern 2: Support multiple stablecoins
// If one is frozen, liquidity can flow to others
// See: Curve 3pool (USDC + USDT + DAI) — diversification against single-issuer risk
Real impact: When USDC depegged to $0.87 during the SVB crisis (March 2023), protocols using USDC as sole collateral faced liquidation cascades. MakerDAO had already diversified to limit USDC exposure to ~40% of DAI backing. This is the operational reality of centralized stablecoin risk.
Common pitfall: Assuming your protocol’s address will never be blacklisted. Even if your protocol is legitimate, composability means a blacklisted address might interact with your contracts through a flash loan or arbitrage path, potentially contaminating your contract’s history. Chainalysis Reactor and OFAC SDN list are the tools compliance teams use.
Used by: Aave V3 can freeze individual reserves via governance if the underlying token is paused. MakerDAO’s PSM (Peg Stability Module) has emergency shutdown capability for this scenario. Liquity chose to use only ETH as collateral — no freeze risk.
💼 Job Market Context
What DeFi teams expect you to know:
- “What happens if your protocol holds USDC and Circle blacklists your contract address?”
- Good answer: “USDC transfers would revert, funds would be stuck”
- Great answer: “All USDC operations would revert. We need a mitigation strategy: either emergency withdrawal in an alternative asset (ETH, DAI), multi-stablecoin support so liquidity can migrate, or governance-triggered asset swap. MakerDAO’s PSM handles this with emergency shutdown. We should also monitor the OFAC SDN list and have an incident response plan.”
Interview Red Flags:
- 🚩 Not knowing that USDC/USDT are upgradeable proxy tokens
- 🚩 Assuming your protocol’s address will never be blacklisted
- 🚩 No awareness of the Tornado Cash sanctions precedent
Pro tip: In architecture discussions, proactively mention emergency withdrawal mechanisms and multi-stablecoin diversification — it shows you think about operational risk, not just code correctness.
10. 📊 Token Supply Mechanics
Why this matters: Tokens don’t just sit still — their total supply changes through minting and burning, and these supply mechanics directly affect protocol accounting.
Inflationary tokens (reward emissions): Protocols like Aave (stkAAVE), Compound (COMP), and Curve (CRV) distribute reward tokens to users. When you build yield aggregators or reward distribution systems, you need to handle:
- Continuous emission schedules
- Reward accrual per block/second
- Claim accounting without iterating over all users (the “reward per token” pattern)
// The standard reward-per-token pattern (from Synthetix StakingRewards)
// See: https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol
rewardPerTokenStored += (rewardRate * (block.timestamp - lastUpdateTime) * 1e18) / totalSupply;
rewards[user] += balance[user] * (rewardPerTokenStored - userRewardPerTokenPaid[user]) / 1e18;
Used by: This exact pattern (originally from Synthetix StakingRewards) is used by virtually every DeFi protocol that distributes rewards — Sushi MasterChef, Convex, Yearn V3 gauges.
🔍 Deep Dive: Reward-Per-Token Math
The problem it solves: You have N stakers with different balances, and rewards flow in continuously. How do you track each staker’s share without iterating over all stakers on every reward distribution? (Iterating would cost O(N) gas — unusable at scale.)
The insight: Instead of tracking each user’s rewards directly, track a single global accumulator: “how much reward has been earned per 1 token staked, since the beginning of time?”
Step-by-step example:
Timeline: Alice stakes 100, then Bob stakes 200, then rewards arrive
State at T=0:
totalSupply = 0, rewardPerToken = 0
T=100: Alice stakes 100 tokens
totalSupply = 100
Alice.userRewardPerTokenPaid = 0 (current rewardPerToken)
T=200: Bob stakes 200 tokens (100 seconds have passed, rewardRate = 1 token/sec)
rewardPerToken += (1 * 100 * 1e18) / 100 = 1e18
│ │ │ │ └── totalSupply
│ │ │ └── scaling factor (for precision)
│ │ └── seconds elapsed (200 - 100)
│ └── rewardRate
└── accumulator update
totalSupply = 300 (100 + 200)
Bob.userRewardPerTokenPaid = 1e18 (current rewardPerToken)
T=300: Alice claims rewards (another 100 seconds, now 300 totalSupply)
rewardPerToken += (1 * 100 * 1e18) / 300 = 0.333e18
rewardPerToken = 1e18 + 0.333e18 = 1.333e18
Alice's reward = 100 * (1.333e18 - 0) / 1e18 = 133.3 tokens
│ │ │ │
│ │ │ └── Alice's userRewardPerTokenPaid (was 0)
│ │ └── current rewardPerToken
│ └── Alice's staked balance
└── Her share: 100% of first 100 sec + 33% of next 100 sec = 100 + 33.3
Bob's reward (if he claimed) = 200 * (1.333e18 - 1e18) / 1e18 = 66.6 tokens
└── His share: 67% of last 100 sec = 66.6 ✓
Why 1e18 scaling? Solidity has no decimals. Without the * 1e18 scaling, rewardRate * elapsed / totalSupply would round to 0 whenever totalSupply > rewardRate * elapsed. The 1e18 factor preserves precision, and is divided out when computing per-user rewards.
The pattern generalized: This same “accumulator + difference” pattern appears as:
feeGrowthGlobalin Uniswap V3 (fee distribution to LPs)liquidityIndexin Aave V3 (interest distribution to depositors)rewardPerTokenin every staking/farming contract
Once you recognize it, you’ll see it everywhere in DeFi.
Deflationary tokens (burn on transfer):
Some tokens burn a percentage on every transfer (covered above as fee-on-transfer). The key additional insight: deflationary mechanics means total supply decreases over time, affecting any accounting that references totalSupply().
Elastic supply (rebase) tokens:
AMPL adjusts ALL holder balances daily to target $1. OHM rebases to distribute staking rewards. This breaks any protocol that stores balanceOf at a point in time:
// ❌ BROKEN with rebasing tokens:
mapping(address => uint256) public deposits; // Stored balance at deposit time
function deposit(uint256 amount) external {
token.safeTransferFrom(msg.sender, address(this), amount);
deposits[msg.sender] += amount; // This amount may not match future balanceOf
}
// ✅ Works: Track shares, not amounts (like wstETH or ERC-4626)
// User deposits 100 stETH → protocol records their share of the pool
// On withdrawal, shares convert to current stETH amount (which rebased)
Deep dive: ERC-4626 Tokenized Vault Standard solves this elegantly with the shares/assets pattern. Covered in depth in Module 7 (Vaults & Yield).
🔗 DeFi Pattern Connection
The reward-per-token pattern is everywhere:
- Yield farming (Module 7): Synthetix StakingRewards is the template — Sushi MasterChef, Convex BaseRewardPool, Yearn gauges all use the same formula
- Lending (Module 4): Aave V3’s interest accrual uses a similar accumulator pattern (
liquidityIndex) to distribute interest without iterating over users - AMMs (Module 2): Uniswap V3’s fee accounting (
feeGrowthGlobal) is the same concept — accumulate per-unit value, compute individual shares by difference
The pattern: Whenever you need to distribute something (rewards, fees, interest) to N users proportionally without iterating, use an accumulator that tracks “value per unit” and let each user compute their share lazily.
11. ⚡ Flash-Mintable Tokens
Why this matters: Some tokens can be created from nothing and destroyed in the same transaction. DAI has flashMint() allowing anyone to mint arbitrary amounts of DAI, use it, and burn it — all atomically.
The security implications:
1. Attacker flash-mints 1 billion DAI (costs only gas + 0.05% fee)
2. Uses the DAI to manipulate a protocol that checks DAI balances or DAI-based prices
3. Returns the DAI + fee in the same transaction
4. Profit from the manipulation exceeds the fee
What this means for protocol design:
// ❌ DANGEROUS: Using token balance as a voting weight or price signal
uint256 votes = dai.balanceOf(msg.sender); // Can be flash-minted to billions
// ✅ SAFE: Use time-weighted or snapshot-based checks
uint256 votes = votingToken.getPastVotes(msg.sender, block.number - 1);
// Can't flash-mint in a previous block
Common pitfall: Using
balanceOfin the current block for governance votes or price calculations. Flash mints (and flash loans, covered in Module 5) can inflate balances to arbitrary amounts within a single transaction. Always use historical snapshots or time-weighted values.
Used by: MakerDAO flash mint module — 0.05% fee, no maximum. Aave V3 flash loans enable similar behavior for any token they hold. OpenZeppelin ERC20FlashMint provides a standard implementation.
Deep dive: Flash loans and flash mints are covered extensively in Module 5. Here, the key takeaway is: never trust current-block balances for security-critical decisions.
💻 Quick Try:
See why balanceOf is dangerous for governance in Foundry:
import {ERC20Votes, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract GovToken is ERC20Votes {
constructor() ERC20("Gov", "GOV") EIP712("Gov", "1") {
_mint(msg.sender, 1000e18);
}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
// In your test:
function test_SnapshotVsBalance() public {
GovToken gov = new GovToken();
gov.delegate(address(this)); // self-delegate to activate checkpoints
// Snapshot: 1000 tokens at previous block
vm.roll(block.number + 1);
assertEq(gov.getPastVotes(address(this), block.number - 1), 1000e18);
// Now simulate a "flash mint" — balance spikes but snapshot is safe
gov.mint(address(this), 1_000_000e18); // sudden 1M tokens
assertEq(gov.balanceOf(address(this)), 1_001_000e18); // balanceOf: inflated!
assertEq(gov.getPastVotes(address(this), block.number - 1), 1000e18); // snapshot: unchanged ✓
}
The snapshot still reads 1,000 tokens even though balanceOf shows 1,001,000. This is why getPastVotes is safe and balanceOf is not.
📖 Read: WETH
Source: The canonical WETH9 contract (deployed December 2017)
Why this matters: WETH exists because ETH doesn’t conform to ERC-20. Protocols that want to treat ETH uniformly with other tokens use WETH. The contract is trivially simple:
deposit()(payable): accepts ETH, mints equivalent WETH (1:1)withdraw(uint256 wad): burns WETH, sends ETH back
Understand that many protocols (Uniswap, Aave, etc.) have dual paths — one for ETH (wraps to WETH internally) and one for ERC-20 tokens.
When you build your own protocols, you’ll face the same design choice:
- Support raw ETH: better UX (users don’t need to wrap), but requires separate code paths and careful handling of
msg.value - Require WETH: simpler code (single ERC-20 path), but users must wrap ETH themselves
Used by: Uniswap V2 Router has
swapExactETHForTokens(wraps ETH → WETH internally), Aave WETHGateway wraps/unwraps for users. Uniswap V4 added native ETH support — its singleton architecture manages ETH balances directly via flash accounting, eliminating the WETH wrapping overhead.
Awareness: ERC-6909 is a minimal multi-token standard (think lightweight ERC-1155). Uniswap V4 uses it for LP position tokens instead of V3’s ERC-721 NFTs — simpler, cheaper, and fungible per-pool. You’ll encounter it when reading V4 code.
Deep dive: WETH9 source code — only 60 lines. Read it.
💻 Quick Try:
Test the WETH wrap/unwrap cycle in Foundry using a mainnet fork:
// In a Foundry test (requires fork mode: forge test --fork-url $ETH_RPC_URL)
interface IWETH {
function deposit() external payable;
function withdraw(uint256 wad) external;
function balanceOf(address) external view returns (uint256);
}
function test_WETHWrapUnwrap() public {
IWETH weth = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
// Wrap: send 1 ETH, get 1 WETH
uint256 ethBefore = address(this).balance;
weth.deposit{value: 1 ether}();
assertEq(weth.balanceOf(address(this)), 1 ether);
// Unwrap: burn 1 WETH, get 1 ETH back
weth.withdraw(1 ether);
assertEq(weth.balanceOf(address(this)), 0);
assertEq(address(this).balance, ethBefore); // ETH fully restored
// Key insight: WETH is a 1:1 wrapper — no fees, no slippage, just ERC-20 compatibility
}
Feel the simplicity — WETH is just a deposit/withdraw box. Now imagine every protocol needing separate code paths for ETH vs ERC-20, and you understand why WETH exists.
💼 Job Market Context
What DeFi teams expect you to know:
- “How does Uniswap V4 handle native ETH differently from V2/V3?”
- Good answer: “V4 supports native ETH directly without requiring WETH wrapping”
- Great answer: “V2/V3 used WETH as an intermediary — the Router wrapped ETH before interacting with pair/pool contracts. V4’s singleton architecture manages ETH balances natively via flash accounting: ETH is tracked as internal deltas alongside ERC-20 tokens, settled at the end of the transaction. This eliminates the gas cost of wrapping/unwrapping and simplifies multi-hop swaps involving ETH. The
address(0)orCurrencyLibrary.NATIVErepresents ETH in V4’s currency system.”
Interview Red Flags:
- 🚩 Thinking V4 dropped native ETH support (it’s the opposite — V4 added it)
- 🚩 Not knowing what WETH is or why it exists
Pro tip: If applying to a DEX role, knowing the V4 CurrencyLibrary and how address(0) represents native ETH shows you’ve read the actual codebase.
💡 Concept: Token Listing Patterns — Permissionless vs Curated
Why this matters: One of the first architectural decisions in any DeFi protocol is: which tokens does it support? This decision shapes your entire security model, risk framework, and user experience.
The three approaches:
1. Permissionless (anyone can add any token)
Uniswap V2 and V3 allow anyone to create a pool for any ERC-20 pair. Uniswap V4 continues this.
- Pros: Maximum composability, no governance bottleneck, any token can be traded immediately
- Cons: Users interact with potentially malicious/weird tokens at their own risk. The protocol must handle ALL edge cases (fee-on-transfer, rebase, etc.) or explicitly document unsupported behaviors
- Security model: User responsibility. The protocol warns but doesn’t prevent
2. Curated allowlist (governance-approved tokens only)
Aave V3 requires governance approval for each new asset, with risk parameters set per token (LTV, liquidation threshold, etc.). Compound V3 has hardcoded asset lists per market.
- Pros: Each token is risk-assessed before addition. Protocol can optimize for known token behaviors. Smaller attack surface
- Cons: Slow to add new assets (governance overhead). May miss opportunities. Centralization of listing decisions
- Security model: Protocol responsibility. Governance evaluates risk
3. Hybrid (permissionless with risk tiers)
Euler V2 allows permissionless vault creation where vault creators set their own risk parameters. Morpho Blue allows anyone to create lending markets with any collateral/borrow pair, but each market has explicit risk parameters.
- Pros: Permissionless innovation with isolated risk. Bad tokens can’t affect good markets
- Cons: More complex architecture. Users must evaluate individual market risk
- Security model: Market-level isolation. Risk is per-market, not protocol-wide
Comparison table:
| Protocol | Approach | Who decides | Token support | Risk isolation |
|---|---|---|---|---|
| Uniswap V2/V3/V4 | Permissionless | Anyone | Any ERC-20 | Per-pool |
| Aave V3 | Curated | Governance | ~30 assets | Shared (E-Mode/Isolation helps) |
| Compound V3 | Curated | Governance | ~5-10 per market | Per-market |
| Euler V2 | Hybrid | Vault creators | Any | Per-vault |
| Morpho Blue | Hybrid | Market creators | Any pair | Per-market |
| MakerDAO | Curated | Governance | ~20 collaterals | Per-vault type |
Common pitfall: Building a permissionless protocol without handling weird token edge cases. If anyone can add tokens, someone WILL add a fee-on-transfer token, a rebasing token, or a malicious token. Either handle all cases or explicitly document/revert on unsupported behaviors.
Deep dive: Aave’s asset listing governance process, Gauntlet risk assessment framework (used by Aave and Compound for parameter recommendations), Euler V2 architecture.
📋 Token Evaluation Checklist
Use this when integrating a new token into your protocol. This synthesizes everything in this module into a practical assessment tool.
| # | Check | What to look for | Impact if missed |
|---|---|---|---|
| 1 | Return values | Does transfer/transferFrom return bool? (USDT doesn’t) | Silent failures → fund loss |
| 2 | Fee-on-transfer | Does the received amount differ from the sent amount? | Accounting drift → insolvency |
| 3 | Rebasing | Does balanceOf change without transfers? (stETH, AMPL, OHM) | Stale balance accounting → incorrect withdrawals |
| 4 | Decimals | How many? (6, 8, 18, or something else?) | Overflow/underflow, wrong exchange rates |
| 5 | Upgradeable | Is it behind a proxy? (USDC, USDT) | Behavior can change post-deployment |
| 6 | Pausable | Can the issuer pause all transfers? (USDC, USDT) | Stuck funds, broken liquidations |
| 7 | Blacklistable | Can specific addresses be blocked? (USDC, USDT) | Protocol address frozen → all funds stuck |
| 8 | ERC-777 hooks | Does it have transfer hooks? (imBTC) | Reentrancy via tokensReceived callback |
| 9 | Zero transfer | Does it revert on zero-amount transfer? (LEND) | Batch operations fail |
| 10 | Multiple addresses | Does it have proxy aliases or multiple entry points? | Address-based dedup fails |
| 11 | Flash-mintable | Can supply be inflated atomically? (DAI) | Balance-based governance/pricing exploitable |
| 12 | Max supply / inflation | What’s the emission schedule? | Dilution affects collateral value over time |
| 13 | Approve race condition | Does it require approve-to-zero first? (USDT) | approve() reverts → UX breaks |
Quick assessment flow:
Is the token a well-known standard token? (DAI, WETH, etc.)
├── YES → Checks 4-7 still apply (USDC is "well-known" but upgradeable + pausable + blacklistable)
└── NO → Run ALL 13 checks
Is your protocol permissionless or curated?
├── Permissionless → Must handle checks 1-3, 8-9 defensively in code
└── Curated → Can skip some defensive patterns for pre-vetted tokens, but still use SafeERC20
Pro tip: When listing a new token in a curated protocol, write a Foundry fork test that interacts with the real deployed token on mainnet. This catches behaviors that documentation misses.
🎯 Build Exercise: Token Interaction Patterns
Workspace: workspace/src/part2/module1/ — starter files: DefensiveVault.sol, DecimalNormalizer.sol, tests: DefensiveVault.t.sol, DecimalNormalizer.t.sol
Exercise 1: Defensive Vault (DefensiveVault.sol) — Build a vault that correctly handles deposits and withdrawals for ANY ERC-20 token, including fee-on-transfer tokens and tokens that don’t return a bool. Requirements:
- Import and apply SafeERC20 for all token interactions
- Implement
deposit()using the balance-before-after pattern (credit actual received, not requested) - Implement
withdraw()with proper balance checks - Track per-user balances and total tracked amount
- Emit events for deposits and withdrawals
Tests (DefensiveVault.t.sol) cover standard tokens, fee-on-transfer tokens (1% fee), USDT-style no-return-value tokens, edge cases, and fuzz invariants.
Exercise 2: Decimal Normalizer (DecimalNormalizer.sol) — Build a multi-token accounting contract that accepts deposits from tokens with different decimals (USDC=6, WBTC=8, DAI=18) and maintains a single normalized internal ledger in 18 decimals. Requirements:
- Register tokens and read their decimals dynamically
- Implement
_normalize()and_denormalize()conversion helpers - Implement
deposit()andwithdraw()with decimal scaling - Track per-user, per-token normalized balances and a global total
- Understand precision loss when denormalizing (division truncation)
Tests (DecimalNormalizer.t.sol) cover registration, normalization math for 6/8/18 decimal tokens, cross-token totals, roundtrip precision, and fuzz invariants.
Foundry tip: The workspace already includes mock tokens in workspace/src/part2/module1/mocks/ — FeeOnTransferToken.sol (1% fee), NoReturnToken.sol (USDT-style), and MockERC20.sol (configurable decimals). Here is the fee-on-transfer pattern for reference:
contract FeeOnTransferToken is ERC20 {
uint256 public constant FEE_BPS = 100; // 1%
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
uint256 fee = (amount * FEE_BPS) / 10_000;
_spendAllowance(from, msg.sender, amount);
_burn(from, fee); // burn fee from sender
_transfer(from, to, amount - fee); // transfer remainder
return true;
}
}
Common pitfall: Testing only with standard ERC-20 mocks. Your vault will pass all tests but fail in production when encountering USDT or fee-on-transfer tokens. Always test with weird token mocks.
💼 Module-Level Interview Prep
The synthesis question — this is what ties the whole module together:
-
“How would you safely integrate an arbitrary ERC-20 token into a lending protocol?”
- Good answer: “Use SafeERC20, balance-before-after for deposits, normalize decimals, check for reentrancy”
- Great answer: “First decide if we’re permissionless or curated. If permissionless: SafeERC20, balance-before-after, reentrancy guard, decimal normalization via
token.decimals(), guard against zero transfers. If curated: still use SafeERC20, but we can skip balance-before-after for tokens we’ve verified. Either way, test with fee-on-transfer, rebasing, and USDT mocks. I’d also check if the token is upgradeable or pausable — that affects our risk model. I’d run through the 13-point token evaluation checklist before listing.”
-
“Walk me through your token evaluation process for a new collateral asset.”
- Good answer: “Check decimals, see if it’s upgradeable, look for weird behaviors”
- Great answer: “I have a 13-point checklist: return values, fee-on-transfer, rebasing, decimals, upgradeability, pausability, blacklistability, ERC-777 hooks, zero-transfer behavior, multiple entry points, flash-mintability, supply inflation, and approve race conditions. For a curated protocol, I’d write a Foundry fork test against the real deployed token to verify assumptions. For permissionless, I’d build defensive code that handles all 13 cases.”
Interview Red Flags — signals of outdated or shallow knowledge:
- 🚩 Not knowing what SafeERC20 is or why it’s needed
- 🚩 Never heard of fee-on-transfer tokens or the balance-before-after pattern
- 🚩 Treating all tokens as 18 decimals
- 🚩 Unaware that USDC/USDT are upgradeable, pausable, and blacklistable
- 🚩 Not knowing about ERC-777 reentrancy vectors
- 🚩 No systematic approach to token evaluation (ad-hoc vs checklist)
Pro tip: When interviewing, mention the Weird ERC-20 catalog by name and the 13-point evaluation checklist approach — it shows you think systematically about token integration, not just “use SafeERC20 and hope for the best.”
📖 How to Study Token Integration in Production
- Start with the token interface — Look for
using SafeERC20 for IERC20or custom token interfaces - Follow the money — Trace every
safeTransfer,safeTransferFromcall. Map who sends tokens where - Check decimal handling — Search for
decimals(),10**, and scaling factors - Look for guards — Reentrancy protection, zero-amount checks, allowance management
- Read the tests — Production test suites often include weird-token mocks that reveal what the team considered
Recommended study order:
| Order | Protocol | What to study | Key file |
|---|---|---|---|
| 1 | Solmate ERC20 | Minimal ERC20 — understand the base | ERC20.sol (180 lines) |
| 2 | Uniswap V2 Pair | Balance-before-after in swap() and mint() | Lines 159-187 |
| 3 | Aave V3 SupplyLogic | SafeERC20, decimal normalization, aToken minting | Full file |
| 4 | Compound V3 Comet | Curated approach, scaling, immutable config | supply() and withdraw() |
| 5 | OpenZeppelin SafeERC20 | How low-level calls handle missing return values | Full file (~60 lines) |
📋 Summary: Advanced Token Behaviors & Protocol Design
✓ Covered:
- ERC-777 hooks — reentrancy through token transfers, not just ETH sends (imBTC, Hundred Finance exploits)
- Upgradeable tokens — USDC/USDT behind proxies, behavior can change post-deployment
- Pausable & blacklistable tokens — OFAC sanctions, Tornado Cash freezing, emergency withdrawal patterns
- Token supply mechanics — inflationary (reward emissions), deflationary (burn-on-transfer), elastic (rebasing)
- Reward-per-token accumulator pattern (Synthetix StakingRewards) — used everywhere in DeFi
- Flash-mintable tokens — DAI
flashMint(), never trust current-block balances - WETH — why it exists, V2/V3 wrapping vs V4 native ETH support
- Token listing strategies — permissionless (Uniswap) vs curated (Aave) vs hybrid (Euler V2, Morpho)
- Token evaluation checklist — 13-point assessment for integrating any new token
- Build exercise — putting it all together in a Foundry test suite
Internalized patterns: Always use SafeERC20 (no reason not to). Use balance-before-after for untrusted tokens (never trust amount parameters). Normalize decimals dynamically via token.decimals(). Guard against reentrancy on ALL token transfers (ERC-777 hooks, not just ETH sends). Design for weird tokens early (permissionless with full checks vs curated allowlist). Account for operational risks (pause, blacklist, upgrade). Use the 13-point evaluation checklist systematically. Recognize the reward-per-token accumulator pattern (rewardPerToken, feeGrowthGlobal, liquidityIndex) for proportional distribution without iteration.
Next: Module 2 (AMMs from First Principles).
🔗 Cross-Module Concept Links
Building on Part 1
| Module | Concept | How It Connects |
|---|---|---|
| ← Module 1: Modern Solidity | Custom errors | Token transfer failure revert data — InsufficientBalance() over string messages |
| ← Module 1: Modern Solidity | unchecked blocks | Gas-optimized balance math where underflow is impossible (post-require) |
| ← Module 1: Modern Solidity | UDVTs | Prevent mixing up token amounts with share amounts — type Shares is uint256 |
| ← Module 2: EVM Changes | Transient storage | Reentrancy guards for ERC-777 hook protection — TSTORE/TLOAD pattern |
| ← Module 3: Token Approvals | Permit (EIP-2612) | Gasless approve built on the approval mechanics covered in this module |
| ← Module 3: Token Approvals | Permit2 | Universal approval manager — extends the approve/transferFrom pattern |
| ← Module 5: Foundry | Fork testing | Test against real mainnet tokens (USDC, USDT, WETH) — catch behaviors mocks miss |
| ← Module 5: Foundry | Fuzz testing | Randomized token amounts and decimal values to catch edge cases |
| ← Module 6: Proxy Patterns | Upgradeable proxies | USDC/USDT are proxy tokens — same storage layout and upgrade mechanics from Module 6 |
Forward to Part 2
| Module | Token Pattern | Application |
|---|---|---|
| → M2: AMMs | Balance-before-after | V2’s swap() uses balance checks, not transfer amounts — handles fee-on-transfer |
| → M2: AMMs | WETH in routers | V2/V3 Router wraps ETH → WETH; V4 handles native ETH via flash accounting |
| → M3: Oracles | Decimal normalization | Combining token amounts with price feeds requires dynamic decimals() handling |
| → M4: Lending | SafeERC20 everywhere | Aave V3 supply/borrow/repay all use SafeERC20, decimal normalization via reserveDecimals |
| → M4: Lending | Token listing as risk | Collateral token properties (decimals, pausability) directly affect lending risk |
| → M5: Flash Loans | Flash-mintable tokens | DAI flashMint() and flash loan callbacks as reentrancy vectors |
| → M6: Stablecoins & CDPs | Pausable/blacklistable | USDC/USDT freeze risk directly impacts stablecoin protocol design |
| → M7: Vaults & Yield | Reward-per-token | Synthetix StakingRewards pattern reappears in vault yield distribution and gauge systems |
| → M7: Vaults & Yield | Rebasing tokens | ERC-4626 shares/assets pattern solves rebasing token accounting |
| → M8: DeFi Security | Token attack vectors | ERC-777 reentrancy, flash mint oracle manipulation, fee-on-transfer accounting bugs |
| → M9: Integration | Full token integration | Capstone requires handling all token edge cases in a complete protocol |
📖 Production Study Order
Study these codebases in order — each builds on the previous one’s patterns:
| # | Repository | Why Study This | Key Files |
|---|---|---|---|
| 1 | OpenZeppelin ERC20 | The canonical implementation — understand the _update() hook, virtual functions, and how every other token inherits or deviates from this | ERC20.sol (_update, _approve, _spendAllowance), extensions/ |
| 2 | Solmate ERC20 | Gas-optimized alternative — compare with OZ to understand which safety checks are worth the gas and which are ceremony. No virtual _update() hook | src/tokens/ERC20.sol (compare transfer, approve with OZ) |
| 3 | Weird ERC-20 Tokens | Catalog of every non-standard ERC-20 behavior — fee-on-transfer, missing return values, rebasing, pausable, blocklist. Every integrating protocol must handle these | README.md (the catalog itself), individual token implementations |
| 4 | USDT TetherToken | The most integrated non-standard token — missing return values on transfer/approve, non-zero-to-non-zero approval restriction. Why SafeERC20 exists | TetherToken.sol (compare transfer signature with ERC-20 spec) |
| 5 | WETH9 | Canonical wrapped ETH — deposit/withdraw pattern, fallback for implicit wrapping. Every DeFi router integrates this | WETH9.sol (deposit, withdraw, fallback) |
| 6 | Uniswap V2 Pair | Balance-before-after pattern in production — swap() reads actual balances instead of trusting transfer amounts; skim() and sync() for balance recovery | UniswapV2Pair.sol (swap, _update, skim, sync) |
| 7 | Aave V3 AToken | Rebasing token via scaled balances — balanceOf() returns scaledBalance * liquidityIndex, not stored balance. The index-based accounting pattern used across all lending protocols | AToken.sol, ScaledBalanceTokenBase.sol |
Reading strategy: Start with OZ ERC20 (1) — read _update() and understand the hook pattern all extensions build on. Compare with Solmate (2) to see what a minimal implementation looks like without hooks. Study the weird-erc20 catalog (3) to map the full landscape of non-standard behaviors. Then read USDT (4) to see the real-world token that forced the creation of SafeERC20. WETH9 (5) is short and shows the ETH wrapping pattern every router uses. Uniswap V2 Pair (6) shows how production protocols defend against fee-on-transfer tokens via balance-before-after. Aave’s AToken (7) shows the rebasing/scaled-balance pattern you’ll encounter in lending and yield protocols.
📚 Resources
Reference implementations:
- OpenZeppelin ERC20 (v5.x)
- OpenZeppelin SafeERC20
- Weird ERC-20 catalog
- WETH9 source (Etherscan verified)
- Solmate ERC20 — gas-optimized reference
Specifications:
- EIP-20 (ERC-20)
- EIP-777 — hooks-enabled token standard
- EIP-2612 (Permit)
- EIP-4626 (Tokenized Vaults) — shares/assets standard (Module 7)
Production examples:
- Uniswap V2 Pair.sol — balance-before-after pattern in
swap() - Aave V3 Pool.sol — SafeERC20 usage, wstETH wrapping, decimal normalization
- Compound V3 Comet.sol — curated allowlist approach, scaling factors
- Synthetix StakingRewards — reward-per-token pattern
- USDC proxy implementation — upgradeable, pausable, blacklistable
Security reading:
- MixBytes — DeFi patterns: ERC20 token transfers
- Integrating arbitrary ERC-20 tokens (cheat sheet)
- Hundred Finance postmortem — ERC-777 reentrancy ($7M)
- SushiSwap MISO incident — malicious token ($3M)
- imBTC/Uniswap V1 exploit — ERC-777 hook reentrancy (~$300K)
- USDC depeg analysis (SVB crisis) — centralized stablecoin risk
- Tornado Cash OFAC sanctions — address blacklisting in practice
Hybrid/permissionless architectures:
- Euler V2 documentation — permissionless vault creation with isolated risk
- Morpho Blue documentation — permissionless lending markets with per-market risk parameters
Risk frameworks:
- Aave risk documentation
- Gauntlet risk platform — quantitative risk assessment
- MakerDAO collateral onboarding (MIP6)
Navigation: ← Part 1 Module 7: Deployment | Module 2: AMMs →