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:
transfersends tokens from the caller (msg.sender) toto. - Delegation:
approveallows aspenderto spend up tovalueof your tokens. Later, thespendercallstransferFromto 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:
publicstate variables auto-generate getters. Declaringmapping(...) public balanceOfcreates abalanceOf(address)function that matches the interface signature.allowanceworks 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 is1.5 * 10^18.- Constructor emits a
Transferevent fromaddress(0). This is a convention to signal “token created” (minting). Block explorers rely on this event to display transaction history. transferFromdecreases the allowance before transferring. Without this, aspendercould withdraw unlimited times.- No need for SafeMath. Since Solidity 0.8, the
-=operation automatically reverts on underflow. Still,requirewith 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, soaddress(this)holds all 1000 tokens.1_000 etheris just shorthand for1000 * 10^18. Theetherkeyword here simply multiplies by1e18and 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 havemsg.senderasalice.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.expectRevertasserts 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")setsnameandsymbol. By default,decimals()returns 18._mintincreasestotalSupply, adds to the recipient’s balance, and emits aTransferevent fromaddress(0).Ownable(initialOwner)(OpenZeppelin v5) requires passing the owner explicitly. TheonlyOwnermodifier prevents others from callingmint.
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. Callingtransfer(to, 100)only sends100 * 10^-18tokens, nearly zero. Always multiply by10 ** decimals(). Conversely, USDC uses 6 decimals, not 18, so don’t hard-code1e18when 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 supportingpermit(ERC-2612). - Non-standard tokens. Some legacy tokens like USDT don’t return
boolfromtransfer. When your contract calls an external token, use theSafeERC20library (using SafeERC20 for IERC20;then callsafeTransfer) instead of callingtransferdirectly. - 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
amountparameter. - Unlimited
mint. Amintfunction withonlyOwnermeans the owner can mint tokens indefinitely. If you want a fixed total supply, remove this function or add a cap, for example usingERC20Capped.
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.