How to Create Upgradable ERC-20 Smart Contracts on Ethereum

Rosario Borgesi
Coinmonks
10 min readJan 2, 2024

--

In one of my recent stories, I discussed how to create an upgradable smart contract using the proxy upgrade pattern provided by OpenZeppelin. In this story I would like todelve deeper into the subject by exploring upgradable ERC20 contracts.

Proxy Upgrade Pattern

Even though the code deployed on the blockchain is immutabile, this pattern allow us to modify it by making use of two contracts: the proxy and the implementation contract.

The main idea is that the users will always interact with the proxy contract which will forward the calls to the implementation contract. To change the code we just need to deploy a new version of the implementation contract and set the proxy to point to this new implementation.

If you are new to this topic, I suggest you take a look at this story where I have explored the subject in great detail.

Table of Contents

· Proxy Upgrade Pattern
· Table of Contents
· ERC-20 Tokens
· OpenZeppelin’s Upgradeable Contracts
· Hands-on Demonstration
First version of the contract
Second version of the contract
· Further Exploration
· Conclusions
· Resources

ERC-20 Tokens

Vitalik Buterin and Fabian Vogelsteller established a framework for developing fungible tokens, a category of digital assets or tokens interchangeable with identical counterparts on a one-to-one basis. This framework is recognized as the ERC-20 Standard.

If you are new to this topic, I suggest you check out these two stories where I covered this subject in great detail:

OpenZeppelin’s Upgradeable Contracts

When creating upgradable smart contracts with the proxy upgrade pattern it is not possible to use constructors. To address this limitation, constructors must be substituted with regular functions, commonly referred to as initializers. These initializers, typically named ‘initialize,’ serve as the repository for the constructor logic.

However we should ensure that the initialize function is called only once like a constructor and for this reason OpenZeppelin provides an Initializable base contract with and initializer modifier that takes care of this:

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
uint256 public x;

function initialize(uint256 _x) public initializer {
x = _x;
}
}

Furthermore, since a contructor automatically invokes the constructors of all contract ancestors, that should also implemented in our initializer function and OpenZeppelin allows us to accomplish this by making use of the onlyInitializing modifier:

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BaseContract is Initializable {
uint256 public y;

function initialize() public onlyInitializing {
y = 42;
}
}

contract MyContract is BaseContract {
uint256 public x;

function initialize(uint256 _x) public initializer {
BaseContract.initialize(); // Do not forget this call!
x = _x;
}
}

Considering the preceding points, utilizing OpenZeppelin’s standard ERC-20 contracts for creating upgradeable tokens is not feasible. In fact since they have a constructor it should be replaced with an initializer:

// @openzeppelin/contracts/token/ERC20/ERC20.sol
pragma solidity ^0.8.0;

...

contract ERC20 is Context, IERC20 {

...

string private _name;
string private _symbol;

constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}

...
}

However, OpenZeppelin provides a solution through a fork of the contracts: openzeppelin/contracts-upgradeable. In this modified version, constructors have been replaced by initializers, allowing for the creation of upgradeable tokens with greater flexibility.

For example the ERC20 contract has been replaced by the ERC20Upgradeable:

// @openzeppelin/contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol
pragma solidity ^0.8.0;

...

contract ERC20Upgradeable is Initializable, ContextUpgradeable, IERC20Upgradeable {
...

string private _name;
string private _symbol;

function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing {
__ERC20_init_unchained(name_, symbol_);
}

function __ERC20_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing {
_name = name_;
_symbol = symbol_;
}

...
}

Hands-on Demonstration

To create a new ERC20 upgradeable token I have made use of the OpenZeppelin Wizard that streamlines the creation of the contract:

I have chosen to call the token UpgradeableToken and I have set the following features:

First version of the contract

This is the code generated for me by the wizard:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract UpgradeableToken1 is Initializable, ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, OwnableUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function initialize(address initialOwner) initializer public {
__ERC20_init("UpgradeableToken", "UTK");
__ERC20Burnable_init();
__ERC20Pausable_init();
__Ownable_init(initialOwner);

_mint(msg.sender, 10000 * 10 ** decimals());
}

function pause() public onlyOwner {
_pause();
}

function unpause() public onlyOwner {
_unpause();
}

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

// The following functions are overrides required by Solidity.

function _update(address from, address to, uint256 value)
internal
override(ERC20Upgradeable, ERC20PausableUpgradeable)
{
super._update(from, to, value);
}
}

This code represents the first version of our contract.

I have created an Hardhat project, containing the whole code, at this repository. If you are new to Hardhat, you can refer to this guide where I have explained how to create a new project from scratch.

To test the main features of the former contract I have created the following script:

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { UpgradeableToken1, UpgradeableToken1__factory } from "../typechain-types";

describe("Contract version 1", () => {
let UpgradeableToken1: UpgradeableToken1__factory;
let token: UpgradeableToken1;
let owner: HardhatEthersSigner;
let addr1: HardhatEthersSigner;
let addr2: HardhatEthersSigner;
const DECIMALS: bigint = 10n ** 18n;
const INITIAL_SUPPLY: bigint = 10_000n;

beforeEach(async () => {
UpgradeableToken1 = await ethers.getContractFactory("UpgradeableToken1");
[owner, addr1, addr2] = await ethers.getSigners();
token = await upgrades.deployProxy(UpgradeableToken1, [owner.address], { initializer: 'initialize', kind: 'transparent'});
await token.waitForDeployment();
});

describe("Deployment", () => {
it("Should set the right name", async () => {
expect(await token.name()).to.equal("UpgradeableToken");
});

it("Should set the right symbol", async () => {
expect(await token.symbol()).to.equal("UTK");
});

it("Should set the right owner", async () => {
expect(await token.owner()).to.equal(owner.address);
});

it("Should assign the initial supply of tokens to the owner", async () => {
const ownerBalance = await token.balanceOf(owner.address);
expect(ownerBalance).to.equal(INITIAL_SUPPLY * DECIMALS);
expect(await token.totalSupply()).to.equal(ownerBalance);
});
});

describe("Transactions", () => {
it("Should transfer tokens between accounts", async () => {
// Transfer 50 tokens from owner to addr1
await token.transfer(addr1.address, 50);
const addr1Balance = await token.balanceOf(addr1.address);
expect(addr1Balance).to.equal(50);

// Transfer 50 tokens from addr1 to addr2
// We use .connect(signer) to send a transaction from another account
await token.connect(addr1).transfer(addr2.address, 50);
const addr2Balance = await token.balanceOf(addr2.address);
expect(addr2Balance).to.equal(50);
});

it("Should fail if sender doesn't have enough tokens", async () => {
const initialOwnerBalance = await token.balanceOf(owner.address);
// Try to send 1 token from addr1 (0 tokens) to owner (1000000 tokens).
expect(
token.connect(addr1).transfer(owner.address, 1)
).to.be.revertedWithCustomError;

// Owner balance shouldn't have changed.
expect(await token.balanceOf(owner.address)).to.equal(
initialOwnerBalance
);
});

it("Should update balances after transfers", async () => {
const initialOwnerBalance: bigint = await token.balanceOf(owner.address);

// Transfer 100 tokens from owner to addr1.
await token.transfer(addr1.address, 100);

// Transfer another 50 tokens from owner to addr2.
await token.transfer(addr2.address, 50);

// Check balances.
const finalOwnerBalance = await token.balanceOf(owner.address);
expect(finalOwnerBalance).to.equal(initialOwnerBalance - 150n);

const addr1Balance = await token.balanceOf(addr1.address);
expect(addr1Balance).to.equal(100);

const addr2Balance = await token.balanceOf(addr2.address);
expect(addr2Balance).to.equal(50);
});
});

describe("Minting", () => {
it("It should mint tokens to the owner's address", async () => {
await token.mint(owner.address, 10n * DECIMALS);
const ownerBalance: bigint = await token.balanceOf(owner.address);
expect(ownerBalance).to.equal((INITIAL_SUPPLY +10n) * DECIMALS);
});
});

describe("Burning", () => {
it("Should burn tokens from the owner's address", async () => {
await token.burn(10n * DECIMALS);
const ownerBalance: bigint = await token.balanceOf(owner.address);
expect(ownerBalance).to.equal((INITIAL_SUPPLY -10n) * DECIMALS);
});
});

describe("Pauseable features", () => {
it("Should pause the contract", async () => {
await token.pause();
expect(await token.paused()).to.be.true
expect( token.transfer(addr1.address, 50)).to.be.revertedWithCustomError;
});

it("Should unpause the contract", async () => {
await token.pause();
await token.unpause();
expect(await token.paused()).to.be.false
expect(await token.transfer(addr1.address, 50)).not.throw;
});
});
});

Second version of the contract

Now let’s imagine that we want to add a new contract containing a blacklist that will freeze a particular account, preventing it to trasfer, receive or burn tokens.

To accomplish this task I have createad a new version of the UpgradeableToken by making it inherit a BlackList contract that implements a blacklist mechanism:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BlackList is OwnableUpgradeable {
mapping (address => bool) internal blackList;

function isBlackListed(address maker) public view returns (bool) {
return blackList[maker];
}

function addBlackList (address evilUser) public onlyOwner {
blackList[evilUser] = true;
emit AddedBlackList(evilUser);
}

function removeBlackList (address clearedUser) public onlyOwner {
blackList[clearedUser] = false;
emit RemovedBlackList(clearedUser);
}

event AddedBlackList(address user);

event RemovedBlackList(address user);
}

contract UpgradeableToken2 is Initializable, ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, OwnableUpgradeable, BlackList {

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function pause() public onlyOwner {
_pause();
}

function unpause() public onlyOwner {
_unpause();
}

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

// The following functions are overrides required by Solidity.

function _update(address from, address to, uint256 value)
internal
override(ERC20Upgradeable, ERC20PausableUpgradeable)
{
require(!isBlackListed(from), "The sender address is blacklisted");
require(!isBlackListed(to), "The recipient address is blacklisted");
super._update(from, to, value);
}
}

To prevent a blacklisted address to transfer, receive, burn or get some new minted tokens it have been placed two requires in the _update function. In fact this functions gets called whenever we call the transfer, burn and mint functions.

To test the second version of the contract with its new features, it has been used the following script:

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { UpgradeableToken2, UpgradeableToken2__factory } from "../typechain-types";

describe("Contract version 2", () => {
let UpgradeableToken2: UpgradeableToken2__factory;
let newToken: UpgradeableToken2;
let owner: HardhatEthersSigner;
let addr1: HardhatEthersSigner;
let addr2: HardhatEthersSigner;
const DECIMALS: bigint = 10n ** 18n;
const INITIAL_SUPPLY: bigint = 10_000n;

beforeEach(async () => {
const UpgradeableToken1 = await ethers.getContractFactory("UpgradeableToken1");
UpgradeableToken2 = await ethers.getContractFactory('UpgradeableToken2');
[owner, addr1, addr2] = await ethers.getSigners();
const oldToken = await upgrades.deployProxy(UpgradeableToken1, [owner.address], { initializer: 'initialize', kind: 'transparent' });
await oldToken.waitForDeployment();
newToken = await upgrades.upgradeProxy(oldToken, UpgradeableToken2, { kind: 'transparent' });
});

describe("Deployment", () => {
it("Should set the right name", async () => {
expect(await newToken.name()).to.equal("UpgradeableToken");
});

it("Should set the right symbol", async () => {
expect(await newToken.symbol()).to.equal("UTK");
});

it("Should set the right owner", async () => {
expect(await newToken.owner()).to.equal(owner.address);
});

it("Should assign the initial supply of tokens to the owner", async () => {
const ownerBalance = await newToken.balanceOf(owner.address);
expect(ownerBalance).to.equal(INITIAL_SUPPLY * DECIMALS);
expect(await newToken.totalSupply()).to.equal(ownerBalance);
});
});

describe("Transactions", () => {
it("Should transfer tokens between accounts", async () => {
// Transfer 50 tokens from owner to addr1
await newToken.transfer(addr1.address, 50);
const addr1Balance = await newToken.balanceOf(addr1.address);
expect(addr1Balance).to.equal(50);

// Transfer 50 tokens from addr1 to addr2
// We use .connect(signer) to send a transaction from another account
await newToken.connect(addr1).transfer(addr2.address, 50);
const addr2Balance = await newToken.balanceOf(addr2.address);
expect(addr2Balance).to.equal(50);
});

it("Should fail if sender doesn't have enough tokens", async () => {
const initialOwnerBalance = await newToken.balanceOf(owner.address);
// Try to send 1 token from addr1 (0 tokens) to owner (1000000 tokens).
expect(
newToken.connect(addr1).transfer(owner.address, 1)
).to.be.revertedWithCustomError;

// Owner balance shouldn't have changed.
expect(await newToken.balanceOf(owner.address)).to.equal(
initialOwnerBalance
);
});

it("Should update balances after transfers", async () => {
const initialOwnerBalance: bigint = await newToken.balanceOf(owner.address);

// Transfer 100 tokens from owner to addr1.
await newToken.transfer(addr1.address, 100);

// Transfer another 50 tokens from owner to addr2.
await newToken.transfer(addr2.address, 50);

// Check balances.
const finalOwnerBalance = await newToken.balanceOf(owner.address);
expect(finalOwnerBalance).to.equal(initialOwnerBalance - 150n);

const addr1Balance = await newToken.balanceOf(addr1.address);
expect(addr1Balance).to.equal(100);

const addr2Balance = await newToken.balanceOf(addr2.address);
expect(addr2Balance).to.equal(50);
});
});

describe("Minting", () => {
it("It should mint tokens to the owner's address", async () => {
await newToken.mint(owner.address, 10n * DECIMALS);
const ownerBalance: bigint = await newToken.balanceOf(owner.address);
expect(ownerBalance).to.equal((INITIAL_SUPPLY +10n) * DECIMALS);
});
});

describe("Burning", () => {
it("Should burn tokens from the owner's address", async () => {
await newToken.burn(10n * DECIMALS);
const ownerBalance: bigint = await newToken.balanceOf(owner.address);
expect(ownerBalance).to.equal((INITIAL_SUPPLY -10n) * DECIMALS);
});
});

describe("Pauseable features", () => {
it("Should pause the contract", async () => {
await newToken.pause();
expect(await newToken.paused()).to.be.true
expect(newToken.transfer(addr1.address, 50)).to.be.revertedWithCustomError;
});

it("Should unpause the contract", async () => {
await newToken.pause();
await newToken.unpause();
expect(await newToken.paused()).to.be.false
await newToken.transfer(addr1.address, 50);
const addr1Balance = await newToken.balanceOf(addr1.address);
expect(addr1Balance).to.equal(50);
});
});

describe("Blacklist features", () => {
it("Should add the address to the blacklist", async () => {
expect(await newToken.isBlackListed(addr1)).to.be.false;
await newToken.addBlackList(addr1);
expect(await newToken.isBlackListed(addr1)).to.be.true;
});

it("Should remove the address from the blacklist", async () => {
await newToken.addBlackList(addr1);
await(newToken.removeBlackList(addr1));
expect(await newToken.isBlackListed(addr1)).to.be.false;
});

it("Should prevent blacklisted address to transfer funds", async () => {
await newToken.transfer(addr1.address, 10n * DECIMALS);
await newToken.addBlackList(addr1);
expect(newToken.connect(addr1).transfer(addr2.address, 50))
.to.be.revertedWith('The sender address is blacklisted');
});

it("Should allow unblacklisted address to transfer funds", async () => {
await newToken.transfer(addr1.address, 10n * DECIMALS);
await newToken.addBlackList(addr1);
await newToken.removeBlackList(addr1);
await newToken.connect(addr1).transfer(addr2.address, 50);
const addr2Balance = await newToken.balanceOf(addr2.address);
expect(addr2Balance).to.equal(50);
});
});
});

The script firstly deploys the first version of the contract UpgradeableToken1 at:

const oldToken = await upgrades.deployProxy(UpgradeableToken1, [owner.address], { initializer: 'initialize', kind: 'transparent' });

And then upgrades the contract by deploying the second version UpgradeableToken2:

newToken = await upgrades.upgradeProxy(oldToken, UpgradeableToken2, { kind: 'transparent' });

It is noteworthy that in production environment it probably would have been wiser to use a UUPS proxy instead of the transparent one.

Further Exploration

For those eager to dive into coding Solidity smart contracts, I recommend exploring the following resources:

Conclusions

In this article, we’ve explored the creation of upgradeable ERC20 tokens using OpenZeppelin’s library and the proxy upgrade pattern. By providing code snippets and testing scripts for both initial and upgraded versions, we’ve demystified the process.

OpenZeppelin’s contracts-upgradeable fork emerges as a robust solution for seamless contract evolution. Our hands-on approach to testing emphasizes the importance of reliability in smart contract development.

I hope that it has been useful and happy coding!

Resources

--

--