如何在以太坊上創建可升級的 ERC-20 智能合約
在我最近的一個故事中,我討論瞭如何使用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 智能合約編碼的人,我建議探索以下資源:
- 如何在以太坊上使用 Hardhat 開發智能合約。我們詳細的文章將引導您完成使用 Hardhat(一個強大的以太坊開發環境)創建智慧合約的過程。您將深入了解編寫和部署 Solidity 程式碼。
- 如何使用 OpenZeppelin 的 ERC-20 合約創建您自己的加密貨幣。在本教程中,我們將探索基於廣泛使用的 ERC-20 標準創建自訂加密貨幣。了解如何使用 OpenZeppelin 久經考驗的合約來建立自己的數位貨幣。
- 如何使用 OpenZeppelin 在以太坊上創建 ERC-721 NFT:逐步教程。使用 ERC-721 標準深入了解不可替代代幣 (NFT) 的世界。我們將引導您完成創建適合藝術家、收藏家和愛好者的獨特數位資產的過程。
結論
在本文中,我們探索了使用 OpenZeppelin 的庫和代理程式升級模式來創建可升級的 ERC20 代幣。透過提供初始版本和升級版本的程式碼片段和測試腳本,我們揭開了該過程的神秘面紗。
OpenZeppelin 的合約可升級分叉成為無縫合約演化的強大解決方案。我們的實踐測試方法強調了智慧合約開發中可靠性的重要性。
我希望它是有用的並且快樂的編碼!