Web3 中级实战:用 Solidity 与 Ethers.js 构建并部署一个可升级的 ERC-20 代币合约
很多人第一次写 ERC-20,都能很快跑通一个“能发币、能转账”的版本。但一到真实项目环境,问题就来了:
- 合约上线后发现要补权限控制,怎么办?
- 需要新增
pause、mint、blacklist等功能,原地址不能变怎么办? - 前端、后端、索引服务都已经绑定了代币地址,重发一个新合约成本很高怎么办?
这时,“可升级合约”就不再是锦上添花,而是工程上的刚需。
这篇文章我会带你完整做一遍:用 Solidity + OpenZeppelin Upgrades + Ethers.js,构建、部署并验证一个可升级的 ERC-20 代币合约。我会尽量站在“已经写过基础合约,但对升级代理还没完全吃透”的中级读者视角来讲,尤其会把几个常见坑讲透。
背景与问题
传统的 ERC-20 合约一旦部署,代码不可变。不可变当然有安全上的好处,但也意味着:
-
功能难迭代
业务方经常会在上线后新增需求,比如白名单、暂停交易、增发规则、治理权限等。 -
地址迁移成本高
如果重发新币,用户资产、交易所接入、DApp 配置、前端展示都要迁移。 -
审计与运维成本上升
一次次重部署,不仅增加出错概率,也让外部系统难以稳定对接。
所以工程上常见的做法是:代理合约 + 逻辑合约分离。
用户和外部系统始终交互的是代理地址,升级时只替换逻辑实现,不换代理地址。
前置知识与环境准备
如果你已经熟悉 Hardhat、Solidity 基础语法、ERC-20 标准接口,这部分可以快速扫一遍。
你需要准备
- Node.js 18+
- npm 或 pnpm
- 一个测试网 RPC URL
- 一个测试钱包私钥
- 少量测试网 ETH
我们会用到的库
hardhat@openzeppelin/contracts-upgradeable@openzeppelin/hardhat-upgradesethers
初始化项目
mkdir upgradeable-erc20-demo
cd upgradeable-erc20-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades ethers dotenv
npx hardhat
选择一个基础 JavaScript 项目即可。
安装完成后的目录建议
.
├── contracts
├── scripts
├── test
├── hardhat.config.js
└── .env
核心原理
在写代码前,先把升级代理模型想明白,不然后面很容易“会抄代码,但不知道为什么这样写”。
可升级合约的基本结构
flowchart LR
U[用户/前端/Ethers.js] --> P[Proxy 代理合约]
P -->|delegatecall| I[V1/V2 实现合约]
A[管理员] -->|upgradeTo| P
这里有三个关键角色:
- Proxy(代理合约):对外暴露固定地址
- Implementation(实现合约):真正的业务逻辑代码
- Admin(管理员):有权限将代理指向新的实现合约
为什么不能用 constructor?
普通合约部署时,constructor 只会执行一次,且执行在实现合约自身上下文里。
但代理模式下,状态存储在代理合约里,逻辑通过 delegatecall 执行。
所以升级合约必须用:
initializerreinitializer
来代替构造函数。
状态为什么不能随便改顺序?
因为升级时,代理的存储布局不会变,新实现合约必须按兼容布局读取旧数据。
如果你在 V2 中随意调整变量顺序、删除旧变量,旧存储槽就会被错误解释,结果往往是灾难性的。
一张更直观的调用时序图
sequenceDiagram
participant C as Client/Ethers.js
participant P as Proxy
participant L as Logic Contract
participant S as Proxy Storage
C->>P: transfer(to, amount)
P->>L: delegatecall transfer(...)
L->>S: 读写余额、总供应量
L-->>P: 返回执行结果
P-->>C: 交易回执
注意:逻辑合约代码执行了,但存储写在代理合约上。
方案选型:为什么这里用 UUPS
OpenZeppelin 常见升级方案主要有 Transparent Proxy 和 UUPS。
本篇选择 UUPS,原因很实际:
- 部署更轻量
- 升级逻辑写在实现合约中
- 工程上更贴近中级开发者会接触到的现代方案
当然,UUPS 也意味着你要更谨慎地控制 _authorizeUpgrade()。
简单对比
| 方案 | 特点 | 适用场景 |
|---|---|---|
| Transparent Proxy | 管理分层清晰,历史更广泛 | 团队已有成熟管理流程 |
| UUPS | 更轻量,灵活性高 | 希望降低代理层复杂度 |
如果你是第一次在项目中使用升级模式,我的建议是:先用 OpenZeppelin 官方插件,不要自己手搓代理合约。
实战代码(可运行)
下面我们从一个最小但实用的版本开始:
- ERC-20
- Ownable 权限管理
- 可增发
- 可升级(UUPS)
第一步:编写 V1 合约
创建 contracts/MyTokenV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract MyTokenV1 is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
function initialize(
string memory name_,
string memory symbol_,
uint256 initialSupply_,
address initialOwner_
) public initializer {
__ERC20_init(name_, symbol_);
__Ownable_init(initialOwner_);
__UUPSUpgradeable_init();
_mint(initialOwner_, initialSupply_);
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
这里有几个关键点
1)继承的是 contracts-upgradeable
不要写成普通 OpenZeppelin 包里的:
ERC20.solOwnable.sol
而应该用升级版:
ERC20Upgradeable.solOwnableUpgradeable.sol
2)用 initialize() 代替 constructor
因为代理不会走实现合约的构造函数。
3)_authorizeUpgrade() 必须实现
UUPS 的升级权限控制入口就在这里。
本示例用 onlyOwner,真实项目里你也可以换成多签或治理合约。
第二步:配置 Hardhat
创建 hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
require("dotenv").config();
const { RPC_URL, PRIVATE_KEY } = process.env;
module.exports = {
solidity: "0.8.20",
networks: {
sepolia: {
url: RPC_URL || "",
accounts: PRIVATE_KEY ? [PRIVATE_KEY] : [],
},
},
};
创建 .env
RPC_URL=https://sepolia.infura.io/v3/your_project_id
PRIVATE_KEY=your_private_key_without_0x
提醒一句:测试可以直接用私钥,生产环境更建议接硬件钱包、多签或专门的密钥托管方案。别把主网私钥明文放到仓库里,我真的见过有人这么干。
第三步:部署代理合约
创建 scripts/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with:", deployer.address);
const Token = await ethers.getContractFactory("MyTokenV1");
const initialSupply = ethers.parseUnits("1000000", 18);
const proxy = await upgrades.deployProxy(
Token,
["My Upgradeable Token", "MUT", initialSupply, deployer.address],
{
initializer: "initialize",
kind: "uups",
}
);
await proxy.waitForDeployment();
const proxyAddress = await proxy.getAddress();
console.log("Proxy deployed to:", proxyAddress);
const implementationAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
console.log("Implementation deployed to:", implementationAddress);
console.log("name:", await proxy.name());
console.log("symbol:", await proxy.symbol());
console.log("totalSupply:", (await proxy.totalSupply()).toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行部署:
npx hardhat run scripts/deploy.js --network sepolia
如果一切顺利,你会看到:
- 代理地址
- 当前实现合约地址
- 代币名称、符号、总供应量
第四步:用 Ethers.js 调用合约
虽然 Hardhat 脚本内部已经能直接调用,但很多人真正关心的是:前端或 Node.js 服务怎么对代理地址读写?
创建 scripts/interact.js
const { ethers } = require("hardhat");
async function main() {
const proxyAddress = "YOUR_PROXY_ADDRESS";
const token = await ethers.getContractAt("MyTokenV1", proxyAddress);
const [signer, user] = await ethers.getSigners();
console.log("Caller:", signer.address);
console.log("Balance before:", (await token.balanceOf(user.address)).toString());
const mintAmount = ethers.parseUnits("1000", 18);
const tx = await token.mint(user.address, mintAmount);
await tx.wait();
console.log("Balance after:", (await token.balanceOf(user.address)).toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
npx hardhat run scripts/interact.js --network sepolia
这里有个重要理解:
你连的是代理地址,但 ABI 用的是实现合约的接口。
这正是升级代理最常见、也最容易让初学者迷糊的地方。
第五步:升级到 V2
现在我们给代币增加一个暂停转账功能。
创建 contracts/MyTokenV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./MyTokenV1.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol";
contract MyTokenV2 is MyTokenV1, ERC20PausableUpgradeable {
function initializeV2() public reinitializer(2) {
__ERC20Pausable_init();
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
function _update(address from, address to, uint256 value)
internal
override(ERC20Upgradeable, ERC20PausableUpgradeable)
{
super._update(from, to, value);
}
}
为什么这里用 reinitializer(2)?
因为 V1 已经执行过 initializer。
V2 新增模块初始化时,要使用版本号递增的 reinitializer,避免重复初始化。
第六步:执行升级
创建 scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const proxyAddress = "YOUR_PROXY_ADDRESS";
const TokenV2 = await ethers.getContractFactory("MyTokenV2");
const upgraded = await upgrades.upgradeProxy(proxyAddress, TokenV2);
await upgraded.waitForDeployment();
console.log("Proxy upgraded:", await upgraded.getAddress());
const implementationAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
console.log("New implementation:", implementationAddress);
const tx = await upgraded.initializeV2();
await tx.wait();
console.log("V2 initialized");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
npx hardhat run scripts/upgrade.js --network sepolia
第七步:验证升级后状态是否保留
升级最核心的验证,不是“新函数能不能调”,而是:
- 旧余额还在不在?
totalSupply是否一致?- owner 是否保持正确?
- 代理地址是否不变?
创建 scripts/verify-upgrade.js
const { ethers } = require("hardhat");
async function main() {
const proxyAddress = "YOUR_PROXY_ADDRESS";
const token = await ethers.getContractAt("MyTokenV2", proxyAddress);
const [owner, user] = await ethers.getSigners();
console.log("Proxy:", proxyAddress);
console.log("Owner:", await token.owner());
console.log("Total Supply:", (await token.totalSupply()).toString());
console.log("Owner Balance:", (await token.balanceOf(owner.address)).toString());
console.log("User Balance:", (await token.balanceOf(user.address)).toString());
let tx = await token.pause();
await tx.wait();
console.log("Token paused");
try {
tx = await token.transfer(user.address, ethers.parseUnits("1", 18));
await tx.wait();
} catch (e) {
console.log("Transfer failed as expected while paused");
}
tx = await token.unpause();
await tx.wait();
console.log("Token unpaused");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
升级流程全景图
flowchart TD
A[编写 MyTokenV1] --> B[deployProxy 部署代理]
B --> C[前端/脚本使用代理地址]
C --> D[编写 MyTokenV2]
D --> E[upgradeProxy 升级实现]
E --> F[执行 initializeV2]
F --> G[验证旧状态保留]
G --> H[验证新功能可用]
逐步验证清单
这是我自己比较习惯的一套检查方式,适合你每做完一步就确认一次。
V1 部署后
-
name()正确 -
symbol()正确 -
totalSupply()正确 -
owner()正确 -
balanceOf(owner)等于初始供应量 -
mint()只有 owner 能调
升级到 V2 后
- 代理地址未变化
- 实现合约地址已变化
- 旧余额未丢失
-
totalSupply()未异常变化 -
pause()/unpause()可用 - 暂停时
transfer()会失败
常见坑与排查
这一部分很重要。我自己第一次接升级合约时,90% 的时间都花在这些“看起来不大,实际上很致命”的问题上。
1. 使用了普通合约库而不是 Upgradeable 版本
现象
部署时可能没报错,但升级后初始化、权限或状态异常。
排查
检查 import 是否来自:
@openzeppelin/contracts-upgradeable/...
而不是:
@openzeppelin/contracts/...
原因
普通版本默认设计给非代理合约使用,构造函数、初始化链路都不同。
2. 写了 constructor
现象
合约部署成功,但代理上的状态没有初始化。
排查
看看是否写了类似:
constructor() ERC20("Token", "TKN") {}
正确做法
改成 initialize():
function initialize(...) public initializer { ... }
3. 升级后存储布局被破坏
现象
升级后余额乱了、owner 变了、总供应量不对。
常见错误
- 修改已有状态变量顺序
- 删除旧变量
- 在继承结构中插入会改变布局的父合约
- 粗暴重构 storage
建议
- 永远把新增变量追加到后面
- 不要删除旧状态变量
- 使用 OpenZeppelin Upgrades 插件的存储布局检查
- 每次升级都做测试网验证
4. 忘了调用 initializeV2()
现象
新功能存在,但模块内部状态没初始化,行为异常。
解释
新增模块如果需要初始化,必须显式调用 reinitializer() 对应函数。
排查方式
查看升级脚本中是否在 upgradeProxy 后执行了:
await upgraded.initializeV2();
5. _authorizeUpgrade() 权限配置错误
现象
任何人都能升级,或者连 owner 都升不了。
正确实现最小示例
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
进阶建议
生产环境中更推荐:
- Gnosis Safe 多签
- Timelock
- DAO 治理合约
6. 连接了实现合约地址而不是代理地址
现象
读到的数据为空,或写入后看起来“不生效”。
排查
确认前端、脚本、后端服务连接的地址是不是代理地址。
经验提醒
这个坑我自己也踩过:
部署输出里 implementation 地址看着“像最新的”,但业务方真正该保存的是 proxy 地址。
安全/性能最佳实践
可升级合约的关键不是“能升级”,而是“能安全地升级”。
1. 升级权限不要给单一热钱包
最简单的 onlyOwner 适合教程和测试,但生产上风险偏高。建议至少用:
- 多签钱包
- 延迟升级(Timelock)
- 升级前公告和观察窗口
如果你的代币已经有真实用户,升级权限相当于“改协议规则”的最高权力,不能轻视。
2. 尽量减少升级频率
理论上可升级,不代表应该频繁升级。
每次升级都会带来:
- 存储兼容风险
- 审计成本
- 用户信任波动
- 前端/后端联调成本
建议把版本节奏控制在“明确需求、充分测试、合并升级”。
3. 保持存储布局稳定
这里再强调一次,因为它真的太关键了。
推荐策略
- 旧变量不删
- 不改顺序
- 新变量追加
- 升级前运行插件检查
- 对关键状态做快照对比测试
4. 对初始化函数做保护
升级合约里最怕的不是函数写错,而是初始化入口被重复调用或被别人抢先调用。
正确做法:
- 使用
initializer - 使用
reinitializer(n) - 不暴露不必要的初始化入口
- 部署后尽快初始化
5. 关注 gas 与功能边界
代理模式本身会带来一点额外开销。通常 ERC-20 这类场景完全可接受,但如果你打算把非常复杂的循环、批量计算、链上治理逻辑都堆进去,就要重新评估 gas 成本。
建议边界
适合放链上的:
- 权限控制
- 余额与转账
- 暂停/增发/销毁
- 少量治理参数
不适合重度堆链上的:
- 大批量循环
- 大规模名单处理
- 复杂统计逻辑
这些更适合下放到索引层或后端服务。
一个简单的测试思路
虽然这篇重点在部署和升级流程,但中级开发者最好顺手把最关键的测试补上。
下面给一个简化版思路。
创建 test/MyToken.js
const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");
describe("MyToken UUPS", function () {
it("should deploy V1 and upgrade to V2 with state preserved", async function () {
const [owner, user] = await ethers.getSigners();
const TokenV1 = await ethers.getContractFactory("MyTokenV1");
const initialSupply = ethers.parseUnits("1000", 18);
const proxy = await upgrades.deployProxy(
TokenV1,
["My Upgradeable Token", "MUT", initialSupply, owner.address],
{ initializer: "initialize", kind: "uups" }
);
await proxy.waitForDeployment();
expect(await proxy.owner()).to.equal(owner.address);
expect(await proxy.totalSupply()).to.equal(initialSupply);
await (await proxy.mint(user.address, ethers.parseUnits("100", 18))).wait();
expect(await proxy.balanceOf(user.address)).to.equal(ethers.parseUnits("100", 18));
const TokenV2 = await ethers.getContractFactory("MyTokenV2");
const upgraded = await upgrades.upgradeProxy(await proxy.getAddress(), TokenV2);
await upgraded.waitForDeployment();
await (await upgraded.initializeV2()).wait();
expect(await upgraded.balanceOf(user.address)).to.equal(ethers.parseUnits("100", 18));
await (await upgraded.pause()).wait();
await expect(
upgraded.transfer(user.address, ethers.parseUnits("1", 18))
).to.be.reverted;
await (await upgraded.unpause()).wait();
});
});
运行测试:
npx hardhat test
实战中的边界条件
这个教程能帮你跑通“可升级 ERC-20”的核心路径,但落到真实项目时,还要考虑这些边界:
适合本方案的场景
- 代币仍在早期迭代
- 需要保留固定地址
- 有明确的升级治理机制
- 团队能承担升级测试与审计
不一定适合的场景
- 强调绝对不可变性
- 社区对管理员权限非常敏感
- 升级治理机制尚未确定
- 团队缺乏存储兼容与升级测试经验
如果你的项目强调“完全去中心化、不可篡改”,那么可升级本身就是一个治理决策,不只是技术选型。
总结
这篇文章我们完整走了一遍:
- 为什么 ERC-20 需要可升级能力
- 代理合约、实现合约、存储布局的核心原理
- 如何用 Solidity 编写 UUPS 可升级 ERC-20
- 如何通过 Hardhat 和 Ethers.js 部署、调用、升级
- 如何验证升级后状态不丢失
- 常见坑、安全实践与工程边界
如果你想把这套方案真正用到项目里,我建议按下面这个顺序推进:
- 先在本地 Hardhat 网络跑通 V1 → V2
- 再上 Sepolia 做一次真实升级演练
- 补齐自动化测试,特别是存储与权限测试
- 最后再考虑主网部署和多签治理
一句经验之谈:
可升级合约最难的不是“部署成功”,而是“半年后还能安全地继续升级”。
所以从第一版开始,就把初始化、权限、存储布局、测试习惯建立好,后面会轻松很多。