← Course home Module 9 / 19 Blockchain — Part 2

ERC-20: Building a Fungible Token

The ERC-20 standard powers every fungible token on Ethereum — from USDT to UNI. In this module, you'll implement one from scratch, understand every function, and learn how OpenZeppelin makes it production-ready.

~75 min read Module 9 ⟠ Part 2

Recap: From Patterns to Standards

Module 8 taught you inheritance, interfaces, AccessControl, proxy upgrades, and gas optimization. Now it's time to apply every one of those patterns to the most important standard in the Ethereum ecosystem: ERC-20.

🧱

What You Know

Inheritance hierarchies, the IERC20 interface (6 functions + 2 events), AccessControl roles, custom errors for gas efficiency, and events for off-chain tracking. All of these are building blocks for what comes next.

🔨

What You'll Build

A complete ERC-20 token implemented from scratch — every function written by hand. Then you'll refactor it using OpenZeppelin's ERC20 base contract, and extend it with burning, pausing, and role-based minting.

💡

Why ERC-20?

Before ERC-20 (proposed in November 2015), every token contract had different function names and signatures. Exchanges and wallets needed custom integration code for each token. ERC-20 standardized 6 functions + 2 events — and suddenly any wallet or exchange could interact with any token automatically. Today, over 450,000 ERC-20 tokens are deployed on Ethereum mainnet.

The ERC-20 Standard: 6 Functions + 2 Events

EIP-20, proposed by Fabian Vogelsteller in November 2015, defines the minimum interface that every fungible token must implement. If your contract implements these 6 functions and 2 events, any wallet, exchange, or DeFi protocol can interact with it.

The 6 Required Functions

Function
Mutability
Description
totalSupply()
view
Returns the total number of tokens in existence.
balanceOf(address)
view
Returns the token balance of a given address.
transfer(address to, uint256 amount)
state-changing
Sends amount tokens from the caller to to.
approve(address spender, uint256 amount)
state-changing
Authorizes spender to spend up to amount tokens on behalf of the caller.
allowance(address owner, address spender)
view
Returns how many tokens spender can still spend from owner.
transferFrom(address from, address to, uint256 amount)
state-changing
A spender transfers amount tokens from from to to (up to their allowance).

The 2 Required Events

Event
Emitted When
Transfer(address indexed from, address indexed to, uint256 value)
On every transfer — including minting (from = address(0)) and burning (to = address(0)).
Approval(address indexed owner, address indexed spender, uint256 value)
When an allowance is set or changed via approve().
🔗

The Approve + TransferFrom Pattern

Why do we need two steps? Because smart contracts can't pull tokens directly from your wallet. Instead: Alice calls approve(Uniswap, 100). Then Uniswap calls transferFrom(Alice, Pool, 100) inside its own transaction. Without this two-step pattern, DeFi simply wouldn't work — every swap, lending deposit, and liquidity provision depends on it.

📝

Optional Extensions

name(), symbol(), and decimals() are not part of the original EIP-20, but they are universally implemented. decimals() is typically 18, meaning 1 token = 1018 smallest units — just like ETH's wei. This convention allows wallets to display human-readable balances.

Building ERC-20 From Scratch

Let's implement every function by hand. No imports, no shortcuts — just raw Solidity. This is the best way to truly understand what happens under the hood when you call transfer() or approve().

Complete MyToken.sol

MyToken.sol Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract MyToken {
    string private _name;
    string private _symbol;
    uint8  private _decimals;
    uint256 private _totalSupply;

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

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

    error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
    error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
    error ERC20InvalidReceiver(address receiver);

    constructor(string memory name_, string memory symbol_, uint256 initialSupply) {
        _name = name_;
        _symbol = symbol_;
        _decimals = 18;
        _mint(msg.sender, initialSupply * 10 ** 18);
    }

    function name() external view returns (string memory) { return _name; }
    function symbol() external view returns (string memory) { return _symbol; }
    function decimals() external view returns (uint8) { return _decimals; }
    function totalSupply() external view returns (uint256) { return _totalSupply; }
    function balanceOf(address account) external view returns (uint256) { return _balances[account]; }

    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        _allowances[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 currentAllowance = _allowances[from][msg.sender];
        if (currentAllowance < amount)
            revert ERC20InsufficientAllowance(msg.sender, currentAllowance, amount);
        _allowances[from][msg.sender] = currentAllowance - amount;
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        if (to == address(0)) revert ERC20InvalidReceiver(address(0));
        uint256 fromBalance = _balances[from];
        if (fromBalance < amount)
            revert ERC20InsufficientBalance(from, fromBalance, amount);
        _balances[from] = fromBalance - amount;
        _balances[to] += amount;
        emit Transfer(from, to, amount);
    }

    function _mint(address to, uint256 amount) internal {
        _totalSupply += amount;
        _balances[to] += amount;
        emit Transfer(address(0), to, amount);
    }
}
🔍

Line-by-Line Breakdown

Every function maps directly to Module 8's IERC20 interface. The _transfer() helper avoids code duplication — the same pattern as Module 8's library lesson. Events are emitted on every state change (Module 7's events lesson). Custom errors replace string messages for gas efficiency. The _mint() helper emits Transfer with from = address(0) — the convention for token creation.

Deep Dive: Approve & TransferFrom

The approve/transferFrom pattern is the most confusing part of ERC-20 for beginners. Let's trace through a concrete example, step by step, to make it crystal clear.

Step-by-Step Flow

Step
Action
Result
1
Alice calls approve(Uniswap, 1000)
_allowances[Alice][Uniswap] = 1000. Emits Approval(Alice, Uniswap, 1000).
2
Uniswap calls transferFrom(Alice, Pool, 500)
Checks allowance >= 500. Deducts 500 from allowance. Transfers 500 tokens from Alice to Pool. Emits Transfer(Alice, Pool, 500).
3
State after step 2
_allowances[Alice][Uniswap] = 500 remaining. Alice's balance decreased by 500. Pool's balance increased by 500.
4
Uniswap tries transferFrom(Alice, Pool, 600)
Reverts: ERC20InsufficientAllowance — only 500 remaining, but 600 requested.
⚠️

The Approve Race Condition

If Alice changes her allowance from 100 to 50, a malicious spender can front-run the transaction: spend the original 100 before the new approval is mined, then spend 50 more = 150 total instead of the intended 50. Solutions: (1) always set allowance to 0 first, then set the new value in a second transaction; or (2) better, use EIP-2612 permit() (see next card), which avoids the race entirely via signed off-chain approvals. Note: OpenZeppelin's old increaseAllowance() / decreaseAllowance() helpers were removed in v5 — don't rely on them.

♾️

Infinite Approval

approve(spender, type(uint256).max) — a common pattern in DeFi for convenience. The user approves once, and the contract can spend any amount forever. Risk: if the contract is compromised or has a bug, all your tokens are at risk. Trade-off: better UX (one approval) vs. reduced security.

✍️

EIP-2612: Permit

Gasless approval using off-chain signatures. Instead of a separate approve() transaction, the user signs a permit message off-chain. The contract calls permit() to set the allowance — all in a single transaction. Used by Uniswap V3, 1inch, and many modern DeFi protocols. Saves gas and improves UX.

OpenZeppelin's ERC20: Production-Ready

Now that you understand every line of a hand-written ERC-20, let's see how professionals do it. OpenZeppelin's ERC20 contract is battle-tested, audited, and secures over $100 billion in value.

Same Token, 5 Lines

MyTokenOZ.sol Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
    constructor() ERC20("MyToken", "MTK") {
        _mint(msg.sender, 1000000 * 10 ** decimals());
    }
}

From Scratch vs OpenZeppelin

Criteria From Scratch OpenZeppelin
Lines of code ~60 ~5
Battle-tested No Yes — audited, $100B+ secured
Extensions Manual implementation Import ERC20Burnable, ERC20Pausable, ERC20Permit
Gas cost Similar Similar (OpenZeppelin is optimized)
🧩

Available Extensions

OpenZeppelin provides ready-to-use extensions via multiple inheritance: ERC20Burnable (burn tokens, reduce supply), ERC20Pausable (emergency stop for all transfers), ERC20Capped (enforce a maximum supply), ERC20Permit (gasless approval via EIP-2612), ERC20Votes (governance voting power tracking). Mix and match as needed.

Code: Token with Extensions

GovernanceToken.sol Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract GovernanceToken is ERC20, ERC20Burnable, ERC20Pausable, AccessControl {
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    constructor() ERC20("GovToken", "GOV") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(PAUSER_ROLE, msg.sender);
        _mint(msg.sender, 1000000 * 10 ** decimals());
    }

    function pause() external onlyRole(PAUSER_ROLE) { _pause(); }
    function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); }

    // Required override for ERC20Pausable
    function _update(address from, address to, uint256 value)
        internal override(ERC20, ERC20Pausable)
    {
        super._update(from, to, value);
    }
}
⚠️

OpenZeppelin Version

This course uses OpenZeppelin v5 (2024). The key difference: v5 uses _update() instead of the old _beforeTokenTransfer() hook. If you see tutorials using _beforeTokenTransfer, they target v4. Check your package.json: "@openzeppelin/contracts": "^5.0.0".

Testing Your Token

A token without tests is a liability. Every function must be verified, every edge case must be covered. Let's look at what to test and how to test it.

What to Test

Mint correctness (deployer receives initial supply), transfer() behaviour (balances update correctly), the full approve() + transferFrom() flow, edge cases (transfer exceeding balance, transferFrom exceeding allowance, transfer to zero address), and event emission — every state change must emit the correct event.

🛠️

Testing Tools

Hardhat is the standard framework (Module 10 covers it in depth). Use viem for contract interaction, chai for assertions, and Hardhat's built-in network for fast local testing. Each test runs in an isolated snapshot — no state leaks between tests.

🐛

Common Bugs

Forgetting to emit events (breaks off-chain indexers), not checking the zero address (tokens sent to address(0) are lost forever), not handling overflow (Solidity 0.8+ handles this automatically), and the approve race condition (always test allowance changes).

Code: Test Examples

MyToken.test.js JavaScript
// Test: transfer reduces sender balance and increases recipient
const tx = await token.transfer(bob, 100n);
expect(await token.balanceOf(alice)).to.equal(900n);
expect(await token.balanceOf(bob)).to.equal(100n);

// Test: transferFrom requires approval
await expect(
  token.connect(bob).transferFrom(alice, bob, 50n)
).to.be.revertedWithCustomError(token, "ERC20InsufficientAllowance");

// Test: approve + transferFrom works
await token.approve(bob, 200n);
await token.connect(bob).transferFrom(alice, bob, 150n);
expect(await token.allowance(alice, bob)).to.equal(50n);
🧪

Test-Driven Development

Write tests before code. If you can't write the test, you don't understand the requirement. Start with the simplest case (deploy + check totalSupply), then build up to complex flows (multi-step approve/transferFrom). Module 11 covers testing in depth with Hardhat and Foundry.

Real-World ERC-20 Tokens

The ERC-20 standard is simple, but the tokens built on it power a multi-trillion dollar ecosystem. Let's look at how real tokens use these exact patterns.

💵

USDT (Tether)

$83B+ market cap. A simple ERC-20 with pause() and blacklist() functions. A centralized issuer (Tether Ltd) mints new tokens when users deposit USD and burns them on withdrawal. The most traded token in crypto.

🦄

UNI (Uniswap)

Governance token. Fixed supply of 1 billion tokens — no minting after deployment. Uses ERC20Votes for on-chain governance: token holders delegate their voting power and vote on protocol proposals.

🔄

WETH (Wrapped ETH)

Wraps native ETH into ERC-20 format so it can be used in DeFi protocols that expect ERC-20 tokens. Call deposit() with ETH to get WETH, call withdraw() to get ETH back. 1 WETH = 1 ETH, always.

🏦

DAI (MakerDAO)

Algorithmic stablecoin. Minted by locking collateral (ETH, USDC, etc.) in MakerDAO vaults. The system adjusts interest rates to keep DAI pegged to $1. Complex system, but the token itself is a standard ERC-20.

🔎

See It on Etherscan

Go to etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7 (USDT). Click "Contract" then "Read Contract" to see totalSupply, balanceOf. Click "Write Contract" to see transfer, approve. This is the ERC-20 interface in action — the same 6 functions you just implemented.

Exercise & Self-Check

Exercises

  1. Minimal ERC-20: Implement a minimal ERC-20 from scratch (no OpenZeppelin) with name(), symbol(), decimals(), and all 6 required functions. Deploy it on Remix and verify every function works.
  2. Minter role: Add a MINTER_ROLE using OpenZeppelin's AccessControl. Only addresses with MINTER_ROLE can call mint() to create new tokens. The deployer should have both DEFAULT_ADMIN_ROLE and MINTER_ROLE.
  3. Trace the flow: Alice has 1000 tokens. She calls approve(Bob, 300). Bob calls transferFrom(Alice, Charlie, 200). What are Alice's balance, Bob's balance, Charlie's balance, and allowance(Alice, Bob) after these calls?
  4. Revert scenarios: Write 5 test scenarios that should revert: (1) transfer more than balance, (2) transferFrom more than allowance, (3) mint without MINTER_ROLE, (4) transfer to the zero address, (5) approve from the zero address. Write the test code for each.
  5. Etherscan analysis: Compare USDT and UNI on Etherscan. Read their verified source code. Which ERC-20 extensions does each use? Which has a fixed supply? Which can be paused? Write a short comparison.

Self-Check Questions

  1. Why does ERC-20 need both transfer() and transferFrom()? What use case does each serve?
  2. What is the approve race condition? Describe the attack and at least one solution.
  3. Why is decimals() typically 18? What would happen if a token used 0 decimals?
  4. What is the difference between _mint() and transfer()? Why does _mint() emit a Transfer event with from = address(0)?
  5. Why would you use OpenZeppelin's ERC20 instead of writing the implementation from scratch?
🎯

Module Summary

You can now implement, test, and deploy ERC-20 tokens using both from-scratch and OpenZeppelin approaches. You understand the 6 functions and 2 events that make up the standard, the approve/transferFrom pattern that powers DeFi, the approve race condition and its solutions, and how to extend tokens with burning, pausing, and role-based access control.

Next module

You've built a token. Now you need a professional development environment. Module 10 covers Hardhat v3 — the industry-standard framework for compiling, testing, and deploying Solidity contracts.

Module 10: Hardhat v3 →
📋 Course Project

Ready to apply everything you've learned? The end-of-course project combines smart contracts, AI, and Node.js into a real decentralised academic assistant.

View Project Brief →