Intermediate • OpenZeppelin ERC-20 extensions in practice

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.

Level · Intermediate
Stack · OZ Contracts, Solidity, Hardhat 3
Time · ~75 minutes
What you’ll ship EXTENDED ERC‑20
Extensions Snapshot · Pausable · ACL
Roles Admin · Pauser · Snapshotter · Minter
Use‑cases Voting · Emergency stop · Safe minting
contracts/StudioToken.sol OpenZeppelin extensions
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
Who is this for?

You already shipped a basic ERC‑20 (maybe via the previous guide). Now you want to add real‑world features without writing fragile code.

Prerequisites
You should know:
  • Basic ERC‑20 flow (balances, transfer, mint)
  • How Hardhat 3 projects are structured
  • Solidity inheritance
Nice to have:
  • Familiarity with roles / RBAC
  • Having Sepolia RPC & test ETH
Snapshots
Pausable
AccessControl
Multiple inheritance
Hardhat 3 + TS tests

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
Remove template noise

Delete the sample Lock.sol and its test. We’ll build our own token.

After init you should have:
  • hardhat.config.ts
  • contracts/
  • test/
  • toolbox‑ethers installed
We will add:
  • 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.

contracts/StudioToken.sol (base)
// 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
This is just a starting point

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)
contracts/StudioToken.sol (snapshots)
// 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);
    }
}
Why do we override _update?

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()
contracts/StudioToken.sol (add pausable)
// 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);
    }
}
This is insecure right now

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
contracts/StudioToken.sol (roles)
// 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:

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
If tests pass — you're golden

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:

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)
Production next step

Move DEFAULT_ADMIN_ROLE to a multisig and revoke deployer roles. This makes the token robust against key leaks.

Suggested next guides
  • ERC‑721 extensions (Enumerable, Royalty)
  • OZ Votes + Governor setup
  • Token security deep dive

You can link those to security.html and oz.html.

0%
done
0% · guide progress