ERC-20: Write Your First Token with Solidity and Foundry

1. Overview

ERC-20 is the most popular token standard on Ethereum. USDC, DAI, LINK, and thousands of other tokens follow it. In this article, you will write a minimal ERC-20 token to understand how the standard works, then write tests with Foundry. Next, you will switch to OpenZeppelin’s implementation for a production environment and deploy your token on a local blockchain.

2. What is ERC-20?

ERC-20 is an interface—a set of functions and events that every token must have. Thanks to this common interface, wallets, exchanges, and DeFi contracts can work with any token in the same way without needing to know how the token is implemented internally.

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

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

    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

You can divide this interface into two groups:

  • Direct transfer: transfer sends tokens from the caller (msg.sender) to to.
  • Delegation: approve allows a spender to spend up to value of your tokens. Later, the spender calls transferFrom to withdraw. This is how a DEX exchange “pulls” tokens from your wallet when you swap.

The three functions name(), symbol(), and decimals() belong to the metadata extension. Theoretically they are not required, but almost every token has them.

3. Setting Up Your Environment with Foundry

This guide uses Foundry: you write both contracts and tests in Solidity, and tests run very fast. After installing Foundry, create a project:

forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts

The forge install command downloads the OpenZeppelin library into the lib/ directory. You need to declare remappings for shorter imports. Add the following line to a remappings.txt file in your project root:

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/

The project structure consists of src/ for contracts, test/ for tests, and script/ for deployment scripts. You can delete the sample Counter files that forge init creates.

4. Writing a Minimal ERC-20 from Scratch

The goal of this section is to understand the ERC-20 standard, not to use it in production. Create a file src/SimpleToken.sol:

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

contract SimpleToken {
    string public name = "Cafe Token";
    string public symbol = "CAFE";
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

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

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    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) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "allowance too low");
        allowance[from][msg.sender] = allowed - amount;
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(to != address(0), "zero address");
        require(balanceOf[from] >= amount, "balance too low");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}

Key points to notice:

  • public state variables auto-generate getters. Declaring mapping(...) public balanceOf creates a balanceOf(address) function that matches the interface signature. allowance works the same way.
  • decimals = 18. Blockchains don’t support decimals, so tokens store all balances as integers. When a wallet displays “1.5 CAFE”, the actual value in the contract is 1.5 * 10^18.
  • Constructor emits a Transfer event from address(0). This is a convention to signal “token created” (minting). Block explorers rely on this event to display transaction history.
  • transferFrom decreases the allowance before transferring. Without this, a spender could withdraw unlimited times.
  • No need for SafeMath. Since Solidity 0.8, the -= operation automatically reverts on underflow. Still, require with a clear message makes debugging easier.

5. Writing Tests with Foundry

Tests in Foundry are contracts that inherit Test. Each function starting with test is a test case. Create a file test/SimpleToken.t.sol:

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

import {Test} from "forge-std/Test.sol";
import {SimpleToken} from "../src/SimpleToken.sol";

contract SimpleTokenTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 value);

    SimpleToken token;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        token = new SimpleToken(1_000 ether);
    }

    function test_transfer_movesBalance() public {
        token.transfer(alice, 100 ether);

        assertEq(token.balanceOf(alice), 100 ether);
        assertEq(token.balanceOf(address(this)), 900 ether);
    }

    function test_transfer_emitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(address(this), alice, 1 ether);

        token.transfer(alice, 1 ether);
    }

    function test_transferFrom_requiresAllowance() public {
        token.transfer(alice, 100 ether);

        vm.prank(alice);
        token.approve(bob, 30 ether);

        vm.prank(bob);
        token.transferFrom(alice, bob, 30 ether);

        assertEq(token.balanceOf(bob), 30 ether);
        assertEq(token.allowance(alice, bob), 0);

        vm.prank(bob);
        vm.expectRevert(bytes("allowance too low"));
        token.transferFrom(alice, bob, 1);
    }
}

Explanation of each section:

  • setUp() runs again before each test. The test contract deploys the token, so address(this) holds all 1000 tokens.
  • 1_000 ether is just shorthand for 1000 * 10^18. The ether keyword here simply multiplies by 1e18 and has nothing to do with real ETH.
  • makeAddr("alice") creates a labeled address, making logs more readable when tests fail.
  • vm.prank(alice) makes the next call have msg.sender as alice.
  • vm.expectEmit(true, true, false, true) checks topic 1 (from), topic 2 (to), and the data field (value) of the event emitted immediately after.
  • vm.expectRevert asserts that the next call must revert with the exact message provided.

Run the tests:

forge test -vv
Ran 3 tests for test/SimpleToken.t.sol:SimpleTokenTest
[PASS] test_transferFrom_requiresAllowance() (gas: 79120)
[PASS] test_transfer_emitsEvent() (gas: 41871)
[PASS] test_transfer_movesBalance() (gas: 43552)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Gas values on your machine may vary slightly depending on the compiler version.

6. Using OpenZeppelin for a Production Token

The self-written version above works but hasn’t been audited. For a real token, you should inherit from OpenZeppelin’s ERC20. This library has been verified against billions of dollars in circulating value. Create a file src/CafeToken.sol:

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeToken is ERC20, Ownable {
    constructor(address initialOwner)
        ERC20("Cafe Token", "CAFE")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 1_000_000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
}

With just a dozen lines, you have a complete token:

  • ERC20("Cafe Token", "CAFE") sets name and symbol. By default, decimals() returns 18.
  • _mint increases totalSupply, adds to the recipient’s balance, and emits a Transfer event from address(0).
  • Ownable(initialOwner) (OpenZeppelin v5) requires passing the owner explicitly. The onlyOwner modifier prevents others from calling mint.

OpenZeppelin v5 uses custom errors instead of revert strings. Therefore, tests must also check against error selectors. Create a file test/CafeToken.t.sol:

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

import {Test} from "forge-std/Test.sol";
import {CafeToken} from "../src/CafeToken.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeTokenTest is Test {
    CafeToken token;
    address owner = makeAddr("owner");
    address alice = makeAddr("alice");

    function setUp() public {
        token = new CafeToken(owner);
    }

    function test_initialSupply_goesToOwner() public view {
        assertEq(token.totalSupply(), 1_000_000e18);
        assertEq(token.balanceOf(owner), 1_000_000e18);
    }

    function test_transfer_revertsWhenBalanceTooLow() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 1)
        );
        token.transfer(owner, 1);
    }

    function test_mint_onlyOwner() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)
        );
        token.mint(alice, 1e18);
    }
}

When transfer reverts, ERC20InsufficientBalance(sender, balance, needed) tells you exactly who is short and by how much. This information is far more useful than a generic string.

7. Deploying to a Local Network and Interacting

Foundry comes with anvil, a local Ethereum node with 10 pre-funded accounts. Open a separate terminal and run:

anvil

In another terminal, deploy the token using anvil’s first account. This private key is public knowledge and is only for local testing:

export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

forge create src/CafeToken.sol:CafeToken \
  --rpc-url http://127.0.0.1:8545 \
  --private-key $PK \
  --broadcast \
  --constructor-args $OWNER
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x...

Use cast to read balances and transfer tokens:

export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3

cast call $TOKEN "balanceOf(address)(uint256)" $OWNER --rpc-url http://127.0.0.1:8545

cast send $TOKEN "transfer(address,uint256)" \
  0x70997970C51812dc3A010C7d01b50e0d17dc79C8 50ether \
  --private-key $PK --rpc-url http://127.0.0.1:8545
1000000000000000000000000 [1e24]

cast call only reads data so it doesn’t cost gas. cast send creates a real transaction on anvil. The argument 50ether is automatically converted by cast to 50 * 10^18.

8. Common Pitfalls and Best Practices

Warning: Don’t write ERC-20 yourself for production. The version in section 4 is for learning only. For a real token, inherit from OpenZeppelin and only add your custom logic.

  • Confusing decimals. Calling transfer(to, 100) only sends 100 * 10^-18 tokens, nearly zero. Always multiply by 10 ** decimals(). Conversely, USDC uses 6 decimals, not 18, so don’t hard-code 1e18 when working with others’ tokens.
  • Race condition in approve. Suppose you change allowance from 100 to 50. A malicious spender can watch the mempool, withdraw 100 before the new command is confirmed, then withdraw 50 more. The safe way is to set allowance to 0 first, or use tokens supporting permit (ERC-2612).
  • Non-standard tokens. Some legacy tokens like USDT don’t return bool from transfer. When your contract calls an external token, use the SafeERC20 library (using SafeERC20 for IERC20; then call safeTransfer) instead of calling transfer directly.
  • Tokens sent to contracts get stuck. ERC-20 has no mechanism to notify the recipient. If someone sends tokens to a contract with no withdrawal function, those tokens are locked permanently.
  • Fee-on-transfer and rebasing tokens. Some tokens receive less than they send, or balances change over time automatically. If your contract receives arbitrary tokens, measure the balance before and after transfer instead of trusting the amount parameter.
  • Unlimited mint. A mint function with onlyOwner means the owner can mint tokens indefinitely. If you want a fixed total supply, remove this function or add a cap, for example using ERC20Capped.

9. Conclusion

In this article, you’ve learned the ERC-20 interface and implemented a minimal token to see how balanceOf, allowance, transfer, and transferFrom work together. You’ve also written tests with Foundry using vm.prank, vm.expectEmit, and vm.expectRevert. Then you switched to CafeToken based on OpenZeppelin, deployed it on anvil, and interacted via cast.

The logical next step is to explore extensions like ERC20Permit for signature-based approval, ERC20Burnable for burning tokens, or ERC20Capped to cap total supply. You should also practice writing contracts that safely receive tokens using SafeERC20.

solidityerc-20ethereumfoundrysmart-contract