Ship your first ERC-20 token
in ~60 minutes.
In this guide you’ll create, test and deploy a real ERC-20 token using Hardhat 3, TypeScript and OpenZeppelin Contracts. No magic, just a clean step-by-step workflow used in real projects.
contract MyToken is ERC20 {
constructor(uint256 initialSupply)
ERC20("MyToken", "MTK")
{
_mint(msg.sender, initialSupply);
}
}
What you’ll learn
This is a practical beginner-friendly guide. You won’t just copy/paste code — you’ll understand the full flow of shipping a token:
- Setting up a clean Hardhat 3 + TypeScript project
- Creating an ERC-20 token with OpenZeppelin Contracts
- Writing a couple of focused tests with ethers
- Deploying to a local network and to Sepolia testnet
- Checking your token on a block explorer
If you already know a bit of JavaScript or TypeScript and want to touch real smart contracts without drowning in theory — this is exactly your level.
- ✓ Basic JS / TS syntax
- ✓ Using a terminal
- ✓ Installing npm packages
- • MetaMask installed
- • A Sepolia RPC URL (Alchemy / Infura)
Set up your project
1. Create a folder for this guide:
mkdir erc20-guide
cd erc20-guide
npm init -y # or pnpm init
2. Install Hardhat 3 as a dev dependency:
npm install --save-dev hardhat
# or
pnpm add -D hardhat
3. Initialize a new Hardhat TypeScript project:
npx hardhat
Choose:
- “Create a TypeScript project”
- Yes — add .gitignore
- Yes — install recommended dependencies
Keep Hardhat installed locally per project and run it through npx hardhat. Global installs create version hell.
- ✓ hardhat.config.ts
- ✓ contracts/Lock.sol sample
- ✓ test/Lock.ts sample test
- ✓ tsconfig.json
We’ll now remove the Lock example and build our own ERC-20 implementation using OpenZeppelin.
Create the ERC-20 token
Install OpenZeppelin Contracts:
npm install @openzeppelin/contracts
Inside contracts/, create MyToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor(uint256 initialSupply) ERC20("MyToken", "MTK") {
_mint(msg.sender, initialSupply);
}
}
This contract:
- Uses the battle-tested ERC20 implementation from OpenZeppelin
- Sets the name to MyToken and symbol to MTK
- Mints the whole initialSupply to the deployer address
Implementing ERC-20 manually is easy to mess up and unsafe. In production, teams always use OpenZeppelin or similar audited libraries.
Compile the project to make sure everything works:
npx hardhat compile
Write a couple of tests
We’ll write two simple tests:
- Initial supply is minted to the deployer
- Transfers work and balances change as expected
Create test/MyToken.ts:
import { expect } from "chai";
import { ethers, loadFixture } from "hardhat";
describe("MyToken", function () {
async function deployTokenFixture() {
const [owner, user] = await ethers.getSigners();
// 1,000,000 MTK with 18 decimals
const initialSupply = 1_000_000n * 10n ** 18n;
const Token = await ethers.getContractFactory("MyToken");
const token = await Token.deploy(initialSupply);
await token.waitForDeployment();
return { token, owner, user, initialSupply };
}
it("mints initial supply to the deployer", async () => {
const { token, owner, initialSupply } =
await loadFixture(deployTokenFixture);
expect(await token.totalSupply()).to.equal(initialSupply);
expect(await token.balanceOf(owner.address)).to.equal(initialSupply);
});
it("transfers tokens between accounts", async () => {
const { token, owner, user } =
await loadFixture(deployTokenFixture);
await expect(
token.transfer(user.address, 1000n)
).to.changeTokenBalances(
token,
[owner, user],
[-1000n, 1000n]
);
});
});
Run the tests:
npx hardhat test
You should see:
MyToken
✓ mints initial supply to the deployer
✓ transfers tokens between accounts
2 passing
You can define a small helper to avoid manually writing 1018:
function tokens(n: number) {
return BigInt(n) * 10n ** 18n;
}
Then call: token.transfer(user.address, tokens(1000));
Deploy to a testnet
We’ll deploy first to the built-in Hardhat network, then to Sepolia.
1. Create a deploy script
In scripts/, create deploy-token.ts:
import { network } from "hardhat";
async function main() {
const { ethers, networkName } = await network.connect();
console.log(`Deploying MyToken to ${networkName}...`);
const initialSupply = 1_000_000n * 10n ** 18n;
const Token = await ethers.getContractFactory("MyToken");
const token = await Token.deploy(initialSupply);
await token.waitForDeployment();
const address = await token.getAddress();
console.log("MyToken deployed at:", address);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
2. Deploy to the local Hardhat network
Simply run:
npx hardhat run scripts/deploy-token.ts
This uses the default simulated network from Hardhat and prints the address. Good for quick sanity checks.
3. Configure Sepolia network
Open hardhat.config.ts and add a basic Sepolia config. For a beginner-friendly version you can use environment variables via process.env:
import { config as loadEnv } from "dotenv";
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox-ethers";
loadEnv();
const config: HardhatUserConfig = {
solidity: {
version: "0.8.20",
settings: {
optimizer: { enabled: true, runs: 200 },
},
},
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL,
accounts: process.env.DEPLOYER_PRIVATE_KEY
? [process.env.DEPLOYER_PRIVATE_KEY]
: [],
},
},
};
export default config;
In your project root create a .env:
SEPOLIA_RPC_URL=<your-sepolia-rpc-url>
DEPLOYER_PRIVATE_KEY=<private-key-with-test-eth>
Make sure .env is in .gitignore. Use this guide only with testnets, never paste your real mainnet private key here.
4. Deploy to Sepolia
Now run the same script, but specifying the network:
npx hardhat run scripts/deploy-token.ts --network sepolia
You’ll see logs and the deployed token address. Save this address — we’ll need it for Etherscan and for interacting with the token later.
Verify the contract (optional)
Verifying your contract on Etherscan makes the source code publicly readable and enables the “Write / Read” tabs in the explorer.
1. Install Etherscan plugin (Toolbox)
If you started from Hardhat’s TypeScript template with toolbox, you likely already have it. If not:
npm install --save-dev @nomicfoundation/hardhat-toolbox-ethers
Make sure it’s imported in hardhat.config.ts:
import "@nomicfoundation/hardhat-toolbox-ethers";
2. Add Etherscan API key
Extend your .env:
ETHERSCAN_API_KEY=<your-etherscan-api-key>
And the config:
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
}
3. Verify the token
Use the deployed address printed by your deploy script and run:
npx hardhat verify --network sepolia <deployed_address> 10000000000000000000000000
The last argument is the initialSupply you passed to the constructor (1,000,000 tokens with 18 decimals).
Where to go from here
Nice! You’ve just:
- Initialized a Hardhat 3 + TypeScript project
- Created an ERC-20 token with OpenZeppelin
- Wrote tests using ethers & matchers
- Deployed to a local network and to Sepolia
- (Optionally) Verified the contract on Etherscan
This is a solid starting point for real-world Web3 development. In the next guides you can:
- Build an ERC-721 NFT collection
- Add access control and pausable features
- Connect your contracts to a frontend dApp
- Learn basic security patterns and common pitfalls
- → Hardhat 3 deep dive · networks, forking, plugins
- → OpenZeppelin extensions · burnable, pausable, ownable
- → Security basics · reentrancy, checks-effects-interactions
You can link these to other pages on your site like hardhat.html, oz.html or security.html to create a complete learning path.