P09: ERC-20 Token from Scratch

P09: ERC-20 Token from Scratch

Project Overview

Attribute Value
Main Language Solidity
Alternative Languages Vyper, Huff
Difficulty Intermediate
Coolness Level Level 3: Genuinely Clever
Business Potential The “Open Core” Infrastructure
Knowledge Area Smart Contracts / Token Standards
Main Book “Mastering Ethereum” by Andreas M. Antonopoulos & Gavin Wood

Learning Objectives

By completing this project, you will:

  1. Understand the ERC-20 token standard at a specification level, including all required and optional functions
  2. Master Solidity state management using mappings for balance tracking and nested mappings for allowances
  3. Implement the approve/transferFrom pattern understanding why delegation requires a two-step process
  4. Learn event emission and indexing for off-chain integration and transaction tracking
  5. Understand integer overflow protection and how Solidity 0.8+ changed the security landscape

Deep Theoretical Foundation

The Problem: How Do You Represent Value On-Chain?

Before ERC-20, every token on Ethereum was a custom implementation. If you wanted to list a token on an exchange, the exchange needed custom code for each token. If you wanted to build a wallet, you needed to understand each token’s unique API.

ERC-20 solved this by defining a standard interface. Any contract implementing this interface can be used by any wallet, exchange, or DeFi protocol without modifications. This is the power of composability—the ability to build complex systems from standardized building blocks.

Why ERC-20 Matters for Web3

ERC-20 is arguably the most important standard in all of Web3:

Impact Area Example
ICO Boom (2017) Over $5 billion raised using ERC-20 tokens
DeFi Foundation Uniswap, Aave, Compound all built on ERC-20
Stablecoins USDT, USDC, DAI are all ERC-20 tokens
Governance Most DAO voting tokens are ERC-20
Wrapped Assets WETH, WBTC bring external assets to Ethereum

Understanding ERC-20 means understanding how value moves on Ethereum.

The ERC-20 Standard Specification

ERC-20 (Ethereum Request for Comment 20) was proposed by Fabian Vogelsteller in November 2015 and finalized as EIP-20. It defines:

Required Functions

function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
function totalSupply() public view returns (uint256)
function balanceOf(address _owner) public view returns (uint256 balance)
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)

Required Events

event Transfer(address indexed _from, address indexed _to, uint256 _value)
event Approval(address indexed _owner, address indexed _spender, uint256 _value)

Understanding Token Decimals

Ethereum and Solidity don’t support floating-point numbers. So how do you represent 0.5 tokens?

The answer is decimals. A token with 18 decimals represents values as integers multiplied by 10^18:

1 token     = 1,000,000,000,000,000,000 (1e18) base units
0.5 tokens  = 500,000,000,000,000,000   (5e17) base units
0.001 token = 1,000,000,000,000,000     (1e15) base units

Most tokens use 18 decimals to match ETH. Some notable exceptions:

  • USDT/USDC: 6 decimals (matching traditional finance)
  • WBTC: 8 decimals (matching Bitcoin)

This is purely a display convention. The contract only ever deals with integers.

The Balance Mapping

At its core, a token is just a mapping from addresses to balances:

mapping(address => uint256) private _balances;

This is the entire “state” of the token. Every transfer is just updating two entries in this mapping:

_balances[from] -= amount;
_balances[to]   += amount;

The elegance is in the simplicity. There’s no “coin” object, no serial numbers, no history—just a mapping of who owns how much.

The Allowance Mechanism: Why Two Steps?

The approve/transferFrom pattern is one of the most important patterns in Ethereum:

// Step 1: Alice approves DEX to spend her tokens
token.approve(dex_address, 100);

// Step 2: DEX transfers tokens from Alice
token.transferFrom(alice, dex, 100);

Why can’t contracts just call transfer()?

When you call a function on a contract, msg.sender is your address. But when a DEX contract calls token.transfer(), msg.sender is the DEX’s address, not yours. The DEX can’t spend your tokens directly.

The solution: delegated spending. You approve a contract to spend up to N tokens on your behalf. The contract can then call transferFrom() to move your tokens.

This creates a nested mapping:

mapping(address => mapping(address => uint256)) private _allowances;

// _allowances[owner][spender] = amount
// "How much can spender spend on behalf of owner?"

The Approve Race Condition

There’s a subtle security issue with the naive approve pattern:

  1. Alice approves Bob to spend 100 tokens
  2. Alice changes her mind and calls approve(bob, 50) to reduce it
  3. Bob sees the pending transaction and front-runs it
  4. Bob calls transferFrom(alice, bob, 100) (using the old allowance)
  5. Alice’s approve(bob, 50) executes
  6. Bob calls transferFrom(alice, bob, 50) (using the new allowance)
  7. Bob got 150 tokens instead of the intended maximum of 100

Mitigation strategies:

  1. Always set allowance to 0 first, then to the new value
  2. Use increaseAllowance() and decreaseAllowance() instead of approve()
  3. Use permit (EIP-2612) for signature-based approvals

Events: The Off-Chain Bridge

Events are how smart contracts communicate with the outside world:

event Transfer(address indexed from, address indexed to, uint256 value);

The indexed keyword makes these parameters searchable in logs. This enables:

  • Wallets: Showing your transaction history
  • Block Explorers: Displaying token transfers
  • Indexers: Building databases of all transfers
  • DeFi protocols: Reacting to token movements

Events don’t change state—they’re purely informational. But without them, understanding what happened on-chain would require re-executing every transaction.

Integer Overflow: The Silent Killer

Before Solidity 0.8, arithmetic operations could silently overflow:

// Solidity 0.7 and earlier
uint256 max = type(uint256).max;  // 2^256 - 1
uint256 overflow = max + 1;        // Silently becomes 0!

uint256 underflow = 0 - 1;         // Silently becomes 2^256 - 1!

This was catastrophic for tokens. Imagine:

  • User has 100 tokens
  • User transfers 101 tokens
  • balance - amount underflows to a huge number
  • User now has more tokens than the total supply!

Historical solutions:

  1. SafeMath library: Wrapped arithmetic with overflow checks
  2. Solidity 0.8+: Built-in overflow protection (reverts on overflow)

The batchOverflow attack (2018) exploited exactly this vulnerability, allowing attackers to mint unlimited tokens on vulnerable contracts.

Storage Layout and Gas Costs

Understanding how Solidity stores data helps you write efficient contracts:

Storage Slot 0: _totalSupply (uint256)
Storage Slot 1: _balances mapping base
Storage Slot 2: _allowances mapping base

For mapping lookups:
_balances[address] is at: keccak256(address . slot)
_allowances[owner][spender] is at: keccak256(spender . keccak256(owner . slot))

Gas costs (approximate, 2024):

  • First write to a storage slot: 20,000 gas
  • Subsequent writes: 5,000 gas
  • Reads: 2,100 gas (cold) / 100 gas (warm)

This is why ERC-20 transfers cost around 50,000 gas: two storage writes (sender and recipient balances).


Complete Project Specification

Functional Requirements

  1. Token Metadata
    • Fixed name, symbol, and decimals
    • Immutable total supply (for basic version)
    • View functions for all metadata
  2. Balance Management
    • Track balances via mapping
    • Support balance queries for any address
    • Prevent transfers exceeding balance
  3. Transfer Functions
    • Direct transfer between addresses
    • Delegated transfer via allowances
    • Proper balance and allowance checks
  4. Allowance System
    • Set allowances via approve
    • Query allowances via allowance
    • Safe increase/decrease allowance functions
  5. Events
    • Emit Transfer on all balance changes
    • Emit Approval on all allowance changes
    • Use indexed parameters for filtering
  6. Security
    • Overflow protection (Solidity 0.8+ or SafeMath)
    • Zero-address checks
    • Reentrancy considerations

Non-Functional Requirements

  • Gas Efficiency: Transfer < 60,000 gas
  • Correctness: Pass OpenZeppelin ERC-20 test suite
  • Compatibility: Work with Uniswap, MetaMask, Etherscan
  • Testability: 100% test coverage

Command-Line Interface (Using Foundry)

# Deploy token
$ forge create src/MyToken.sol:MyToken \
    --constructor-args "MyToken" "MTK" 1000000000000000000000000 \
    --rpc-url $RPC_URL --private-key $PRIVATE_KEY
Deployed to: 0x1234...

# Check balance
$ cast call 0x1234... "balanceOf(address)" 0xYourAddress
1000000000000000000000000  # 1M tokens with 18 decimals

# Transfer tokens
$ cast send 0x1234... "transfer(address,uint256)" 0xAlice 1000000000000000000 \
    --rpc-url $RPC_URL --private-key $PRIVATE_KEY
Transaction: 0xabc...

# Approve spender
$ cast send 0x1234... "approve(address,uint256)" 0xSpender 1000000000000000000000 \
    --rpc-url $RPC_URL --private-key $PRIVATE_KEY

# Check allowance
$ cast call 0x1234... "allowance(address,address)" 0xOwner 0xSpender
1000000000000000000000

# Transfer from (as approved spender)
$ cast send 0x1234... "transferFrom(address,address,uint256)" \
    0xOwner 0xRecipient 500000000000000000000 \
    --rpc-url $RPC_URL --private-key $PRIVATE_KEY

Solution Architecture

File Structure

src/
├── ERC20.sol              # Core ERC-20 implementation
├── interfaces/
│   └── IERC20.sol         # Standard ERC-20 interface
├── extensions/
│   ├── ERC20Burnable.sol  # Burn functionality
│   ├── ERC20Capped.sol    # Maximum supply cap
│   └── ERC20Permit.sol    # EIP-2612 permit
└── MyToken.sol            # Your token contract

test/
├── ERC20.t.sol            # Unit tests
├── ERC20.invariants.sol   # Invariant tests
└── ERC20.integration.sol  # Integration tests

script/
├── Deploy.s.sol           # Deployment script
└── Interact.s.sol         # Interaction scripts

Interface Definition

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    // Events
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    // Getters
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function allowance(address owner, address spender) external view returns (uint256);

    // Actions
    function transfer(address to, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

Core Data Structures

contract ERC20 is IERC20 {
    // Token metadata (immutable for gas savings)
    string private _name;
    string private _symbol;
    uint8 private constant _decimals = 18;

    // Core state
    uint256 private _totalSupply;
    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) private _allowances;

    // Constructor
    constructor(string memory name_, string memory symbol_, uint256 initialSupply) {
        _name = name_;
        _symbol = symbol_;
        _mint(msg.sender, initialSupply);
    }
}

Key Functions Explained

The _transfer Internal Function

function _transfer(address from, address to, uint256 amount) internal virtual {
    require(from != address(0), "ERC20: transfer from the zero address");
    require(to != address(0), "ERC20: transfer to the zero address");

    uint256 fromBalance = _balances[from];
    require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");

    unchecked {
        // Safe because we checked fromBalance >= amount
        _balances[from] = fromBalance - amount;
        // Safe because totalSupply is capped and sum of balances = totalSupply
        _balances[to] += amount;
    }

    emit Transfer(from, to, amount);
}

Why use unchecked?

In Solidity 0.8+, arithmetic is checked by default. But in _transfer, we’ve already verified that:

  1. fromBalance >= amount (subtraction is safe)
  2. _balances[to] + amount <= totalSupply (addition is safe, since total is constant)

Using unchecked saves gas (~100 gas per operation) when we know the math is safe.

The approve Function

function approve(address spender, uint256 amount) public virtual returns (bool) {
    address owner = msg.sender;
    require(spender != address(0), "ERC20: approve to the zero address");

    _allowances[owner][spender] = amount;
    emit Approval(owner, spender, amount);
    return true;
}

The transferFrom Function

function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) {
    address spender = msg.sender;

    // Check and update allowance
    uint256 currentAllowance = _allowances[from][spender];
    if (currentAllowance != type(uint256).max) {
        require(currentAllowance >= amount, "ERC20: insufficient allowance");
        unchecked {
            _allowances[from][spender] = currentAllowance - amount;
        }
    }

    _transfer(from, to, amount);
    return true;
}

Why check for max uint256?

When allowance is set to type(uint256).max, it’s treated as “infinite approval”—the allowance is never decremented. This saves gas for protocols that need unlimited access (like some DEXs), but requires trust.


Phased Implementation Guide

Phase 1: Project Setup and Interface

Goal: Set up the development environment and define the interface.

Tasks:

  1. Install Foundry (curl -L https://foundry.paradigm.xyz | bash)
  2. Create new project (forge init erc20-token)
  3. Create the IERC20 interface
  4. Create the basic contract structure

Validation:

$ forge build
# Should compile without errors

Hints if stuck:

  • Use pragma solidity ^0.8.20; for latest features
  • Interface functions should be external
  • Don’t forget the indexed keyword on event parameters

Phase 2: Token Metadata

Goal: Implement name, symbol, decimals, and totalSupply.

Tasks:

  1. Add private state variables for metadata
  2. Implement getter functions
  3. Set up constructor with initial values
  4. Write tests for getters

Validation:

function testMetadata() public {
    assertEq(token.name(), "MyToken");
    assertEq(token.symbol(), "MTK");
    assertEq(token.decimals(), 18);
}

Hints if stuck:

  • Use string memory for name and symbol
  • Decimals is typically uint8 with value 18
  • Consider making decimals a constant for gas savings

Phase 3: Balance Tracking and Transfer

Goal: Implement balanceOf and transfer.

Tasks:

  1. Add the _balances mapping
  2. Implement balanceOf()
  3. Implement internal _transfer() function
  4. Implement public transfer() function
  5. Implement _mint() for initial supply
  6. Write comprehensive transfer tests

Validation:

function testTransfer() public {
    uint256 initialBalance = token.balanceOf(address(this));
    token.transfer(alice, 100);

    assertEq(token.balanceOf(address(this)), initialBalance - 100);
    assertEq(token.balanceOf(alice), 100);
}

function testTransferInsufficientBalance() public {
    vm.expectRevert("ERC20: transfer amount exceeds balance");
    token.transfer(alice, type(uint256).max);
}

Hints if stuck:

  • Remember to emit the Transfer event
  • Check for zero-address transfers
  • Use require for balance checks
  • The Transfer event from minting has from as address(0)

Phase 4: Allowance System

Goal: Implement approve, allowance, transferFrom.

Tasks:

  1. Add the _allowances nested mapping
  2. Implement approve()
  3. Implement allowance()
  4. Implement transferFrom()
  5. Implement _spendAllowance() internal helper
  6. Write tests for the complete flow

Validation:

function testApproveAndTransferFrom() public {
    token.transfer(alice, 1000);

    vm.prank(alice);
    token.approve(bob, 500);

    assertEq(token.allowance(alice, bob), 500);

    vm.prank(bob);
    token.transferFrom(alice, charlie, 300);

    assertEq(token.balanceOf(alice), 700);
    assertEq(token.balanceOf(charlie), 300);
    assertEq(token.allowance(alice, bob), 200);
}

Hints if stuck:

  • msg.sender in transferFrom is the spender, not the owner
  • Remember to update the allowance after spending
  • Don’t forget the Approval event
  • Handle the infinite approval case (type(uint256).max)

Phase 5: Safe Allowance Functions

Goal: Implement increaseAllowance and decreaseAllowance.

Tasks:

  1. Implement increaseAllowance()
  2. Implement decreaseAllowance()
  3. Write tests for edge cases
  4. Document the race condition mitigation

Validation:

function testIncreaseAllowance() public {
    token.approve(alice, 100);
    token.increaseAllowance(alice, 50);
    assertEq(token.allowance(address(this), alice), 150);
}

function testDecreaseAllowance() public {
    token.approve(alice, 100);
    token.decreaseAllowance(alice, 30);
    assertEq(token.allowance(address(this), alice), 70);
}

function testDecreaseAllowanceBelowZero() public {
    token.approve(alice, 100);
    vm.expectRevert("ERC20: decreased allowance below zero");
    token.decreaseAllowance(alice, 150);
}

Hints if stuck:

  • These are not part of the ERC-20 standard but are commonly included
  • Use unchecked carefully with proper validation
  • Consider what happens at boundary values

Phase 6: Events and Testing

Goal: Ensure proper event emission and comprehensive testing.

Tasks:

  1. Verify all events are emitted correctly
  2. Write event-checking tests
  3. Add fuzz tests for edge cases
  4. Add invariant tests

Validation:

function testTransferEmitsEvent() public {
    vm.expectEmit(true, true, false, true);
    emit Transfer(address(this), alice, 100);
    token.transfer(alice, 100);
}

function testFuzzTransfer(address to, uint256 amount) public {
    vm.assume(to != address(0));
    vm.assume(amount <= token.balanceOf(address(this)));

    uint256 balanceBefore = token.balanceOf(to);
    token.transfer(to, amount);
    assertEq(token.balanceOf(to), balanceBefore + amount);
}

Hints if stuck:

  • Use vm.expectEmit to verify events
  • Use vm.assume in fuzz tests to filter invalid inputs
  • Test boundary conditions: 0, 1, max values

Phase 7: Deployment and Integration

Goal: Deploy to testnet and verify integration.

Tasks:

  1. Write deployment script
  2. Deploy to Sepolia testnet
  3. Verify on Etherscan
  4. Test with MetaMask
  5. Test with Uniswap (optional)

Validation:

# Deploy
$ forge script script/Deploy.s.sol --rpc-url $SEPOLIA_RPC --broadcast

# Verify
$ forge verify-contract $ADDRESS src/MyToken.sol:MyToken \
    --chain sepolia --etherscan-api-key $ETHERSCAN_KEY

# Interact
$ cast call $ADDRESS "name()" --rpc-url $SEPOLIA_RPC
# Should return your token name

Hints if stuck:

  • Get Sepolia ETH from faucets
  • Etherscan verification requires matching compiler settings
  • Add the token to MetaMask using the contract address

Testing Strategy

Unit Tests

// test/ERC20.t.sol
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/ERC20.sol";

contract ERC20Test is Test {
    ERC20 public token;
    address public alice = address(0x1);
    address public bob = address(0x2);

    uint256 public constant INITIAL_SUPPLY = 1000000 * 10**18;

    function setUp() public {
        token = new ERC20("Test Token", "TEST", INITIAL_SUPPLY);
    }

    // Metadata tests
    function test_Name() public {
        assertEq(token.name(), "Test Token");
    }

    function test_Symbol() public {
        assertEq(token.symbol(), "TEST");
    }

    function test_Decimals() public {
        assertEq(token.decimals(), 18);
    }

    function test_TotalSupply() public {
        assertEq(token.totalSupply(), INITIAL_SUPPLY);
    }

    // Balance tests
    function test_InitialBalance() public {
        assertEq(token.balanceOf(address(this)), INITIAL_SUPPLY);
    }

    function test_BalanceOfZeroAddress() public {
        assertEq(token.balanceOf(address(0)), 0);
    }

    // Transfer tests
    function test_Transfer() public {
        assertTrue(token.transfer(alice, 1000));
        assertEq(token.balanceOf(alice), 1000);
        assertEq(token.balanceOf(address(this)), INITIAL_SUPPLY - 1000);
    }

    function test_TransferZero() public {
        assertTrue(token.transfer(alice, 0));
        assertEq(token.balanceOf(alice), 0);
    }

    function test_TransferInsufficientBalance() public {
        vm.expectRevert("ERC20: transfer amount exceeds balance");
        token.transfer(alice, INITIAL_SUPPLY + 1);
    }

    function test_TransferToZeroAddress() public {
        vm.expectRevert("ERC20: transfer to the zero address");
        token.transfer(address(0), 100);
    }

    // Approval tests
    function test_Approve() public {
        assertTrue(token.approve(alice, 1000));
        assertEq(token.allowance(address(this), alice), 1000);
    }

    function test_ApproveToZeroAddress() public {
        vm.expectRevert("ERC20: approve to the zero address");
        token.approve(address(0), 1000);
    }

    // TransferFrom tests
    function test_TransferFrom() public {
        token.approve(alice, 1000);

        vm.prank(alice);
        assertTrue(token.transferFrom(address(this), bob, 500));

        assertEq(token.balanceOf(bob), 500);
        assertEq(token.allowance(address(this), alice), 500);
    }

    function test_TransferFromInsufficientAllowance() public {
        token.approve(alice, 100);

        vm.prank(alice);
        vm.expectRevert("ERC20: insufficient allowance");
        token.transferFrom(address(this), bob, 500);
    }

    function test_TransferFromInfiniteAllowance() public {
        token.approve(alice, type(uint256).max);

        vm.prank(alice);
        token.transferFrom(address(this), bob, 1000);

        // Allowance should remain max
        assertEq(token.allowance(address(this), alice), type(uint256).max);
    }
}

Fuzz Tests

function testFuzz_Transfer(address to, uint256 amount) public {
    vm.assume(to != address(0));
    vm.assume(to != address(this));
    vm.assume(amount <= token.balanceOf(address(this)));

    uint256 senderBalanceBefore = token.balanceOf(address(this));
    uint256 receiverBalanceBefore = token.balanceOf(to);

    token.transfer(to, amount);

    assertEq(token.balanceOf(address(this)), senderBalanceBefore - amount);
    assertEq(token.balanceOf(to), receiverBalanceBefore + amount);
}

function testFuzz_TransferFrom(
    address from,
    address to,
    uint256 approval,
    uint256 amount
) public {
    vm.assume(from != address(0));
    vm.assume(to != address(0));
    vm.assume(from != to);
    vm.assume(amount <= approval);
    vm.assume(amount <= INITIAL_SUPPLY);

    // Setup: give 'from' some tokens
    token.transfer(from, INITIAL_SUPPLY);

    // Approve this contract to spend
    vm.prank(from);
    token.approve(address(this), approval);

    // Execute transferFrom
    token.transferFrom(from, to, amount);

    assertEq(token.balanceOf(to), amount);
}

Invariant Tests

// test/ERC20.invariants.sol
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/ERC20.sol";

contract ERC20Invariants is Test {
    ERC20 public token;
    Handler public handler;

    function setUp() public {
        token = new ERC20("Test", "TST", 1000000e18);
        handler = new Handler(token);
        targetContract(address(handler));
    }

    function invariant_TotalSupplyMatchesBalances() public {
        uint256 sumOfBalances = 0;
        address[] memory actors = handler.actors();

        for (uint256 i = 0; i < actors.length; i++) {
            sumOfBalances += token.balanceOf(actors[i]);
        }

        assertEq(token.totalSupply(), sumOfBalances);
    }

    function invariant_TotalSupplyNeverChanges() public {
        assertEq(token.totalSupply(), 1000000e18);
    }
}

contract Handler is Test {
    ERC20 public token;
    address[] public actors;

    constructor(ERC20 _token) {
        token = _token;
        actors.push(address(this));
    }

    function transfer(uint256 actorSeed, address to, uint256 amount) public {
        if (to == address(0)) to = address(1);

        address from = actors[actorSeed % actors.length];
        amount = bound(amount, 0, token.balanceOf(from));

        vm.prank(from);
        token.transfer(to, amount);

        _addActor(to);
    }

    function _addActor(address actor) internal {
        for (uint256 i = 0; i < actors.length; i++) {
            if (actors[i] == actor) return;
        }
        actors.push(actor);
    }
}

Common Pitfalls & Debugging

Pitfall 1: Forgetting Zero-Address Checks

Problem: Transferring to address(0) burns tokens unintentionally.

Symptom: Total supply becomes inconsistent with sum of balances.

Solution:

function transfer(address to, uint256 amount) public returns (bool) {
    require(to != address(0), "ERC20: transfer to the zero address");
    // ...
}

Pitfall 2: Event Emission Order

Problem: Emitting events before state changes can confuse indexers.

Symptom: Off-chain systems see inconsistent state.

Solution:

function transfer(address to, uint256 amount) public returns (bool) {
    // 1. Check
    require(_balances[msg.sender] >= amount);

    // 2. Effect (state change)
    _balances[msg.sender] -= amount;
    _balances[to] += amount;

    // 3. Interaction (event, external calls)
    emit Transfer(msg.sender, to, amount);
    return true;
}

Pitfall 3: Approval Race Condition

Problem: User tries to change allowance and gets front-run.

Symptom: Spender drains more than intended.

Solution:

// Instead of changing from 100 to 50 directly:
token.approve(spender, 0);     // First set to 0
token.approve(spender, 50);    // Then set to new value

// Or use safe increase/decrease:
function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
    _approve(msg.sender, spender, _allowances[msg.sender][spender] + addedValue);
    return true;
}

Pitfall 4: Integer Overflow (Pre-Solidity 0.8)

Problem: Balance subtraction underflows to huge number.

Symptom: User can transfer more than they have.

Solution:

// Solidity 0.8+ handles this automatically
// For 0.7 and earlier, use SafeMath:
using SafeMath for uint256;
_balances[from] = _balances[from].sub(amount);

Pitfall 5: Return Value Confusion

Problem: Some tokens don’t return bool from transfer.

Symptom: require(token.transfer(...)) fails on non-compliant tokens.

Solution:

// When interacting with unknown tokens, use low-level call:
(bool success, bytes memory data) = address(token).call(
    abi.encodeWithSelector(IERC20.transfer.selector, to, amount)
);
require(success && (data.length == 0 || abi.decode(data, (bool))));

// Or use OpenZeppelin's SafeERC20:
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(to, amount);

Pitfall 6: Missing Approval Event on TransferFrom

Problem: Not emitting Approval event when allowance changes.

Symptom: Off-chain systems have stale allowance data.

Solution:

function transferFrom(address from, address to, uint256 amount) public returns (bool) {
    _spendAllowance(from, msg.sender, amount);
    _transfer(from, to, amount);
    return true;
}

function _spendAllowance(address owner, address spender, uint256 amount) internal {
    uint256 currentAllowance = _allowances[owner][spender];
    if (currentAllowance != type(uint256).max) {
        require(currentAllowance >= amount, "ERC20: insufficient allowance");
        _approve(owner, spender, currentAllowance - amount);
        // _approve emits Approval event
    }
}

Extensions and Challenges

Challenge 1: Implement ERC20Permit (EIP-2612)

Add gasless approvals using signatures. This allows users to approve and transfer in one transaction without holding ETH:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

Challenge 2: Implement ERC20Votes

Add voting power tracking with delegation, as used by governance tokens:

function getVotes(address account) external view returns (uint256);
function getPastVotes(address account, uint256 blockNumber) external view returns (uint256);
function delegate(address delegatee) external;

Challenge 3: Implement Token with Fees

Create a token that takes a fee on transfers (like some reflection tokens):

function transfer(address to, uint256 amount) public returns (bool) {
    uint256 fee = amount * feePercent / 100;
    uint256 netAmount = amount - fee;

    _transfer(msg.sender, feeRecipient, fee);
    _transfer(msg.sender, to, netAmount);
    return true;
}

Challenge 4: Implement Pausable Token

Add emergency pause functionality:

modifier whenNotPaused() {
    require(!paused, "Token is paused");
    _;
}

function transfer(address to, uint256 amount) public whenNotPaused returns (bool) {
    // ...
}

Challenge 5: Implement Huff Version

Rewrite the token in Huff (raw EVM assembly) for maximum gas efficiency. This teaches you exactly how EVM storage and memory work.


Real-World Connections

Stablecoins

USDT, USDC, and DAI are all ERC-20 tokens. Understanding ERC-20 helps you understand:

  • How stablecoins track balances
  • Why USDT has non-standard behavior (no return value)
  • How DAI’s savings rate (DSR) works via wrapping

DeFi Integrations

Every major DeFi protocol uses ERC-20:

  • Uniswap: Swaps between ERC-20 tokens
  • Aave: Lends and borrows ERC-20 tokens
  • Compound: Issues cTokens as ERC-20
  • Curve: Specialized for ERC-20 stablecoin swaps

Token Launchpads

ICOs, IDOs, and token launches all rely on ERC-20:

  • Projects create tokens with fixed or variable supply
  • Lockups use approve to vest tokens over time
  • Airdrops iterate over transfer calls

Wrapped Tokens

WETH (Wrapped Ether) makes ETH behave like an ERC-20:

function deposit() public payable {
    _mint(msg.sender, msg.value);
}

function withdraw(uint256 amount) public {
    _burn(msg.sender, amount);
    payable(msg.sender).transfer(amount);
}

Resources

Primary References

  1. EIP-20 Specification: EIPs - EIP-20 - The official standard
  2. “Mastering Ethereum” Chapter 10: Token Standards - Antonopoulos & Wood
  3. Solidity Documentation: Solidity Docs - Language reference

Code References

  1. OpenZeppelin ERC-20: GitHub - Industry standard implementation
  2. Solmate ERC-20: GitHub - Gas-optimized implementation
  3. Solady ERC-20: GitHub - Ultra-optimized implementation

Supplementary Reading

  1. EIP-2612 (Permit): EIPs - EIP-2612 - Gasless approvals
  2. Token Approval Best Practices: Revoke.cash Blog
  3. Smart Contract Security: Trail of Bits - Building Secure Contracts

Tools

  1. Foundry: Book - Modern Solidity development
  2. Hardhat: Docs - JavaScript-based alternative
  3. Tenderly: App - Transaction debugging and simulation

Self-Assessment Checklist

Before moving to the next project, verify:

  • I can explain why ERC-20 uses decimals instead of floating-point
  • I understand the difference between transfer and transferFrom
  • I can explain the approve race condition and how to mitigate it
  • My implementation passes all standard ERC-20 test vectors
  • I understand why events are important for off-chain systems
  • I can explain how Solidity 0.8+ protects against integer overflow
  • My token works correctly with MetaMask and Etherscan
  • I understand the storage layout of balances and allowances
  • I can explain why infinite approval (type(uint256).max) exists
  • I understand the CEI (Checks-Effects-Interactions) pattern

What’s Next?

With ERC-20 mastered, you now understand the fundamental building block of DeFi. In Project 10: Automated Market Maker (AMM/DEX), you’ll build a Uniswap-style decentralized exchange that uses ERC-20 tokens. You’ll see how the constant product formula (x * y = k) enables trustless token swaps, and how liquidity providers earn fees. This is where tokens become truly useful.