May 5, 2026·10 min·By

Build and Deploy an ERC-20 Token in Under an Hour

ERC-20SolidityHardhatEthereumWeb3tokens

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.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

What tech stack does Innovibe use for projects like this?+

Next.js + TypeScript on the frontend, Node.js or Go on the backend, Postgres for the primary data store, and GCP (Cloud Run, BigQuery, Pub/Sub) for infrastructure. We pick tools that are boring in the best way — proven, well-documented, and easy to hire for.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation