Web3 Development
ERC-20 Token Development: From Concept to Mainnet
TL;DR
ERC-20 is the standard interface for fungible tokens on Ethereum and EVM-compatible chains. I have deployed ERC-20 tokens for client projects across Ethereum, Arbitrum, and Base — and every time, the process comes down to the same fundamentals: inherit from OpenZeppelin, add only the features you actually need, write thorough Foundry tests, deploy to testnet first, then mainnet. This guide walks through the entire process with real Solidity code you can use today. I cover the base contract, adding mintable/burnable/pausable functionality, fuzz testing, testnet deployment with forge create, and the mainnet checklist I run through before every production deploy. If you are building a token, this is the exact workflow I follow.
What Is ERC-20 and Why It Matters
ERC-20 is a technical standard proposed in November 2015 by Fabian Vogelsteller and Vitalik Buterin. It defines six mandatory functions and two events that any fungible token contract must implement to be compatible with wallets, DEXs, and other smart contracts across the Ethereum ecosystem.
The six functions: totalSupply, balanceOf, transfer, allowance, approve, transferFrom. The two events: Transfer and Approval. That is the entire interface. Every token from USDC to UNI to LINK implements these same six functions.
Why does this matter for you? Because when your token follows ERC-20, it automatically works with MetaMask, Uniswap, Aave, and every other protocol that speaks the standard. No custom integration needed. No special wallet support. It just works.
When clients come to me for ERC-20 token development, the first question I ask is: "What does this token actually do?" A governance token needs different features than a utility token. A stablecoin needs different controls than a memecoin. The standard gives you the foundation — what you build on top defines the token.
If you need help with token architecture or deployment, check out my Web3 development services.
Writing the Contract
I start every ERC-20 project with OpenZeppelin Contracts↗. Writing your own ERC-20 from scratch is an unnecessary risk. OpenZeppelin's contracts have been audited by dozens of firms, battle-tested with billions in TVL, and used by teams from Compound to Aave. Use them.
Here is the project setup with Foundry:
mkdir my-token && cd my-token
forge init
forge install OpenZeppelin/openzeppelin-contractsAdd the remapping in foundry.toml:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]Now the base contract. This is the simplest production-ready ERC-20:
// 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 MyToken is ERC20, Ownable {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply,
address initialOwner
) ERC20(name, symbol) Ownable(initialOwner) {
_mint(initialOwner, initialSupply * 10 ** decimals());
}
}That is 15 lines. It gives you a fixed-supply token with an owner. The initialSupply parameter is in whole tokens — the constructor multiplies by 10 ** decimals() (18 by default) to get the actual wei amount.
For most utility tokens and governance tokens with a fixed supply, this is all you need. Resist the urge to add features you do not have a concrete use case for.
Adding Features: Mintable, Burnable, Pausable
When clients need more control, I add features incrementally. Never bundle everything in from day one. Here is the full-featured version I use for projects that need admin controls:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
contract ManagedToken is ERC20, ERC20Burnable, ERC20Pausable, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
uint256 public constant MAX_SUPPLY = 1_000_000_000 * 1e18; // 1 billion cap
error MaxSupplyExceeded(uint256 requested, uint256 available);
constructor(
string memory name,
string memory symbol,
uint256 initialSupply,
address admin
) ERC20(name, symbol) {
if (initialSupply * 1e18 > MAX_SUPPLY) {
revert MaxSupplyExceeded(initialSupply * 1e18, MAX_SUPPLY);
}
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);
_mint(admin, initialSupply * 1e18);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
if (totalSupply() + amount > MAX_SUPPLY) {
revert MaxSupplyExceeded(amount, MAX_SUPPLY - totalSupply());
}
_mint(to, amount);
}
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(PAUSER_ROLE) {
_unpause();
}
// Required override for ERC20Pausable
function _update(
address from,
address to,
uint256 value
) internal override(ERC20, ERC20Pausable) {
super._update(from, to, value);
}
}Let me break down the decisions:
AccessControl over Ownable. For tokens with multiple admin functions, role-based access is safer than a single owner. You can give the minter role to a backend service without giving it pause authority. Separation of concerns at the contract level.
MAX_SUPPLY cap. Every mintable token needs a hard cap. Without one, a compromised minter key can inflate supply to infinity. The cap is a constant, so it cannot be changed — not even by the admin. I set this to 1 billion for most projects, but the number depends on your tokenomics.
Custom errors over require strings. Custom errors (MaxSupplyExceeded) cost less gas than require with a string message. They also give better error context in frontends and block explorers.
ERC20Burnable. This lets any token holder burn their own tokens. Useful for deflationary mechanics, buyback-and-burn programs, or simply giving users the option to remove tokens from circulation.
ERC20Pausable. Emergency stop mechanism. If you discover a vulnerability or get hit with an exploit, pausing the contract stops all transfers while you figure out a response. I include this in every production token.
Testing with Foundry
I do not deploy anything without tests. Foundry makes this fast — tests run in milliseconds, not minutes. Here is the test file I use:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {ManagedToken} from "../src/ManagedToken.sol";
contract ManagedTokenTest is Test {
ManagedToken public token;
address public admin = makeAddr("admin");
address public minter = makeAddr("minter");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
uint256 public constant INITIAL_SUPPLY = 100_000_000; // 100M tokens
function setUp() public {
vm.prank(admin);
token = new ManagedToken("Test Token", "TEST", INITIAL_SUPPLY, admin);
}
function test_InitialState() public view {
assertEq(token.name(), "Test Token");
assertEq(token.symbol(), "TEST");
assertEq(token.totalSupply(), INITIAL_SUPPLY * 1e18);
assertEq(token.balanceOf(admin), INITIAL_SUPPLY * 1e18);
}
function test_Transfer() public {
uint256 amount = 1000 * 1e18;
vm.prank(admin);
token.transfer(user1, amount);
assertEq(token.balanceOf(user1), amount);
assertEq(token.balanceOf(admin), (INITIAL_SUPPLY * 1e18) - amount);
}
function test_MintWithRole() public {
vm.prank(admin);
token.grantRole(token.MINTER_ROLE(), minter);
uint256 mintAmount = 500 * 1e18;
vm.prank(minter);
token.mint(user1, mintAmount);
assertEq(token.balanceOf(user1), mintAmount);
}
function test_MintRevertWithoutRole() public {
vm.prank(user1);
vm.expectRevert();
token.mint(user1, 1000 * 1e18);
}
function test_MintRevertExceedsMaxSupply() public {
uint256 remaining = token.MAX_SUPPLY() - token.totalSupply();
vm.prank(admin);
vm.expectRevert();
token.mint(user1, remaining + 1);
}
function test_Burn() public {
uint256 burnAmount = 1000 * 1e18;
vm.prank(admin);
token.burn(burnAmount);
assertEq(token.totalSupply(), (INITIAL_SUPPLY * 1e18) - burnAmount);
}
function test_PauseAndUnpause() public {
vm.prank(admin);
token.pause();
vm.prank(admin);
vm.expectRevert();
token.transfer(user1, 100 * 1e18);
vm.prank(admin);
token.unpause();
vm.prank(admin);
token.transfer(user1, 100 * 1e18);
assertEq(token.balanceOf(user1), 100 * 1e18);
}
// Fuzz testing — Foundry runs this with hundreds of random inputs
function testFuzz_Transfer(uint256 amount) public {
amount = bound(amount, 1, INITIAL_SUPPLY * 1e18);
vm.prank(admin);
token.transfer(user1, amount);
assertEq(token.balanceOf(user1), amount);
assertEq(token.balanceOf(admin), (INITIAL_SUPPLY * 1e18) - amount);
}
function testFuzz_MintUpToMaxSupply(uint256 amount) public {
uint256 remaining = token.MAX_SUPPLY() - token.totalSupply();
amount = bound(amount, 1, remaining);
vm.prank(admin);
token.mint(user1, amount);
assertEq(token.balanceOf(user1), amount);
assertLe(token.totalSupply(), token.MAX_SUPPLY());
}
}Run the tests:
forge test -vvvThe -vvv flag gives you detailed stack traces when something fails. I run with -vvvv when debugging specific test failures — it shows every opcode execution.
Key things I always test:
- Initial state is correct. Supply, balances, roles — verify everything after deployment.
- Happy paths work. Transfer, mint, burn, pause, unpause.
- Access control holds. Unauthorized users cannot mint, pause, or admin.
- Edge cases revert. Minting past max supply, transferring more than balance.
- Fuzz tests. Let Foundry throw random values at your functions. It will find edge cases you did not think of.
For coverage, run forge coverage. I aim for 95%+ on every token contract. The remaining 5% is usually OpenZeppelin internals that are already tested upstream.
Deploying to Testnet
Before mainnet, always deploy to a testnet. I use Sepolia for Ethereum and the respective testnets for L2s. Here is the deployment script:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Script} from "forge-std/Script.sol";
import {ManagedToken} from "../src/ManagedToken.sol";
contract DeployToken is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
ManagedToken token = new ManagedToken(
"My Token",
"MTK",
100_000_000, // 100M initial supply
deployer
);
vm.stopBroadcast();
}
}Deploy to Sepolia:
forge script script/DeployToken.s.sol:DeployToken \
--rpc-url $SEPOLIA_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEYThe --verify flag automatically verifies the contract on Etherscan after deployment. This is not optional — an unverified contract looks suspicious to users and makes integration harder for other developers.
After deployment, I run through this testnet checklist:
- [ ] Contract verified on block explorer
- [ ] Token shows correct name, symbol, and decimals
- [ ] Transfer between two wallets works
- [ ] Mint function works with authorized wallet
- [ ] Mint reverts with unauthorized wallet
- [ ] Pause stops transfers, unpause resumes them
- [ ] Token can be added to MetaMask via contract address
- [ ] Token appears correctly on testnet DEX (if applicable)
I spend at least 24 hours on testnet. Not because the tests take that long, but because I want different team members to interact with the contract and flag anything that feels off.
Deploying to Mainnet
Mainnet deployment is the same command with a different RPC URL and real ETH for gas. But the preparation is completely different.
My mainnet pre-deployment checklist:
- All tests pass. Zero failures, zero skips.
forge testmust be clean. - Coverage above 95%.
forge coverageconfirms critical paths are tested. - Fuzz tests ran with 10,000+ runs. Set
fuzz.runs = 10000infoundry.toml. - Gas benchmarks reviewed.
forge test --gas-reportshows per-function gas costs. Transfer should be under 65,000 gas. Mint under 70,000. - Contract size checked.
forge build --sizes— must be under 24,576 bytes (the EVM limit). - Admin keys secured. The deployer wallet is not the long-term admin. After deployment, transfer admin role to a multisig (Gnosis Safe). Never leave admin control on a hot wallet.
- Emergency plan documented. If something goes wrong post-deployment, who pauses the contract? What is the communication plan?
Deploy to Ethereum mainnet:
forge script script/DeployToken.s.sol:DeployToken \
--rpc-url $MAINNET_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
--slowThe --slow flag sends transactions one at a time and waits for confirmation. On mainnet, you do not want transaction ordering issues.
For L2 deployments (Arbitrum, Base, Optimism), the process is identical — swap the RPC URL and block explorer API key. Gas costs on L2s are typically 10-50x cheaper than Ethereum mainnet, which is why most of my client tokens launch on L2 first.
# Arbitrum deployment
forge script script/DeployToken.s.sol:DeployToken \
--rpc-url $ARBITRUM_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $ARBISCAN_API_KEY
# Base deployment
forge script script/DeployToken.s.sol:DeployToken \
--rpc-url $BASE_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $BASESCAN_API_KEYPost-deployment, transfer the admin role to a multisig immediately:
// In a separate script or manual transaction
token.grantRole(DEFAULT_ADMIN_ROLE, multisigAddress);
token.revokeRole(DEFAULT_ADMIN_ROLE, deployerAddress);
token.revokeRole(MINTER_ROLE, deployerAddress);
token.revokeRole(PAUSER_ROLE, deployerAddress);Common Mistakes I Have Seen
After deploying tokens for multiple client projects, these are the mistakes that keep showing up:
1. No Max Supply on Mintable Tokens
If your token is mintable and has no cap, a compromised admin key means unlimited inflation. Always set a MAX_SUPPLY constant. Not a variable — a constant that cannot be changed.
2. Using tx.origin for Authentication
// WRONG — vulnerable to phishing attacks
require(tx.origin == owner);
// CORRECT
require(msg.sender == owner);tx.origin is the original transaction sender. If a user calls a malicious contract that then calls your token, tx.origin will be the user's address, not the malicious contract. Use msg.sender. Always.
3. No Pause Mechanism
"We will never need to pause." I have heard this from three different clients. Two of them needed to pause within six months. One was a vulnerability disclosure. The other was a compromised backend service that had minter permissions. The pause mechanism saved both projects.
4. Deploying with the Wrong Compiler Version
Solidity 0.8.24 has overflow protection built in. If you deploy with 0.7.x or use unchecked blocks carelessly, you lose that safety net. Pin your compiler version. Do not use ^0.8.0 — use 0.8.24 exactly.
5. Not Verifying on Block Explorer
An unverified contract means users cannot read the source code on Etherscan. They cannot confirm what the contract does. This kills trust and makes it harder to get listed on DEXs and token aggregators. Verify immediately after deployment.
6. Hardcoding Gas Values
// WRONG
address(receiver).call{gas: 2300}("");
// CORRECT — let the EVM estimate
address(receiver).call{value: amount}("");Gas costs change with EVM upgrades. The 2300 gas stipend that worked in 2020 is not guaranteed to work in 2025. Use estimateGas on the frontend and avoid hardcoded gas limits in contracts.
7. Skipping Testnet
"It works in tests, ship it." Tests run in a simulated environment. Testnet is a real blockchain with real block times, real mempool dynamics, and real gas estimation. I have caught frontend integration bugs on testnet that unit tests would never surface.
Key Takeaways
- Use OpenZeppelin. Do not write ERC-20 from scratch. The risk is not worth it.
- Add features incrementally. Start with the base contract. Add mint/burn/pause only when you have a concrete use case.
- Test with Foundry. Fuzz testing catches edge cases that manual tests miss. Aim for 95%+ coverage.
- Deploy to testnet first. Spend at least 24 hours there. Let your team interact with the contract.
- Transfer admin to multisig. Never leave admin control on a single hot wallet after mainnet deployment.
- Verify on block explorer. Immediately. No exceptions.
- Include a pause mechanism. You will be glad you did when something unexpected happens.
The complete contract code in this article is production-ready. I have used this exact pattern for client tokens deployed on Ethereum, Arbitrum, and Base. If you are building a token and need help with architecture, auditing, or deployment, reach out through my services page.
*Written by Uvin Vindula↗ — Web3 engineer building smart contracts, DeFi protocols, and decentralized applications. Based in Sri Lanka and the UK. Currently deploying on Ethereum, Arbitrum, Base, and Optimism. Find me at @IAMUVIN↗ or contact@uvin.lk↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.