# ERC-20 Token Tutorial — Create Your First Token in Solidity
Creating your own ERC-20 token is a foundational skill for any Solidity developer. Whether you're building a governance token, a reward system, or just learning the ropes, understanding ERC-20 is essential.
In this tutorial, we'll build an ERC-20 token from scratch, test it thoroughly, and deploy it to a testnet. By the end, you'll have a production-ready token contract.
Prerequisites
Before we start, make sure you have:
- Basic Solidity knowledge (variables, functions, mappings)
- A wallet (MetaMask recommended)
- Some testnet ETH (Sepolia recommended)
- Foundry installed (
curl -L https://foundry.paradigm.xyz | bash)
What is ERC-20?
ERC-20 is the most widely adopted token standard on Ethereum. It defines a common interface that all tokens must implement, ensuring compatibility with wallets, exchanges, and dApps.
The standard specifies 6 core functions and 2 events:
Functions:
totalSupply(): Total token supply
balanceOf(address account): Get balance of an address
transfer(address to, uint256 amount): Transfer tokens
approve(address spender, uint256 amount): Approve spending
allowance(address owner, address spender): Check allowance
transferFrom(address from, address to, uint256 amount): Transfer on behalf
Events:
Transfer(address indexed from, address indexed to, uint256 value)
Approval(address indexed owner, address indexed spender, uint256 value)
Building ERC-20 From Scratch
Let's implement a basic ERC-20 token step by step:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BasicToken {
string public name;
string public symbol;
uint8 public decimals;
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(string memory _name, string memory _symbol, uint256 _initialSupply) {
name = _name;
symbol = _symbol;
decimals = 18; // Standard for most tokens
totalSupply = _initialSupply * 10**decimals;
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
emit Transfer(from, to, amount);
return true;
}
}
Understanding the Code
Decimals: Most tokens use 18 decimals (like ETH). This means 1 token = 1e18 base units. This allows for fractional transfers while avoiding floating-point arithmetic.
Transfer: Simple value transfer with balance checks. Notice we emit an event for indexing.
Approve/TransferFrom: The two-step pattern for delegated transfers. A user approves a spender (like a DEX), then the spender can transfer on their behalf.
Using OpenZeppelin (Recommended)
While building from scratch is educational, production tokens should use OpenZeppelin's battle-tested implementation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, Ownable {
constructor(uint256 initialSupply) ERC20("MyToken", "MTK") Ownable(msg.sender) {
_mint(msg.sender, initialSupply * 10**decimals());
}
// Optional: add minting function
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
// Optional: add burning function
function burn(uint256 amount) public {
_burn(msg.sender, amount);
}
}
Why OpenZeppelin?
- Audited by top security firms
- Handles edge cases (overflow, reentrancy)
- Modular (add features like pausable, burnable, snapshot)
- Gas-optimized
- Battle-tested in production
Testing With Foundry
Never deploy untested code. Here's a comprehensive test suite:
// test/MyToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken token;
address alice = address(0x1);
address bob = address(0x2);
function setUp() public {
token = new MyToken(1000000); // 1M tokens
}
function testInitialSupply() public {
assertEq(token.totalSupply(), 1000000 * 10**18);
assertEq(token.balanceOf(address(this)), 1000000 * 10**18);
}
function testTransfer() public {
token.transfer(alice, 1000 * 10**18);
assertEq(token.balanceOf(alice), 1000 * 10**18);
}
function testFailTransferInsufficientBalance() public {
vm.prank(alice);
token.transfer(bob, 1); // Alice has no tokens, should fail
}
function testApprove() public {
token.approve(alice, 500 * 10**18);
assertEq(token.allowance(address(this), alice), 500 * 10**18);
}
function testTransferFrom() public {
token.approve(alice, 500 * 10**18);
vm.prank(alice);
token.transferFrom(address(this), bob, 200 * 10**18);
assertEq(token.balanceOf(bob), 200 * 10**18);
assertEq(token.allowance(address(this), alice), 300 * 10**18);
}
function testMintOnlyOwner() public {
token.mint(alice, 1000 * 10**18);
assertEq(token.balanceOf(alice), 1000 * 10**18);
}
function testFailMintUnauthorized() public {
vm.prank(alice);
token.mint(bob, 1000 * 10**18); // Should fail, alice is not owner
}
}
Run tests:
forge test -vvv
Deploying Your Token
Step 1: Setup Environment
Create a .env file:
PRIVATE_KEY=your_private_key_here
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_KEY
ETHERSCAN_API_KEY=your_etherscan_api_key
Step 2: Create Deploy Script
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../src/MyToken.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
MyToken token = new MyToken(1000000); // 1M initial supply
console.log("Token deployed at:", address(token));
vm.stopBroadcast();
}
}
Step 3: Deploy to Sepolia
source .env
forge script script/Deploy.s.sol:DeployScript --rpc-url $SEPOLIA_RPC_URL --broadcast --verify -vvvv
Step 4: Verify on Etherscan
If auto-verification fails:
forge verify-contract <CONTRACT_ADDRESS> src/MyToken.sol:MyToken --chain sepolia --constructor-args $(cast abi-encode "constructor(uint256)" 1000000)
Adding Advanced Features
Mintable with Supply Cap
contract CappedToken is ERC20, Ownable {
uint256 public immutable cap;
constructor(uint256 _cap) ERC20("Capped", "CAP") Ownable(msg.sender) {
cap = _cap * 10**decimals();
}
function mint(address to, uint256 amount) public onlyOwner {
require(totalSupply() + amount <= cap, "Cap exceeded");
_mint(to, amount);
}
}
Pausable (Emergency Stop)
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
contract PausableToken is ERC20, Ownable, Pausable {
constructor() ERC20("Pausable", "PSE") Ownable(msg.sender) {
_mint(msg.sender, 1000000 * 10**decimals());
}
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Pausable)
{
super._update(from, to, value);
}
}
Deployment Checklist
Before deploying to mainnet:
- [ ] All tests passing
- [ ] Security audit (for high-value tokens)
- [ ] Verify total supply matches expectations
- [ ] Check ownership transfer if needed
- [ ] Test on testnet first
- [ ] Verify contract on Etherscan
- [ ] Prepare token metadata (logo, description)
- [ ] Consider multi-sig for ownership
- [ ] Document all admin functions
- [ ] Plan for token distribution
Common Pitfalls
Mistake 1: Forgetting Decimals
// Wrong: will mint 1000 tokens, not 1000 * 10^18
_mint(msg.sender, 1000);
// Correct:
_mint(msg.sender, 1000 * 10**decimals());
Mistake 2: Not Emitting Events
Events are crucial for indexers and frontends. Always emit Transfer/Approval.
Mistake 3: Allowing Zero Address Transfers
The zero address (0x0) should be blocked in transfers to prevent accidental burns.
Mistake 4: Integer Overflow (Pre-0.8.0)
Solidity 0.8.0+ has built-in overflow checks. If using older versions, use SafeMath.
Next Steps
You now have a production-ready ERC-20 token. Consider:
- Add governance: Use token for voting (see OpenZeppelin Governor)
- Create liquidity: List on Uniswap
- Build utilities: Staking, rewards, NFT minting
- Implement vesting: Time-locked token releases
The ERC-20 standard is just the beginning. Combine it with DeFi protocols, DAOs, and NFTs to build complex economic systems.
Resources:
Happy building! 🚀