Skip to content
Learni
View all tutorials
Blockchain

How to Develop and Deploy an ERC-20 Token on Ethereum in 2026

Lire en français

Introduction

In 2026, Ethereum remains the gold standard for smart contracts, with upgrades like Prague enhancing scalability and security. Developing an ERC-20 token is a rite of passage for any expert blockchain developer—it's the foundation for DeFi, NFTs, and DAOs. This tutorial guides you step-by-step with Foundry, the ultra-fast Rust-based toolchain dominating Solidity development (tests 100x faster than Hardhat).

We'll create MyToken (MTK): initial mint, pause/resume via owner, and advanced blacklisting. You'll learn to initialize a project, code an OpenZeppelin-based contract, test exhaustively (including fuzzing), and deploy to Sepolia (Ethereum testnet).

Why Foundry? No Node.js, parallel builds, native Solidity scripting. Result: a pro workflow ready for mainnet or L2s like Base/Optimism. By the end, you'll bookmark this guide for your real audits and launches. Estimated time: 30 min.

Prerequisites

  • Foundry installed (see below if not)
  • Git
  • Ethereum private key (for deployment, via env PRIVATE_KEY)
  • Free Sepolia RPC (Alchemy/Infura)
  • Solidity ^0.8, OpenZeppelin, Foundry testing knowledge
  • Unix-like terminal (WSL on Windows)

Installing Foundry

install-foundry.sh
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
cast --version
anvil --version

This one-liner script installs Foundry (foundry/anvil/cast/forge) via their official binary. foundryup updates to the latest stable version (v0.2.1+ in 2026). Verify with --version: expect Forge 0.2.x for EVM Prague support. Pitfall: on macOS M1, add arch -arm64 if curl fails.

Initializing the Project

Create a dedicated repo. Foundry init with template includes OpenZeppelin remote libs, ready for ERC-20. It generates src/, test/, script/, and basic foundry.toml. Clone to GitHub for CI/CD.

Initialize the Foundry Project

init-project.sh
forge init erc20-token --template https://github.com/foundry-rs/forge-template
cd erc20-token
git init
git add .
git commit -m "Initial Foundry ERC-20 project"
forge install

Init with forge-rs template adds OpenZeppelin v5.x as a git submodule lib. forge install resolves deps (oz, ds-test). Structure: src/ for contracts, test/ for unit/fuzz, script/ for deploy. Pitfall: don't forget cd before edits, and push to Git for forge snapshot CI.

foundry.toml Configuration

foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
  "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
]

[profile.default.rpc_storage_caching]
chains = { sepolia = { mode = "local" } }

[profile.default.fuzz]
runs = 10000
seed = 123456789

[profile.default.wallets]
from_key = "${PRIVATE_KEY}"

Pro config: remappings for clean OZ imports, local RPC caching for Sepolia (10x speed gain). Fuzzing at 10k runs with reproducible seed. Auto wallet via PRIVATE_KEY env. Pitfall: without remappings, imports fail; test with forge build --verbose.

Developing the Smart Contract

Replace src/Counter.sol with our advanced ERC-20. Inherits from OZ for audit-ready security. Features: owner-only mint, pausable, blacklist (burn-like for compliance).

ERC-20 MyToken.sol Smart Contract

src/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract MyToken is ERC20, ERC20Pausable, Ownable, ReentrancyGuard {
    mapping(address => bool) public blacklist;

    event Blacklisted(address indexed account);
    event Unblacklisted(address indexed account);

    constructor() ERC20("MyToken", "MTK") Ownable(msg.sender) {
        _mint(msg.sender, 1_000_000 * 10**decimals());
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function blacklistAddress(address account) public onlyOwner {
        blacklist[account] = true;
        emit Blacklisted(account);
    }

    function unblacklistAddress(address account) public onlyOwner {
        blacklist[account] = false;
        emit Unblacklisted(account);
    }

    function _beforeTokenTransfer(address from, address to, uint256 amount)
        internal
        whenNotPaused
        override(ERC20, ERC20Pausable)
    {
        require(!blacklist[from] && !blacklist[to], "Blacklisted address");
        super._beforeTokenTransfer(from, to, amount);
    }

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

    function burn(uint256 amount) public {
        _burn(msg.sender, amount);
    }

Secure contract: OZ base + Pausable/Ownable/ReentrancyGuard. Hooks _beforeTokenTransfer block blacklist/pause. Owner-only mint, public burn. Constructor mints 1M supply. Pitfall: incorrect override causes infinite recursion; compile with forge build for checks.

Unit Tests and Fuzzing

Foundry tests are your shield: invariants, fuzz, traces. Achieve 100% coverage: deploy, mint, pause, blacklist, reentrancy.

Complete Token.t.sol Tests

test/MyToken.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";

contract MyTokenTest is Test {
    MyToken token;
    address owner = makeAddr("owner");
    address user = makeAddr("user");
    address blackUser = makeAddr("blackUser");

    function setUp() public {
        vm.prank(owner);
        token = new MyToken();
    }

    function testInitialSupply() public {
        assertEq(token.totalSupply(), 1_000_000 * 10**token.decimals());
        assertEq(token.balanceOf(owner), 1_000_000 * 10**token.decimals());
    }

    function testMint() public {
        uint256 mintAmount = 100 * 10**token.decimals();
        vm.prank(owner);
        token.mint(user, mintAmount);
        assertEq(token.balanceOf(user), mintAmount);
    }

    function testPause() public {
        vm.prank(owner);
        token.pause();
        vm.expectRevert("Pausable: paused");
        token.transfer(user, 1);
        vm.prank(owner);
        token.unpause();
    }

    function testBlacklist() public {
        vm.startPrank(owner);
        token.blacklistAddress(blackUser);
        vm.stopPrank();
        vm.expectRevert("Blacklisted address");
        vm.prank(blackUser);
        token.transfer(user, 1);
    }

    function testFuzzBurn(uint256 amount) public {
        vm.assume(amount <= token.balanceOf(owner));
        vm.prank(owner);
        token.burn(amount);
        assertEq(token.balanceOf(owner), 1_000_000 * 10**token.decimals() - amount);
    }

    function testInvariantTotalSupply() public {
        uint256 initial = token.totalSupply();
        // Mint/burn/pause ne change pas l'invariant
        assertEq(token.totalSupply(), initial);
    }

Exhaustive suite: setUp fork-like, vm.prank simulates EOAs, expectRevert checks errors. Fuzz testFuzzBurn uses assume for bounds/gas. Invariant test for audits. Pitfall: without vm.assume, fuzz OOM; run forge test -vvv for traces.

Build and Tests

build-test.sh
forge build
forge test --gas-report
forge test --fuzz-runs 10000
forge snapshot

Build checks syntax/gas. --gas-report optimizes (target <200k deploy). Fuzz 10k iters. snapshot baselines gas for regressions. Pitfall: gas spike? Profile with --gas-report and remap deps.

Deployment Script

Forge scripts in Solidity: broadcast tx, auto Etherscan verify. Set PRIVATE_KEY and SEPOLIA_RPC_URL in .env.

Deploy.s.sol Script

script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script, console} from "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";

contract Deploy is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployerPrivateKey);
        
        MyToken token = new MyToken();
        console.log("MyToken deployed to:", address(token));
        console.log("Owner:", token.owner());
        console.log("Total Supply:", token.totalSupply());

        vm.stopBroadcast();
    }

Broadcast script: vm.envUint securely reads PRIVATE_KEY, new deploys, logs addr. Auto-verifies on Etherscan via chain config. Pitfall: without SEPOLIA_RPC_URL env, falls back to mainnet! Set export SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY.

Deploy to Sepolia

deploy-sepolia.sh
export PRIVATE_KEY=0xYourPrivateKeyHere
export SEPOLIA_RPC_URL=https://rpc.sepolia.org  # ou Alchemy
forge script script/Deploy.s.sol:Deploy --rpc-url $SEPOLIA_RPC_URL --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY --legacy

Broadcasts live tx to Sepolia, --verify auto-sources Etherscan. --legacy for create2 compat on old chains. Logs addr/console in tx receipt. Pitfall: nonce collision? Add --sender; check forge verify-contract post-deploy.

Best Practices

  • Security first: Always inherit audited OZ contracts, run Slither/Forge invariants.
  • Gas optimization: Profile with --gas-report, use immutable for storage, batch ops.
  • Test coverage: Aim for 100%+ fuzz/invariants; integrate CI GitHub Actions with forge test -vv.
  • Env management: .env + dotenv for keys; multi-sig deploy via Gnosis.
  • Upgrades: Prepare UUPS proxy from v1 for upgradable tokens.

Common Errors to Avoid

  • Forgot remappings: OZ imports fail → forge remappings --check.
  • Unbounded fuzz: Gas OOM → vm.assume(amount < type(uint128).max).
  • Private key leak: Never commit .env; use seth or HD wallets.
  • Testnet gas: Sepolia saturated? Switch to Holesky or L2 testnet like OP Sepolia.

Next Steps

Master pro audits with Slither and Echidna. Deploy to L2 (Base, Arbitrum) via foundry.toml chains.

Check out our Learni Blockchain courses: advanced Solidity, fullstack DeFi, ZK proofs. Join the Discord community for contract reviews.