A working ERC-20 token deployed to the Sepolia testnet, verified on Etherscan, visible in your wallet, and transferable — built with the professional write-test-deploy loop.
- The Blockchain lessons through Tokens and standards and Developer tooling — you'll use the ERC-20 interface, the develop-test-deploy loop, and a testnet
- Node.js (18+) and npm installed, and a code editor
- A browser wallet (MetaMask) with the Sepolia testnet added, and a little Sepolia test ETH from a faucet
- Hardhat — compile, test, and deploy the contract, and run scripts against local and test networks ↗
- OpenZeppelin Contracts — the audited ERC-20 base you inherit instead of writing the standard yourself ↗
- MetaMask + a Sepolia faucet — an account to deploy from and free test ETH to pay gas on the testnet ↗
- Etherscan (Sepolia) — inspect your deployed contract, verify its source, and watch transfers ↗
Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.
Set up the toolkit
Get a working Hardhat project with OpenZeppelin installed, so you can compile and test contracts locally.
- Confirm your Node.js version is 18 or newer, since Hardhat needs a current runtime. If it's older, install a newer Node before continuing.bash
node --versionCheck: The printed version is v18 or higher. - Create a project folder, initialise npm, and install Hardhat and the toolbox as dev dependencies.bash
mkdir my-token && cd my-token npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox - Initialise a Hardhat project and choose the JavaScript project when prompted. This scaffolds the folders (contracts, test, scripts) and a config file.bash
npx hardhat initCheck: You have contracts/, test/, and scripts/ folders and a hardhat.config.js. - Install OpenZeppelin Contracts, the library holding the audited ERC-20 you'll inherit from.bash
npm install @openzeppelin/contracts - Delete the sample contract and test that Hardhat generated (usually Lock.sol and its test) so you start clean, then confirm the project still compiles.bash
rm contracts/Lock.sol test/Lock.js npx hardhat compileCheck: Compilation succeeds with nothing to compile, or an empty artifacts build, and no errors.
Write the token
Create your ERC-20 by inheriting the audited base and minting an initial supply — the whole contract is a few lines.
- Create contracts/MyToken.sol. Inherit from OpenZeppelin's ERC20 and set the name and symbol in the constructor, minting an initial supply to the deployer. Remember amounts are in the smallest unit, so multiply by 10**decimals().solidity
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MyToken is ERC20 { constructor() ERC20("My Token", "MTK") { _mint(msg.sender, 1000000 * 10 ** decimals()); // 1,000,000 MTK } } - Compile the contract and fix any errors. Compilation turns your Solidity into the EVM bytecode that will be deployed.bash
npx hardhat compileCheck: Compilation succeeds and an artifact for MyToken appears under artifacts/. - Read the OpenZeppelin ERC20 source you inherited (in node_modules/@openzeppelin) and note that transfer, approve, transferFrom, balanceOf and the Transfer/Approval events all come from it — you wrote only the constructor.This is the point of standards and audited libraries: your token is fully ERC-20 compliant and every wallet will support it, yet you wrote almost no code.
Test it
Write tests that deploy the token and check its behaviour, so you trust it before spending gas on a real network.
- Create test/MyToken.js. Deploy the token in the test and check the name, symbol, and that the deployer received the full initial supply.javascript
const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("MyToken", function () { it("mints the full supply to the deployer", async function () { const [owner] = await ethers.getSigners(); const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy(); expect(await token.name()).to.equal("My Token"); const supply = await token.totalSupply(); expect(await token.balanceOf(owner.address)).to.equal(supply); }); }); - Add a test that transfers tokens from the owner to a second account and checks both balances changed correctly.javascript
it("transfers tokens between accounts", async function () { const [owner, alice] = await ethers.getSigners(); const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy(); const amount = ethers.parseUnits("100", 18); await token.transfer(alice.address, amount); expect(await token.balanceOf(alice.address)).to.equal(amount); }); - Run the test suite against Hardhat's built-in local chain. Everything runs in seconds with no gas cost.bash
npx hardhat testCheck: All tests pass. - Add one more test that confirms a transfer of more than your balance reverts, so you've exercised a failure case, and re-run the suite.Testing the failure path matters as much as the happy path — a token that lets you overspend is broken. OpenZeppelin's ERC20 reverts on insufficient balance; your test should assert that.
Deploy to a local chain
Practise the deployment itself on a free local network before touching a real testnet.
- Write scripts/deploy.js that deploys the token and prints its address. A deploy script is code you keep and version, so deployment is reproducible.javascript
const { ethers } = require("hardhat"); async function main() { const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy(); await token.waitForDeployment(); console.log("MyToken deployed to:", await token.getAddress()); } main().catch((e) => { console.error(e); process.exit(1); }); - In one terminal, start a standalone local blockchain. It prints a set of test accounts pre-funded with fake ETH.bash
npx hardhat nodeCheck: A local node is running and lists 20 funded accounts with their private keys. - In a second terminal, run the deploy script against that local node and note the printed contract address.bash
npx hardhat run scripts/deploy.js --network localhostCheck: The script prints 'MyToken deployed to: 0x...'. - Open the Hardhat console against the local node and read a value from your deployed token to confirm it responds, then stop the node when done. This proves the deployment works before spending anything on a testnet.bash
npx hardhat console --network localhostIn the console you can attach to the address and call token.name() or token.totalSupply(). It's the quickest way to sanity-check a fresh deployment interactively.
Deploy to a testnet
Put your token on the public Sepolia test network, where it behaves exactly like mainnet but the ETH is free.
- Get a Sepolia RPC URL from a node provider (such as Alchemy or Infura — a free account gives you one), and export your deployer wallet's private key from MetaMask for a throwaway test account.Use a fresh test-only account, never a wallet holding real funds. A private key in a project can leak; keep real value far away from development keys.
- Install dotenv and create a .env file holding your RPC URL and private key. Add .env to .gitignore immediately so it is never committed.bash
npm install dotenv printf 'SEPOLIA_RPC_URL=your-rpc-url\nPRIVATE_KEY=your-test-key\n' > .env echo '.env' >> .gitignoreCheck: A .env file exists with your two values, and .gitignore contains .env. - Configure the Sepolia network in hardhat.config.js, reading the URL and key from the environment so no secrets are hard-coded.javascript
require("@nomicfoundation/hardhat-toolbox"); require("dotenv").config(); module.exports = { solidity: "0.8.20", networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.PRIVATE_KEY], }, }, }; - Make sure your test account holds a little Sepolia ETH (from a faucet), then deploy to Sepolia. This is a real transaction on a shared network, so it takes a few seconds to confirm.bash
npx hardhat run scripts/deploy.js --network sepoliaCheck: The script prints the deployed address, and searching it on sepolia.etherscan.io shows your contract.
Interact and verify
Use your live token from a script and your wallet, and publish its source so anyone can read it — the finishing touches of a real deployment.
- Import the token into MetaMask by adding a custom token with your deployed contract address. Your 1,000,000 MTK balance appears — because your wallet is just reading the contract's ledger.Check: MetaMask shows your MTK balance under the Sepolia network.
- Send some MTK from your wallet to a friend's address (or a second account of yours) and watch the transfer confirm, then find the Transfer event on Etherscan.Check: The recipient's balance increases and the transfer appears on sepolia.etherscan.io.
- Verify the contract's source on Etherscan using the Hardhat verify plugin, so anyone can read and trust the code behind your token. You'll need a free Etherscan API key in your .env and the verify config added.bash
npm install --save-dev @nomicfoundation/hardhat-verify npx hardhat verify --network sepolia YOUR_CONTRACT_ADDRESSCheck: Etherscan shows a green check and displays your verified Solidity source. - Write a script that reads the total supply and the deployer's balance from the live Sepolia contract, and run it, so you've interacted with your token programmatically as well as through the wallet.bash
npx hardhat run scripts/read.js --network sepoliaA small read script (getContractAt to your address, then call totalSupply/balanceOf) confirms your deployment is reachable over the public network from code, not just the wallet UI. - Write down your token's address, network, and the transactions you made in a short README, so the project is a shareable portfolio piece — a token you designed, tested, deployed, and verified.A verified contract with a clear README is a genuine portfolio artifact. You've done the full professional loop: write, test, deploy locally, deploy to a testnet, interact, and verify.
Troubleshooting
- npx hardhat compile fails with a Solidity version error.
- The pragma in your contract and the solidity version in hardhat.config.js must be compatible. Set both to a 0.8.x version (e.g. pragma ^0.8.20 and solidity: "0.8.20"), and ensure OpenZeppelin is installed so its imports resolve.
- Deploying to Sepolia fails with 'insufficient funds'.
- Your deployer account has no Sepolia ETH. Copy the account address from MetaMask, request test ETH from a Sepolia faucet, wait for it to arrive, and try again — deployment costs gas even on a testnet.
- The deploy script errors with 'invalid private key' or 'network not found'.
- Check the .env values are loaded (require('dotenv').config() at the top of the config) and the private key has no 0x issues or stray spaces. Confirm you passed --network sepolia and that the sepolia block is present in hardhat.config.js.
- MetaMask doesn't show the token after import.
- Confirm MetaMask is on the Sepolia network, and that you pasted the exact deployed contract address. The symbol and decimals should auto-fill from the contract; if the balance is zero, you may be looking at an account that isn't the deployer.
- Etherscan verification fails with a bytecode mismatch.
- Verification must use the exact compiler version and settings you deployed with. Ensure the solidity version in hardhat.config.js matches, that optimizer settings are identical, and that you're verifying the right address on the right network.
Where to go from here
- Add a capped supply or an owner-only mint function (inherit OpenZeppelin's Ownable) so only you can create more tokens — and write the access-control tests for it.
- Make the token burnable or pausable by inheriting the matching OpenZeppelin extensions, and test the new behaviour.
- Build a tiny frontend with ethers.js that connects a wallet and shows the connected account's MTK balance, using the Connecting-a-frontend lesson.
- Deploy the same token to a Layer 2 testnet and compare the gas cost with Sepolia, connecting it to the Scaling lesson.
Did a step fail or feel unclear? Tell me which one →