Tutoriel·10 min de lecture·Par Solingo

ERC-20 Token Tutorial — Create Your First Token in Solidity

Step-by-step guide to creating, testing, and deploying your first ERC-20 token. From scratch to mainnet.

# 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! 🚀

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement