Web3 中级实战:基于 Solidity 与 Ethers.js 构建可升级智能合约的完整方案
很多人第一次写智能合约时,都会默认它“部署即永恒”。这句话没错,但放到真实业务里就有点残酷:需求会变、Bug 会暴露、权限模型会调整,甚至一个简单的 uint256 字段顺序写错,都可能让你想重发一个新地址,然后面对“旧数据怎么办”的问题。
所以,中级阶段的 Web3 开发,绕不过去的一件事就是:如何让合约可升级,同时尽量不破坏状态与地址稳定性。
这篇文章我会带你从 0 到 1 走一遍完整方案,基于:
- Solidity
- OpenZeppelin Upgrades
- Hardhat
- Ethers.js
我们不只是讲概念,还会真的写出一套可运行的升级合约示例,并解释升级过程中最容易踩的坑。
背景与问题
先说核心矛盾。
普通智能合约一旦部署,代码就固化在链上。对于简单 Demo,这是优点;但对需要长期维护的产品,它也意味着几个现实问题:
- 合约逻辑不能直接修改
- 如果重部署,合约地址会变
- 地址变化会导致前端、索引器、白名单、外部集成全部跟着改
- 老合约上的状态数据无法自动搬迁
比如你有一个链上积分系统:
- 用户地址对应积分余额
- 管理员可铸造积分
- 后续想加“销毁积分”功能
如果你直接部署 V1,再部署 V2,那么 V2 的地址变了,V1 上的余额状态还留在旧地址里。这个迁移过程不仅麻烦,还容易出错。
这就是可升级智能合约要解决的问题:
保持对外地址不变,升级内部逻辑实现。
前置知识与环境准备
这篇文章默认你已经了解:
- Solidity 基础语法
- 合约部署流程
- Ethers.js 基本调用方式
- Node.js / npm 基本使用
环境版本建议
- Node.js 18+
- Hardhat
- Solidity 0.8.x
- Ethers.js v6
- OpenZeppelin Contracts Upgradeable
- OpenZeppelin Hardhat Upgrades
初始化项目
mkdir upgradeable-token-demo
cd upgradeable-token-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades
初始化 Hardhat:
npx hardhat
选择一个基础 JavaScript 项目即可。
安装额外依赖
如果你的 Hardhat 模板未自动包含 ethers 相关插件,可补充:
npm install --save-dev ethers
核心原理
在真正写代码前,先把底层机制理顺。你只要理解这部分,后面很多“为什么不能这么写”就自然明白了。
1. 代理模式的本质
可升级智能合约一般不是直接升级“已部署代码”,而是使用代理合约(Proxy)。
- 用户始终与 Proxy 交互
- Proxy 保存状态数据
- Proxy 将调用转发给 Implementation(逻辑合约)
- 当需要升级时,只替换 Proxy 指向的 Implementation 地址
- 由于状态保存在 Proxy 中,所以升级后数据仍然在
flowchart LR
User[用户/前端] --> Proxy[代理合约 Proxy]
Proxy --> ImplV1[逻辑合约 V1]
Proxy -. 升级后切换 .-> ImplV2[逻辑合约 V2]
Proxy --> Storage[(状态存储)]
2. delegatecall 是关键
Proxy 转发调用时,底层依赖 delegatecall:
- 执行的是逻辑合约的代码
- 但读写的是 Proxy 的存储空间
msg.sender仍然是外部调用者
这也解释了为什么存储布局必须兼容。因为逻辑合约虽然换了,但 Proxy 里的 slot 数据没变。
3. 为什么不能用构造函数
普通合约部署时,构造函数只执行一次。但在代理模式里:
- Proxy 才是用户真正交互的地址
- Implementation 只是逻辑模板
- 构造函数不会在 Proxy 的上下文里初始化状态
所以升级合约通常使用:
initializerreinitializer
而不是构造函数。
4. 存储布局为什么敏感
假设 V1 中:
uint256 public totalSupply;
mapping(address => uint256) public balances;
如果 V2 把字段顺序改成:
mapping(address => uint256) public balances;
uint256 public totalSupply;
那么原来 Proxy 里 slot 的意义就变了,状态直接错位,轻则读错数据,重则整个系统不可恢复。
5. 常见代理方案
行业里常见有几种:
- Transparent Proxy
- UUPS Proxy
- Beacon Proxy
这篇教程我选择Transparent Proxy 来讲,原因很现实:
- 上手门槛低
- 工具链成熟
- 适合中级开发者先理解完整升级流程
架构流程图
下面这张图把部署、调用、升级三件事串起来。
sequenceDiagram
participant Dev as 开发者
participant Script as 部署脚本
participant Proxy as Proxy
participant Impl1 as Implementation V1
participant Impl2 as Implementation V2
participant User as 用户
Dev->>Script: 部署 V1
Script->>Impl1: deploy()
Script->>Proxy: deploy proxy + initialize()
User->>Proxy: 调用 mint/balanceOf
Proxy->>Impl1: delegatecall
Dev->>Script: 执行升级
Script->>Impl2: deploy()
Script->>Proxy: upgradeTo Impl2
User->>Proxy: 调用 burn/version
Proxy->>Impl2: delegatecall
实战代码(可运行)
我们实现一个可升级的积分 Token:
- V1:支持初始化、铸造、查询余额
- V2:新增销毁功能和版本号方法
项目结构示例:
contracts/
MyTokenV1.sol
MyTokenV2.sol
scripts/
deploy.js
upgrade.js
interact.js
hardhat.config.js
第一步:配置 Hardhat
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
module.exports = {
solidity: "0.8.20",
};
第二步:编写 V1 合约
contracts/MyTokenV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyTokenV1 is Initializable, OwnableUpgradeable {
string public name;
mapping(address => uint256) private _balances;
uint256 public totalSupply;
event Mint(address indexed to, uint256 amount);
function initialize(string memory _name, address initialOwner) public initializer {
__Ownable_init(initialOwner);
name = _name;
}
function mint(address to, uint256 amount) external onlyOwner {
require(to != address(0), "invalid to");
_balances[to] += amount;
totalSupply += amount;
emit Mint(to, amount);
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
}
这里有几个关键点
- 继承
Initializable - 使用
initialize()替代构造函数 - 继承
OwnableUpgradeable - 调用
__Ownable_init(initialOwner) - 状态变量布局先定好,后续升级只能追加,不能随便重排
第三步:编写 V2 合约
V2 在 V1 基础上增加:
burn()销毁积分version()返回版本
contracts/MyTokenV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./MyTokenV1.sol";
contract MyTokenV2 is MyTokenV1 {
event Burn(address indexed from, uint256 amount);
function burn(address from, uint256 amount) external onlyOwner {
require(from != address(0), "invalid from");
require(_getBalance(from) >= amount, "insufficient balance");
_setBalance(from, _getBalance(from) - amount);
totalSupply -= amount;
emit Burn(from, amount);
}
function version() external pure returns (string memory) {
return "v2";
}
function _getBalance(address account) internal view returns (uint256) {
return _balancesOf(account);
}
function _setBalance(address account, uint256 amount) internal {
_updateBalance(account, amount);
}
function _balancesOf(address account) internal view returns (uint256) {
return __balanceOf(account);
}
function _updateBalance(address account, uint256 amount) internal {
__setBalance(account, amount);
}
function __balanceOf(address account) internal view returns (uint256) {
return _balancesSlot(account);
}
function __setBalance(address account, uint256 amount) internal {
_setBalancesSlot(account, amount);
}
function _balancesSlot(address account) internal view returns (uint256) {
return _balancesInternal(account);
}
function _setBalancesSlot(address account, uint256 amount) internal {
_setBalancesInternal(account, amount);
}
function _balancesInternal(address account) internal view returns (uint256) {
return _balancesAccessor(account);
}
function _setBalancesInternal(address account, uint256 amount) internal {
_balancesMutator(account, amount);
}
function _balancesAccessor(address account) internal view returns (uint256) {
return _rawBalanceOf(account);
}
function _balancesMutator(address account, uint256 amount) internal {
_rawSetBalance(account, amount);
}
function _rawBalanceOf(address account) internal view returns (uint256) {
return _balances[account];
}
function _rawSetBalance(address account, uint256 amount) internal {
_balances[account] = amount;
}
}
上面这份代码你应该已经看出问题了:V1 里 _balances 是 private,V2 不能直接访问。
这是升级开发中特别典型的设计点。我当时第一次做升级合约时,就在这里浪费了不少时间:V1 只想着“封装”,结果 V2 根本拿不到状态。
所以我们应该先把 V1 改成可继承扩展的写法。
更正后的 V1:为升级预留内部方法
contracts/MyTokenV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyTokenV1 is Initializable, OwnableUpgradeable {
string public name;
mapping(address => uint256) internal _balances;
uint256 public totalSupply;
event Mint(address indexed to, uint256 amount);
function initialize(string memory _name, address initialOwner) public initializer {
__Ownable_init(initialOwner);
name = _name;
}
function mint(address to, uint256 amount) external onlyOwner {
require(to != address(0), "invalid to");
_balances[to] += amount;
totalSupply += amount;
emit Mint(to, amount);
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
}
更新后的 contracts/MyTokenV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./MyTokenV1.sol";
contract MyTokenV2 is MyTokenV1 {
event Burn(address indexed from, uint256 amount);
function burn(address from, uint256 amount) external onlyOwner {
require(from != address(0), "invalid from");
require(_balances[from] >= amount, "insufficient balance");
_balances[from] -= amount;
totalSupply -= amount;
emit Burn(from, amount);
}
function version() external pure returns (string memory) {
return "v2";
}
}
这才是比较合理的升级写法:
V1 提前为未来扩展保留 internal 可见性。
第四步:部署代理合约
scripts/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const MyTokenV1 = await ethers.getContractFactory("MyTokenV1");
const proxy = await upgrades.deployProxy(
MyTokenV1,
["DemoToken", "0x0000000000000000000000000000000000000001"],
{ initializer: "initialize" }
);
await proxy.waitForDeployment();
const proxyAddress = await proxy.getAddress();
console.log("Proxy deployed to:", proxyAddress);
const implementationAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
console.log("Implementation V1:", implementationAddress);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
注意:上面 owner 地址为了演示写死了,实际请换成你的测试账户地址。
运行:
npx hardhat run scripts/deploy.js --network localhost
如果你使用本地链,先启动节点:
npx hardhat node
第五步:与 V1 交互
scripts/interact.js
const { ethers } = require("hardhat");
async function main() {
const proxyAddress = "替换成你的代理地址";
const [owner, user] = await ethers.getSigners();
const token = await ethers.getContractAt("MyTokenV1", proxyAddress);
let tx = await token.connect(owner).mint(user.address, 1000);
await tx.wait();
const balance = await token.balanceOf(user.address);
const totalSupply = await token.totalSupply();
console.log("User balance:", balance.toString());
console.log("Total supply:", totalSupply.toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat run scripts/interact.js --network localhost
第六步:升级到 V2
scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const proxyAddress = "替换成你的代理地址";
const MyTokenV2 = await ethers.getContractFactory("MyTokenV2");
const upgraded = await upgrades.upgradeProxy(proxyAddress, MyTokenV2);
await upgraded.waitForDeployment();
console.log("Proxy upgraded at:", await upgraded.getAddress());
const implementationAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
console.log("Implementation V2:", implementationAddress);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat run scripts/upgrade.js --network localhost
第七步:验证升级后状态是否保留
升级最重要的不是“成功切换版本”,而是要确认:
升级前的数据有没有保留下来。
scripts/verify-upgrade.js
const { ethers } = require("hardhat");
async function main() {
const proxyAddress = "替换成你的代理地址";
const [owner, user] = await ethers.getSigners();
const token = await ethers.getContractAt("MyTokenV2", proxyAddress);
const beforeBalance = await token.balanceOf(user.address);
const beforeSupply = await token.totalSupply();
const version = await token.version();
console.log("Version:", version);
console.log("Balance before burn:", beforeBalance.toString());
console.log("Supply before burn:", beforeSupply.toString());
const tx = await token.connect(owner).burn(user.address, 400);
await tx.wait();
const afterBalance = await token.balanceOf(user.address);
const afterSupply = await token.totalSupply();
console.log("Balance after burn:", afterBalance.toString());
console.log("Supply after burn:", afterSupply.toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
如果一切正常,你会看到:
- 代理地址没变
- 实现合约地址变了
- V1 的余额状态还在
- V2 的新函数可以调用
升级前后状态变化图
stateDiagram-v2
[*] --> V1已部署
V1已部署 --> 已初始化: initialize(name, owner)
已初始化 --> 已铸造余额: mint(user, 1000)
已铸造余额 --> 升级到V2: upgradeProxy
升级到V2 --> 状态保留: 余额/totalSupply 不丢失
状态保留 --> 调用新功能: burn(user, 400)
调用新功能 --> [*]
逐步验证清单
这部分我建议你每次升级都过一遍,别嫌麻烦,很多线上事故就是省略验证步骤导致的。
部署 V1 后
-
name()返回正确 -
owner()正确 -
mint()可执行 -
balanceOf()数据正确 -
totalSupply()数据正确
升级前
- 记录代理地址
- 记录关键状态值
- 执行
validateUpgrade - 检查 storage layout 兼容性
升级后
- 代理地址未变化
- implementation 地址已变化
- 升级前余额仍存在
- 新增函数可调用
- 权限控制未失效
常见坑与排查
这部分是实战里最值钱的内容。理论都懂了,真正卡人的往往是这些细节。
1. 使用了构造函数,初始化不生效
现象:
- 合约部署成功
owner()不是预期值- 状态变量为空
原因:
代理模式下,构造函数不会初始化 Proxy 的状态。
正确做法:
使用 initialize(),并加 initializer 修饰器。
function initialize(string memory _name, address initialOwner) public initializer {
__Ownable_init(initialOwner);
name = _name;
}
2. 升级后数据错乱
现象:
totalSupply数值异常- 用户余额变成奇怪的大数
- 某些读取函数直接报错
原因:
大概率是存储布局被破坏,常见触发方式:
- 修改状态变量顺序
- 删除旧变量
- 修改变量类型
- 在继承链中插入新父合约,导致 slot 重新分配
排查方法:
- 对比 V1 和 V2 的状态变量顺序
- 使用 OpenZeppelin upgrades 校验
- 检查继承树是否变化
经验建议:
只做一件事:
新变量永远追加在末尾,不要改已有变量顺序。
3. 私有变量导致 V2 无法访问
现象:
V2 编译报错,不能读取 V1 状态变量。
原因:
V1 使用了 private。
解决:
如果未来可能扩展,优先用 internal,或者预留内部 getter/setter。
4. initialize 被重复调用
现象:
- 攻击者重新初始化 owner
- 权限被夺走
原因:
初始化函数未受 initializer 保护,或实现合约初始化防护缺失。
最佳实践:
在实现合约中禁用初始化器。
/// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract SafeBase is Initializable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
}
如果你采用这种模式,记得在实现合约继承链中妥善安排。
5. 升级权限配置错误
现象:
- 任意人可以升级实现
- 管理员地址丢失后无法升级
- 多签未接入,单点风险过高
原因:
升级权限设计过于草率。
建议:
- 测试环境可先用 EOA
- 生产环境尽量挂到多签钱包
- 升级操作配合 Timelock 或治理流程
6. 前端 ABI 没更新
现象:
链上已经升级成功,但前端调用新方法报错。
原因:
代理地址没变,但前端仍在使用 V1 ABI。
解决:
升级后同步更新 ABI,尤其是新增函数的接口定义。
安全/性能最佳实践
可升级合约的重点并不只是“能升级”,而是“升级后还安全、还能维护”。
1. 严格遵守存储布局规则
这是第一原则。
不要做:
- 改已有变量顺序
- 改变量类型
- 删除变量
- 在旧变量中间插入新变量
应该做:
- 只在末尾追加新变量
- 复杂系统中保留
__gap
例如:
uint256[50] private __gap;
这个做法常用于给未来版本预留 slot 空间,尤其在可复用基类中很常见。
2. 优先复用成熟库
自己手写 Proxy 并不是练手的最佳路径,尤其不是上线的最佳路径。
中级开发阶段,建议优先使用:
- OpenZeppelin Contracts Upgradeable
- OpenZeppelin Upgrades Plugins
因为它们至少帮你挡掉了很多低级错误,比如布局校验、部署流程封装、ERC1967 地址管理等。
3. 初始化逻辑保持幂等和最小化
初始化函数不要塞太多复杂业务,例如:
- 大批量数据写入
- 外部合约回调
- 多层权限设置嵌套
越复杂,越难审计,也越容易在部署脚本里出错。
建议初始化只做三类事:
- 权限绑定
- 核心参数设置
- 基础状态赋值
4. 升级前先做 fork 测试
如果你维护的是线上项目,我非常建议在主网 fork 或测试网完整跑一遍:
- 读取线上代理状态
- fork 当前区块
- 执行升级脚本
- 跑核心业务回归测试
这一步非常值。很多“本地没问题,线上炸了”的情况,都是因为真实链上状态和测试环境差异太大。
5. 升级治理要有边界
能升级是能力,也是风险。
我的建议是按阶段区分:
- 早期项目:保留升级能力,提高迭代速度
- 成熟协议:引入多签 + Timelock + 公示窗口
- 强去中心化场景:明确承诺何时冻结升级能力
不要一边宣传不可篡改,一边后台保留超级升级权限而不披露。这不是技术问题,是信任问题。
6. Gas 与性能视角
代理模式天然比直连实现合约多一层调用转发,因此会带来一些额外 gas 开销。通常这个成本在大多数业务里是可接受的,但有边界:
适合升级代理的场景:
- 业务合约
- 权限系统
- 配置中心
- 治理模块
不一定适合的场景:
- 极致追求 gas 的高频核心路径
- 已冻结逻辑的超轻量合约
- 生命周期很短的临时合约
换句话说,不要为了“技术先进”而全量升级化。
真正合理的做法是:把需要长期演进的部分做成可升级,把简单稳定的部分保持不可变。
一个更稳妥的 V1 写法示例
如果你准备把这个 Demo 继续演进成真实项目,我建议 V1 一开始就加上禁用初始化和 gap 预留。
contracts/MyTokenV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyTokenV1 is Initializable, OwnableUpgradeable {
string public name;
mapping(address => uint256) internal _balances;
uint256 public totalSupply;
event Mint(address indexed to, uint256 amount);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(string memory _name, address initialOwner) public initializer {
__Ownable_init(initialOwner);
name = _name;
}
function mint(address to, uint256 amount) external onlyOwner {
require(to != address(0), "invalid to");
_balances[to] += amount;
totalSupply += amount;
emit Mint(to, amount);
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
uint256[47] private __gap;
}
这里的 47 只是示意,具体要根据已有状态变量数量和继承布局统一规划。
方案边界:什么时候不该用可升级合约
这是一个很容易被忽略,但我认为非常重要的问题。
并不是所有合约都值得升级。
不太适合的情况
-
一次性活动合约
- 生命周期很短
- 改需求概率低
-
极简且逻辑稳定的合约
- 如某些固定规则的锁仓合约
- 审计完成后更适合冻结逻辑
-
用户极度依赖不可变承诺的场景
- 升级权本身会成为信任风险
- 这时治理设计比技术实现更关键
更适合的情况
- 长期运营的业务协议
- 需求持续演进的产品
- 需要热修复的系统
- 多模块协作、接口可能扩展的项目
一句话总结:
可升级合约解决的是“持续演进问题”,不是所有问题。
总结
这篇文章我们从实战角度走完了一套可升级合约的完整路径:
- 理解为什么普通合约难以迭代
- 明白 Proxy + delegatecall 的工作机制
- 使用
initializer替代构造函数 - 基于 Solidity + Ethers.js + Hardhat 部署 V1
- 升级到 V2 并验证状态保留
- 避开存储布局、权限、ABI 等常见坑
- 建立升级前后的验证清单与安全边界
如果你现在正准备把可升级方案落到项目里,我给你三个可执行建议:
-
第一版就按升级方式写
- 不是后面再“改成可升级”
- 尤其状态变量可见性、初始化方式要一开始就定对
-
每次升级都做 storage layout 校验
- 不要靠肉眼
- 不要觉得“我只是加了个字段,应该没事”
-
生产环境把升级权限交给多签
- 单人 EOA 管理升级权限,风险很大
- 有条件就叠加 Timelock
最后说一句比较实际的话:
可升级合约不是难在“写出来”,而是难在长期维护时不把自己坑进去。如果你能把“状态兼容、权限边界、验证流程”这三件事养成习惯,已经超过很多只会跑通 Demo 的开发者了。