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
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
cast --version
anvil --versionThis 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
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 installInit 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
[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
// 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
// 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
forge build
forge test --gas-report
forge test --fuzz-runs 10000
forge snapshotBuild 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
// 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
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 --legacyBroadcasts 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, useimmutablefor storage, batch ops. - Test coverage: Aim for 100%+ fuzz/invariants; integrate CI GitHub Actions with
forge test -vv. - Env management:
.env+dotenvfor 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; usesethor 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.