如何在以太坊上創建可升級的 ERC-20 智能合約

胡家維 Hu Kenneth
My blockchain development Journey
33 min readApr 22, 2024

--

在我最近的一個故事中,我討論瞭如何使用OpenZeppelin 提供的代理 升級模式來建立可升級的智慧合約。在這個故事中,我想透過探索可升級的 ERC20 合約來更深入地研究這個主題。

代理升級模式

儘管部署在區塊鏈上的程式碼是不可變的,但這種模式允許我們透過使用兩個合約來修改它:代理程式和實現合約。

主要想法是用戶將始終與代理合約交互,代理合約將呼叫轉發給實現合約。要更改程式碼,我們只需要部署新版本的實作合約,並將代理設定為指向這個新的實作。

如果您對這個主題不熟悉,我建議您看一下這個故事,我在其中詳細探討了該主題。

目錄

·代理升級模式
·目錄
· ERC-20 代幣
· OpenZeppelin 的可升級合約
·動手演示
合約第一版
合約第二版
·進一步探索
·結論
·資源

ERC-20 代幣

Vitalik Buterin 和 Fabian Vogelsteller 建立了一個開發可替代代幣的框架,可替代代幣是一類數位資產或代幣,可以在一對一的基礎上與相同的對應物互換。該框架被認可為 ERC-20 標準。

如果您是這個主題的新手,我建議您查看我詳細介紹了該主題的兩個故事:

OpenZeppelin 的可升級合約

當使用代理升級模式建立可升級的智慧合約時,不可能使用建構函式。為了解決此限制,必須用常規函數(通常稱為初始值設定項)來取代建構函數。這些初始化程序通常稱為“initialize”,用作建構函數邏輯的儲存庫。

然而,我們應該確保初始化函數像建構函數一樣只被呼叫一次,因此 OpenZeppelin 提供了一個帶有初始化修飾符的可初始化基本合約來處理這個問題:

// 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;
}
}

此外,由於建構函數會自動呼叫所有合約祖先的建構函數,因此也應該在我們的初始化函數中實現,而 OpenZeppelin 允許我們透過使用onlyInitializing修飾符來實現此目的:

// 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;
}
}

考慮到上述幾點,利用 OpenZeppelin 的標準 ERC-20 合約來創建可升級代幣是不可行的。事實上,因為它們有一個建構函數,所以應該用初始化器來取代它:

// @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_;
}

...
}

然而,OpenZeppelin 透過合約的分支提供了一個解決方案:openzeppelin/contracts-upgradeable。在此修改版本中,建構函式已被初始化程序取代,從而允許以更大的靈活性創建可升級的令牌。

例如ERC20合約已被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_;
}

...
}

動手演示

為了創建新的 ERC20 可升級代幣,我使用了OpenZeppelin 嚮導來簡化合約的創建:

我選擇將令牌稱為UpgradeableToken,並設定了以下功能:

合約第一版

這是嚮導為我產生的程式碼:

// 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);
}
}

該程式碼代表我們合約的第一個版本。

我在此存儲庫中創建了一個 Hardhat 項目,其中包含整個程式碼。如果您是 Hardhat 新手,可以參考本指南,我在其中解釋瞭如何從頭開始建立新專案。

為了測試前一份合約的主要功能,我創建了以下腳本:

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;
});
});
});

合約第二版

現在讓我們想像一下,我們想要添加一個包含黑名單的新合約,該黑名單將凍結特定帳戶,防止其轉移、接收或銷毀代幣。

為了完成此任務,我創建了 UpgradeableToken 的新版本,使其繼承實現黑名單機制的 BlackList 合約:

// 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);
}
}

為了防止列入黑名單的地址傳輸、接收、刻錄或獲取一些新鑄造的代幣,已在_update函數中添加了兩個要求。事實上,每當我們呼叫transfer、burn和mint函數時,這個函數就會被呼叫。

為了測試合約的第二個版本及其新功能,使用了以下腳本:

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);
});
});
});

腳本首先在下列位置部署合約UpgradeableToken1的第一個版本:

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

然後透過部署第二個版本 UpgradeableToken2 來升級合約

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

值得注意的是,在生產環境中,使用UUPS 代理而不是透明代理可能會更明智。

進一步探索

對於那些渴望深入研究 Solidity 智能合約編碼的人,我建議探索以下資源:

結論

在本文中,我們探索了使用 OpenZeppelin 的庫和代理程式升級模式來創建可升級的 ERC20 代幣。透過提供初始版本和升級版本的程式碼片段和測試腳本,我們揭開了該過程的神秘面紗。

OpenZeppelin 的合約可升級分叉成為無縫合約演化的強大解決方案。我們的實踐測試方法強調了智慧合約開發中可靠性的重要性。

我希望它是有用的並且快樂的編碼!

資源

--

--

胡家維 Hu Kenneth
My blockchain development Journey

撰寫任何事情,O型水瓶混魔羯,咖啡愛好者,Full stack/blockchain Web3 developer,Founder of Blockchain&Dapps meetup ,Udemy teacher。 My Linktree: https://linktr.ee/kennethhutw