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:
- Understand the ERC-20 token standard at a specification level, including all required and optional functions
- Master Solidity state management using mappings for balance tracking and nested mappings for allowances
- Implement the approve/transferFrom pattern understanding why delegation requires a two-step process
- Learn event emission and indexing for off-chain integration and transaction tracking
- 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:
- Alice approves Bob to spend 100 tokens
- Alice changes her mind and calls
approve(bob, 50)to reduce it - Bob sees the pending transaction and front-runs it
- Bob calls
transferFrom(alice, bob, 100)(using the old allowance) - Alice’s
approve(bob, 50)executes - Bob calls
transferFrom(alice, bob, 50)(using the new allowance) - Bob got 150 tokens instead of the intended maximum of 100
Mitigation strategies:
- Always set allowance to 0 first, then to the new value
- Use
increaseAllowance()anddecreaseAllowance()instead ofapprove() - 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 - amountunderflows to a huge number- User now has more tokens than the total supply!
Historical solutions:
- SafeMath library: Wrapped arithmetic with overflow checks
- 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
- Token Metadata
- Fixed name, symbol, and decimals
- Immutable total supply (for basic version)
- View functions for all metadata
- Balance Management
- Track balances via mapping
- Support balance queries for any address
- Prevent transfers exceeding balance
- Transfer Functions
- Direct transfer between addresses
- Delegated transfer via allowances
- Proper balance and allowance checks
- Allowance System
- Set allowances via approve
- Query allowances via allowance
- Safe increase/decrease allowance functions
- Events
- Emit Transfer on all balance changes
- Emit Approval on all allowance changes
- Use indexed parameters for filtering
- 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:
fromBalance >= amount(subtraction is safe)_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:
- Install Foundry (
curl -L https://foundry.paradigm.xyz | bash) - Create new project (
forge init erc20-token) - Create the IERC20 interface
- 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
indexedkeyword on event parameters
Phase 2: Token Metadata
Goal: Implement name, symbol, decimals, and totalSupply.
Tasks:
- Add private state variables for metadata
- Implement getter functions
- Set up constructor with initial values
- 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 memoryfor name and symbol - Decimals is typically
uint8with value 18 - Consider making decimals a constant for gas savings
Phase 3: Balance Tracking and Transfer
Goal: Implement balanceOf and transfer.
Tasks:
- Add the
_balancesmapping - Implement
balanceOf() - Implement internal
_transfer()function - Implement public
transfer()function - Implement
_mint()for initial supply - 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
requirefor balance checks - The Transfer event from minting has
fromasaddress(0)
Phase 4: Allowance System
Goal: Implement approve, allowance, transferFrom.
Tasks:
- Add the
_allowancesnested mapping - Implement
approve() - Implement
allowance() - Implement
transferFrom() - Implement
_spendAllowance()internal helper - 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.senderintransferFromis 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:
- Implement
increaseAllowance() - Implement
decreaseAllowance() - Write tests for edge cases
- 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
uncheckedcarefully with proper validation - Consider what happens at boundary values
Phase 6: Events and Testing
Goal: Ensure proper event emission and comprehensive testing.
Tasks:
- Verify all events are emitted correctly
- Write event-checking tests
- Add fuzz tests for edge cases
- 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.expectEmitto verify events - Use
vm.assumein 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:
- Write deployment script
- Deploy to Sepolia testnet
- Verify on Etherscan
- Test with MetaMask
- 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
approveto vest tokens over time - Airdrops iterate over
transfercalls
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
- EIP-20 Specification: EIPs - EIP-20 - The official standard
- “Mastering Ethereum” Chapter 10: Token Standards - Antonopoulos & Wood
- Solidity Documentation: Solidity Docs - Language reference
Code References
- OpenZeppelin ERC-20: GitHub - Industry standard implementation
- Solmate ERC-20: GitHub - Gas-optimized implementation
- Solady ERC-20: GitHub - Ultra-optimized implementation
Supplementary Reading
- EIP-2612 (Permit): EIPs - EIP-2612 - Gasless approvals
- Token Approval Best Practices: Revoke.cash Blog
- Smart Contract Security: Trail of Bits - Building Secure Contracts
Tools
- Foundry: Book - Modern Solidity development
- Hardhat: Docs - JavaScript-based alternative
- 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
transferandtransferFrom - 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.