ERC-20 Tokens

Difficulty: Intermediate

ERC-20 is the most widely adopted token standard on Ethereum. Defined in EIP-20, it specifies a common interface that all fungible tokens must implement, enabling seamless interoperability between tokens, wallets, decentralized exchanges, and DeFi protocols. Any contract that implements the six required functions and two events is considered ERC-20 compliant, and can be immediately used across the entire Ethereum ecosystem without custom integration.

The standard defines two transfer mechanisms. The first is a direct `transfer(to, amount)` where the token holder sends tokens from their own balance to another address. The second is a two-step approval flow: the holder first calls `approve(spender, amount)` to grant a spender an allowance, and then the spender calls `transferFrom(from, to, amount)` to move tokens on the holder's behalf. This approve-then-transfer pattern is essential for smart contract interactions, since contracts cannot initiate transfers from your balance without prior authorization.

The `balanceOf(address)` function returns the token balance for any account. The `totalSupply()` function returns the total number of tokens in existence. The `allowance(owner, spender)` function returns how many tokens a spender is still allowed to transfer on behalf of the owner. These three view functions provide complete transparency into the token's distribution and authorization state.

Two events are required: `Transfer(from, to, amount)` must be emitted on every token movement (including minting from the zero address and burning to the zero address), and `Approval(owner, spender, amount)` must be emitted whenever `approve` is called. These events are critical for off-chain services like block explorers, portfolio trackers, and indexing services that need to track token movements without querying every account.

Common extensions beyond the base standard include `mint` and `burn` functions for supply management, `permit` (EIP-2612) for gasless approvals using EIP-712 signatures, and metadata functions like `name()`, `symbol()`, and `decimals()`. Most tokens use 18 decimals to match ETH's native decimal places, but stablecoins like USDC use 6 decimals. Always check `decimals()` before performing amount calculations, as assuming 18 decimals is a common source of bugs.

Code examples

Basic ERC-20 Implementation

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

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);

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

contract MyToken is IERC20 {
    string public name;
    string public symbol;
    uint8 public decimals = 18;
    uint256 private _totalSupply;

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

    constructor(string memory _name, string memory _symbol, uint256 initialSupply) {
        name = _name;
        symbol = _symbol;
        _mint(msg.sender, initialSupply * 10  decimals);
    }

    function totalSupply() external view override returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) external view override returns (uint256) {
        return _balances[account];
    }

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

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

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

    function transferFrom(address from, address to, uint256 amount) external override returns (bool) {
        uint256 currentAllowance = _allowances[from][msg.sender];
        require(currentAllowance >= amount, "ERC20: insufficient allowance");
        unchecked {
            _allowances[from][msg.sender] = currentAllowance - amount;
        }
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(from != address(0), "ERC20: transfer from zero address");
        require(to != address(0), "ERC20: transfer to zero address");
        require(_balances[from] >= amount, "ERC20: insufficient balance");
        unchecked {
            _balances[from] -= amount;
            _balances[to] += amount;
        }
        emit Transfer(from, to, amount);
    }

    function _mint(address to, uint256 amount) internal {
        require(to != address(0), "ERC20: mint to zero address");
        _totalSupply += amount;
        _balances[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function _burn(address from, uint256 amount) internal {
        require(from != address(0), "ERC20: burn from zero address");
        require(_balances[from] >= amount, "ERC20: insufficient balance");
        _balances[from] -= amount;
        _totalSupply -= amount;
        emit Transfer(from, address(0), amount);
    }
}

This implements the full ERC-20 interface from scratch. The _transfer internal function handles the shared logic for both transfer() and transferFrom(). Minting emits Transfer from address(0), and burning emits Transfer to address(0).

Using OpenZeppelin ERC-20

// 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/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract GovernanceToken is ERC20, ERC20Burnable, ERC20Permit, Ownable {
    uint256 public constant MAX_SUPPLY = 1_000_000_000 * 10  18; // 1 billion

    constructor(address initialOwner)
        ERC20("Governance Token", "GOV")
        ERC20Permit("Governance Token")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 100_000_000 * 10  18); // 100M initial
    }

    function mint(address to, uint256 amount) external onlyOwner {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
    }
}

In production, use OpenZeppelin's ERC20 base contract. This example adds burnable functionality, EIP-2612 permit for gasless approvals, and capped minting restricted to the owner.

Key points

Concepts covered

ERC-20, transfer, approve, allowance