Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Advanced Token Behaviors & Protocol Design


💡 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:

  1. AMMs (Module 2): Uniswap V2’s “pull” pattern — users approve the Router, Router calls transferFrom to move tokens into Pair contracts. V4 replaces this with flash accounting
  2. Lending (Module 4): Users approve the Pool contract to pull collateral. Aave V3 and Compound V3 both use this for deposits
  3. Vaults (Module 7): ERC-4626 vaults call transferFrom on deposit — the entire vault standard is built on this two-step pattern
  4. 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 when decimals > 18. Always guard: require(decimals <= 18) or handle both directions with decimals > 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 mulDiv for 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/_afterTokenTransfer hooks with a single _update function — this is a design change you’ll encounter when reading older protocol code vs newer code)
  • How approve() and transferFrom() interact through the _allowances mapping
  • The _spendAllowance() helper and its special case for type(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 bool
  • forceApprove — replaces the deprecated safeApprove, 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:

  1. Read the interface firstIERC20.sol defines what tokens should do
  2. Read safeTransfer — See how it uses functionCallWithValue to handle missing return values
  3. Read forceApprove — Understand the USDT “approve to zero first” workaround
  4. Compare with Solmate’s SafeTransferLibSolmate’s version skips address.code.length checks for gas savings (trade-off: no empty address detection)
  5. 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 use token.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:

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. Use forceApprove which 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:

  1. “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 balanceOf before and after transferFrom, 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 with FeeOnTransferToken mocks is essential.”

Interview Red Flags:

  • 🚩 Not knowing what SafeERC20 is or why token.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:

  1. 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
  2. Lending (Module 4): Rebasing tokens break collateral accounting — Aave V3 wraps stETH to wstETH before accepting as collateral
  3. 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)
  • received vs amount → 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 tokensToSend hook was called during tokenToEthSwap, 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 — including transferFrom calls 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 has pause)
  • Blacklist: The issuer can block specific addresses from sending/receiving tokens (USDC has blacklist(address), USDT has addBlackList(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:

  1. “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:

  • feeGrowthGlobal in Uniswap V3 (fee distribution to LPs)
  • liquidityIndex in Aave V3 (interest distribution to depositors)
  • rewardPerToken in 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:

  1. Yield farming (Module 7): Synthetix StakingRewards is the template — Sushi MasterChef, Convex BaseRewardPool, Yearn gauges all use the same formula
  2. Lending (Module 4): Aave V3’s interest accrual uses a similar accumulator pattern (liquidityIndex) to distribute interest without iterating over users
  3. 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 balanceOf in 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:

  1. “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) or CurrencyLibrary.NATIVE represents 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:

ProtocolApproachWho decidesToken supportRisk isolation
Uniswap V2/V3/V4PermissionlessAnyoneAny ERC-20Per-pool
Aave V3CuratedGovernance~30 assetsShared (E-Mode/Isolation helps)
Compound V3CuratedGovernance~5-10 per marketPer-market
Euler V2HybridVault creatorsAnyPer-vault
Morpho BlueHybridMarket creatorsAny pairPer-market
MakerDAOCuratedGovernance~20 collateralsPer-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.

#CheckWhat to look forImpact if missed
1Return valuesDoes transfer/transferFrom return bool? (USDT doesn’t)Silent failures → fund loss
2Fee-on-transferDoes the received amount differ from the sent amount?Accounting drift → insolvency
3RebasingDoes balanceOf change without transfers? (stETH, AMPL, OHM)Stale balance accounting → incorrect withdrawals
4DecimalsHow many? (6, 8, 18, or something else?)Overflow/underflow, wrong exchange rates
5UpgradeableIs it behind a proxy? (USDC, USDT)Behavior can change post-deployment
6PausableCan the issuer pause all transfers? (USDC, USDT)Stuck funds, broken liquidations
7BlacklistableCan specific addresses be blocked? (USDC, USDT)Protocol address frozen → all funds stuck
8ERC-777 hooksDoes it have transfer hooks? (imBTC)Reentrancy via tokensReceived callback
9Zero transferDoes it revert on zero-amount transfer? (LEND)Batch operations fail
10Multiple addressesDoes it have proxy aliases or multiple entry points?Address-based dedup fails
11Flash-mintableCan supply be inflated atomically? (DAI)Balance-based governance/pricing exploitable
12Max supply / inflationWhat’s the emission schedule?Dilution affects collateral value over time
13Approve race conditionDoes 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() and withdraw() 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:

  1. “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.”
  2. “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

  1. Start with the token interface — Look for using SafeERC20 for IERC20 or custom token interfaces
  2. Follow the money — Trace every safeTransfer, safeTransferFrom call. Map who sends tokens where
  3. Check decimal handling — Search for decimals(), 10**, and scaling factors
  4. Look for guards — Reentrancy protection, zero-amount checks, allowance management
  5. Read the tests — Production test suites often include weird-token mocks that reveal what the team considered

Recommended study order:

OrderProtocolWhat to studyKey file
1Solmate ERC20Minimal ERC20 — understand the baseERC20.sol (180 lines)
2Uniswap V2 PairBalance-before-after in swap() and mint()Lines 159-187
3Aave V3 SupplyLogicSafeERC20, decimal normalization, aToken mintingFull file
4Compound V3 CometCurated approach, scaling, immutable configsupply() and withdraw()
5OpenZeppelin SafeERC20How low-level calls handle missing return valuesFull 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).


Building on Part 1

ModuleConceptHow It Connects
← Module 1: Modern SolidityCustom errorsToken transfer failure revert data — InsufficientBalance() over string messages
← Module 1: Modern Solidityunchecked blocksGas-optimized balance math where underflow is impossible (post-require)
← Module 1: Modern SolidityUDVTsPrevent mixing up token amounts with share amounts — type Shares is uint256
← Module 2: EVM ChangesTransient storageReentrancy guards for ERC-777 hook protection — TSTORE/TLOAD pattern
← Module 3: Token ApprovalsPermit (EIP-2612)Gasless approve built on the approval mechanics covered in this module
← Module 3: Token ApprovalsPermit2Universal approval manager — extends the approve/transferFrom pattern
← Module 5: FoundryFork testingTest against real mainnet tokens (USDC, USDT, WETH) — catch behaviors mocks miss
← Module 5: FoundryFuzz testingRandomized token amounts and decimal values to catch edge cases
← Module 6: Proxy PatternsUpgradeable proxiesUSDC/USDT are proxy tokens — same storage layout and upgrade mechanics from Module 6

Forward to Part 2

ModuleToken PatternApplication
→ M2: AMMsBalance-before-afterV2’s swap() uses balance checks, not transfer amounts — handles fee-on-transfer
→ M2: AMMsWETH in routersV2/V3 Router wraps ETH → WETH; V4 handles native ETH via flash accounting
→ M3: OraclesDecimal normalizationCombining token amounts with price feeds requires dynamic decimals() handling
→ M4: LendingSafeERC20 everywhereAave V3 supply/borrow/repay all use SafeERC20, decimal normalization via reserveDecimals
→ M4: LendingToken listing as riskCollateral token properties (decimals, pausability) directly affect lending risk
→ M5: Flash LoansFlash-mintable tokensDAI flashMint() and flash loan callbacks as reentrancy vectors
→ M6: Stablecoins & CDPsPausable/blacklistableUSDC/USDT freeze risk directly impacts stablecoin protocol design
→ M7: Vaults & YieldReward-per-tokenSynthetix StakingRewards pattern reappears in vault yield distribution and gauge systems
→ M7: Vaults & YieldRebasing tokensERC-4626 shares/assets pattern solves rebasing token accounting
→ M8: DeFi SecurityToken attack vectorsERC-777 reentrancy, flash mint oracle manipulation, fee-on-transfer accounting bugs
→ M9: IntegrationFull token integrationCapstone 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:

#RepositoryWhy Study ThisKey Files
1OpenZeppelin ERC20The canonical implementation — understand the _update() hook, virtual functions, and how every other token inherits or deviates from thisERC20.sol (_update, _approve, _spendAllowance), extensions/
2Solmate ERC20Gas-optimized alternative — compare with OZ to understand which safety checks are worth the gas and which are ceremony. No virtual _update() hooksrc/tokens/ERC20.sol (compare transfer, approve with OZ)
3Weird ERC-20 TokensCatalog of every non-standard ERC-20 behavior — fee-on-transfer, missing return values, rebasing, pausable, blocklist. Every integrating protocol must handle theseREADME.md (the catalog itself), individual token implementations
4USDT TetherTokenThe most integrated non-standard token — missing return values on transfer/approve, non-zero-to-non-zero approval restriction. Why SafeERC20 existsTetherToken.sol (compare transfer signature with ERC-20 spec)
5WETH9Canonical wrapped ETH — deposit/withdraw pattern, fallback for implicit wrapping. Every DeFi router integrates thisWETH9.sol (deposit, withdraw, fallback)
6Uniswap V2 PairBalance-before-after pattern in production — swap() reads actual balances instead of trusting transfer amounts; skim() and sync() for balance recoveryUniswapV2Pair.sol (swap, _update, skim, sync)
7Aave V3 ATokenRebasing token via scaled balances — balanceOf() returns scaledBalance * liquidityIndex, not stored balance. The index-based accounting pattern used across all lending protocolsAToken.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:

Specifications:

Production examples:

Security reading:

Hybrid/permissionless architectures:

Risk frameworks:


Navigation: ← Part 1 Module 7: Deployment | Module 2: AMMs →