ERC-20 is the standard interface for fungible tokens on Ethereum. Here's how to build a production-quality one from scratch.
Project Setup
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init # Select "Create a TypeScript project"
npm install @openzeppelin/contracts
The Token Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, ERC20Burnable, ERC20Permit, Ownable {
uint256 public constant MAX_SUPPLY = 100_000_000 * 10**18; // 100M tokens
bool public mintingFinished = false;
event MintingFinished();
constructor(address initialOwner)
ERC20("My Token", "MTK")
ERC20Permit("My Token")
Ownable(initialOwner)
{
// Mint initial supply to deployer
_mint(initialOwner, 10_000_000 * 10**18); // 10M to team
}
function mint(address to, uint256 amount) external onlyOwner {
require(!mintingFinished, "Minting is finished");
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
_mint(to, amount);
}
function finishMinting() external onlyOwner {
mintingFinished = true;
emit MintingFinished();
}
}
Adding a Vesting Schedule
// TokenVesting.sol
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract TokenVesting is Ownable {
struct VestingSchedule {
uint256 totalAmount;
uint256 released;
uint256 startTime;
uint256 duration;
uint256 cliffDuration;
}
IERC20 public token;
mapping(address => VestingSchedule) public schedules;
constructor(address _token) Ownable(msg.sender) {
token = IERC20(_token);
}
function createSchedule(
address beneficiary,
uint256 amount,
uint256 startTime,
uint256 cliffDays,
uint256 vestingDays
) external onlyOwner {
require(schedules[beneficiary].totalAmount == 0, "Schedule exists");
schedules[beneficiary] = VestingSchedule({
totalAmount: amount,
released: 0,
startTime: startTime,
cliffDuration: cliffDays * 1 days,
duration: vestingDays * 1 days
});
token.transferFrom(msg.sender, address(this), amount);
}
function release() external {
VestingSchedule storage s = schedules[msg.sender];
require(s.totalAmount > 0, "No vesting schedule");
uint256 releasable = vestedAmount(msg.sender) - s.released;
require(releasable > 0, "Nothing to release");
s.released += releasable;
token.transfer(msg.sender, releasable);
}
function vestedAmount(address beneficiary) public view returns (uint256) {
VestingSchedule storage s = schedules[beneficiary];
if (block.timestamp < s.startTime + s.cliffDuration) {
return 0; // Still in cliff period
}
uint256 elapsed = block.timestamp - s.startTime;
if (elapsed >= s.duration) {
return s.totalAmount; // Fully vested
}
return (s.totalAmount * elapsed) / s.duration; // Linear vesting
}
}
Tests
// test/MyToken.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
describe("MyToken", () => {
let token: any;
let owner: any, addr1: any;
beforeEach(async () => {
[owner, addr1] = await ethers.getSigners();
const Token = await ethers.getContractFactory("MyToken");
token = await Token.deploy(owner.address);
});
it("should have correct initial supply", async () => {
const balance = await token.balanceOf(owner.address);
expect(balance).to.equal(ethers.parseEther("10000000"));
});
it("should allow owner to mint", async () => {
await token.mint(addr1.address, ethers.parseEther("1000"));
expect(await token.balanceOf(addr1.address)).to.equal(ethers.parseEther("1000"));
});
it("should prevent minting beyond max supply", async () => {
await expect(
token.mint(addr1.address, ethers.parseEther("100000000"))
).to.be.revertedWith("Exceeds max supply");
});
it("should prevent minting after finishMinting()", async () => {
await token.finishMinting();
await expect(
token.mint(addr1.address, ethers.parseEther("1000"))
).to.be.revertedWith("Minting is finished");
});
});
Deployment Script
// ignition/modules/Token.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("TokenModule", (m) => {
const deployer = m.getAccount(0);
const token = m.contract("MyToken", [deployer]);
return { token };
});
# Deploy to local hardhat network
npx hardhat ignition deploy ignition/modules/Token.ts
# Deploy to testnet (Sepolia)
npx hardhat ignition deploy ignition/modules/Token.ts --network sepolia
# Verify on Etherscan
npx hardhat verify --network sepolia DEPLOYED_ADDRESS CONSTRUCTOR_ARG
Hardhat Config for Mainnet
// hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
const config: HardhatUserConfig = {
solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } },
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL!,
accounts: [process.env.PRIVATE_KEY!]
},
mainnet: {
url: process.env.MAINNET_RPC_URL!,
accounts: [process.env.PRIVATE_KEY!],
gasPrice: "auto"
}
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY!
}
};
export default config;
Never deploy to mainnet without a full audit, especially for any token that will hold user funds.