Level‑up your ERC‑20 token
with real extensions.
In this guide you’ll take a simple ERC‑20 and turn it into a production‑ready token: snapshots for voting, pausing for emergencies, and role‑based access control for safe minting. Everything is hands‑on, using OpenZeppelin Contracts and Hardhat 3.
contract StudioToken is
ERC20,
ERC20Snapshot,
ERC20Pausable,
AccessControl
{
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant SNAPSHOT_ROLE = keccak256("SNAPSHOT_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
...
}
What you’ll learn
This intermediate guide is all about using OZ extensions the way real projects do: not “import everything”, but understanding why each piece exists and how to combine them safely.
- How snapshots store historical balances for voting and airdrops
- How pausing actually blocks transfers and minting
- How to set up AccessControl with minimal power per role
- How to resolve multiple‑inheritance hooks correctly
- Writing tests that prove extensions work together
You already shipped a basic ERC‑20 (maybe via the previous guide). Now you want to add real‑world features without writing fragile code.
- ✓ Basic ERC‑20 flow (balances, transfer, mint)
- ✓ How Hardhat 3 projects are structured
- ✓ Solidity inheritance
- • Familiarity with roles / RBAC
- • Having Sepolia RPC & test ETH
Set up a clean workspace
We’ll start from a fresh Hardhat 3 TypeScript project. If you already have one, you can reuse it — just adapt paths.
1. Create a folder:
mkdir oz-erc20-extensions
cd oz-erc20-extensions
npm init -y
2. Install Hardhat 3:
npm install --save-dev hardhat
3. Initialize TS template:
npx hardhat
Choose:
- Create a TypeScript project
- Add .gitignore
- Install recommended deps
Delete the sample Lock.sol and its test. We’ll build our own token.
- ✓ hardhat.config.ts
- ✓ contracts/
- ✓ test/
- ✓ toolbox‑ethers installed
- → OpenZeppelin Contracts
- → An extended token
- → TS tests for each extension
Install OpenZeppelin Contracts
OZ Contracts is basically the standard library of Ethereum. We’ll use it for safe, audited extensions.
npm install @openzeppelin/contracts
In this guide we’ll rely on OZ v5+ style (Solidity ≥ 0.8.20). If your project is older, upgrade first.
What extensions are we using? click to expand
- ERC20Snapshot — saves historical balances at a “snapshot id”. Useful for voting or airdrops.
- ERC20Pausable — allows an authorized account to pause/unpause transfers.
- AccessControl — role‑based access control. Safer than a single owner key.
Create a base ERC‑20 token
Create contracts/StudioToken.sol. Start with a minimal ERC‑20 that we’ll extend.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract StudioToken is ERC20 {
constructor(uint256 initialSupply)
ERC20("StudioToken", "STUDIO")
{
_mint(msg.sender, initialSupply);
}
}
Compile to confirm setup:
npx hardhat compile
We keep it simple so that extension effects are easy to verify.
Add Snapshots (historical balances)
Snapshots record account balances at a point in time. This solves a common problem in governance: you want to know how many tokens someone had when voting started, not today.
We’ll extend our token with ERC20Snapshot. That gives us:
- snapshot() → creates a new snapshot id
- balanceOfAt(account, id)
- totalSupplyAt(id)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
contract StudioToken is ERC20, ERC20Snapshot {
constructor(uint256 initialSupply)
ERC20("StudioToken", "STUDIO")
{
_mint(msg.sender, initialSupply);
}
function snapshot() external returns (uint256) {
return _snapshot();
}
// --- Overrides required by Solidity ---
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Snapshot)
{
super._update(from, to, value);
}
}
In OZ v5, transfer/mint/burn logic funnels through _update. Both ERC20 and Snapshot modify it, so Solidity needs a single final override.
Recompile:
npx hardhat compile
Snapshot mental model read this if unsure
Snapshot does not store full balances every time. It stores checkpoints only when balances change. If an account doesn’t change between snapshot A and B, its balance is read from the latest checkpoint.
That’s why snapshots are efficient even with many holders.
Add Pausable (emergency stop)
Pausable is a safety switch. If something goes wrong (exploit, oracle bug, governance attack) you want to stop transfers fast.
We’ll add ERC20Pausable and expose:
- pause()
- unpause()
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
contract StudioToken is ERC20, ERC20Snapshot, ERC20Pausable {
constructor(uint256 initialSupply)
ERC20("StudioToken", "STUDIO")
{
_mint(msg.sender, initialSupply);
}
function snapshot() external returns (uint256) {
return _snapshot();
}
// Simple pause controls for now (we'll secure them with roles next step)
function pause() external {
_pause();
}
function unpause() external {
_unpause();
}
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Snapshot, ERC20Pausable)
{
super._update(from, to, value);
}
}
Anyone can pause/unpause. That’s intentional for the moment — next step we lock it behind roles.
Add AccessControl (roles)
A single owner key is dangerous. If compromised, the whole token is done. Instead, we use role‑based access control: different keys for different operations.
We’ll add OZ AccessControl and define roles:
- DEFAULT_ADMIN_ROLE — can grant/revoke roles
- PAUSER_ROLE — can pause/unpause
- SNAPSHOT_ROLE — can call snapshot
- MINTER_ROLE — can mint more tokens
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract StudioToken is ERC20, ERC20Snapshot, ERC20Pausable, AccessControl {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant SNAPSHOT_ROLE = keccak256("SNAPSHOT_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor(uint256 initialSupply)
ERC20("StudioToken", "STUDIO")
{
address admin = msg.sender;
_mint(admin, initialSupply);
// grant all roles to deployer for now
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);
_grantRole(SNAPSHOT_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
}
function snapshot() external onlyRole(SNAPSHOT_ROLE) returns (uint256) {
return _snapshot();
}
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(PAUSER_ROLE) {
_unpause();
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Snapshot, ERC20Pausable)
{
super._update(from, to, value);
}
}
Role design best‑practice production notes
In real deployments you usually:
- assign admin role to a multisig / timelock
- separate minter from pauser
- revoke deployer’s power after setup
We keep a single admin for clarity, but the contract supports splitting roles anytime.
Write tests for each extension
Tests are where extensions prove their value. We’ll verify:
- Snapshots freeze historical balances
- Pausable blocks transfers
- Roles gate sensitive functions
Create test/StudioToken.ts:
import { expect } from "chai";
import { ethers, loadFixture } from "hardhat";
describe("StudioToken (extensions)", function () {
async function deployFixture() {
const [admin, user, pauser, snapshotter, minter] =
await ethers.getSigners();
const initialSupply = 1_000_000n * 10n ** 18n;
const Token = await ethers.getContractFactory("StudioToken");
const token = await Token.deploy(initialSupply);
await token.waitForDeployment();
// role ids from contract
const PAUSER_ROLE = await token.PAUSER_ROLE();
const SNAPSHOT_ROLE = await token.SNAPSHOT_ROLE();
const MINTER_ROLE = await token.MINTER_ROLE();
// grant separate roles to different accounts (simulate production)
await token.grantRole(PAUSER_ROLE, pauser.address);
await token.grantRole(SNAPSHOT_ROLE, snapshotter.address);
await token.grantRole(MINTER_ROLE, minter.address);
return {
token,
admin,
user,
pauser,
snapshotter,
minter,
initialSupply,
PAUSER_ROLE,
SNAPSHOT_ROLE,
MINTER_ROLE,
};
}
it("snapshots historical balances", async () => {
const { token, admin, user, snapshotter } =
await loadFixture(deployFixture);
// transfer before snapshot
await token.transfer(user.address, 1000n);
// snapshot (only snapshotter)
const tx = await token.connect(snapshotter).snapshot();
const receipt = await tx.wait();
const id = receipt?.logs[0]?.args?.id ?? 1n;
// balances after snapshot change
await token.transfer(user.address, 500n);
// historical balance is frozen
expect(await token.balanceOfAt(user.address, id)).to.equal(1000n);
expect(await token.balanceOf(user.address)).to.equal(1500n);
});
it("pausable blocks transfers", async () => {
const { token, user, pauser } =
await loadFixture(deployFixture);
await token.connect(pauser).pause();
await expect(
token.transfer(user.address, 1n)
).to.be.revertedWithCustomError(token, "EnforcedPause");
await token.connect(pauser).unpause();
await expect(token.transfer(user.address, 1n)).to.not.be.reverted;
});
it("roles gate minting", async () => {
const { token, user, minter } =
await loadFixture(deployFixture);
// user can't mint
await expect(
token.connect(user).mint(user.address, 1n)
).to.be.reverted;
// minter can
await expect(
token.connect(minter).mint(user.address, 100n)
).to.not.be.reverted;
expect(await token.balanceOf(user.address)).to.equal(100n);
});
});
Run tests:
npx hardhat test
You now have strong proof that extensions don’t interfere with each other.
Deploy and verify
Deployment is identical to the previous Hardhat guide — but now your token has roles. We'll deploy with an initial supply and keep admin roles for the deployer.
Create scripts/deploy-token.ts:
import { network } from "hardhat";
async function main() {
const { ethers, networkName } = await network.connect();
const initialSupply = 1_000_000n * 10n ** 18n;
console.log(`Deploying StudioToken to ${networkName}...`);
const Token = await ethers.getContractFactory("StudioToken");
const token = await Token.deploy(initialSupply);
await token.waitForDeployment();
console.log("StudioToken deployed at:", await token.getAddress());
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Local deploy:
npx hardhat run scripts/deploy-token.ts
Sepolia deploy (after config env vars):
npx hardhat run scripts/deploy-token.ts --network sepolia
Verification reminder same as previous guide
Add Etherscan key to .env and config, then:
npx hardhat verify --network sepolia <address> <initialSupply>
Initial supply is the constructor arg used at deployment.
What you achieved
Nice. You now have a token that resembles real products:
- Snapshots for governance / airdrops
- Pausable emergency stop
- AccessControl to split power safely
- Correct multi‑inheritance overrides
- Tests proving the whole stack works
You can extend further with:
- Burnable tokens (ERC20Burnable)
- Permit approvals (ERC20Permit)
- Capped supply (ERC20Capped)
- Votes module (ERC20Votes)
Move DEFAULT_ADMIN_ROLE to a multisig and revoke deployer roles. This makes the token robust against key leaks.
- → ERC‑721 extensions (Enumerable, Royalty)
- → OZ Votes + Governor setup
- → Token security deep dive
You can link those to security.html and oz.html.