区块链中级实战:基于 Solidity 与 Hardhat 搭建可升级智能合约及安全测试流程
很多人学 Solidity 时,前半程都挺顺:写个 ERC20、部署一下、调几个函数,链上世界一片祥和。可一旦项目进入“长期维护”阶段,问题就来了:
- 合约部署后不能直接改代码,发现 bug 怎么办?
- 业务逻辑要迭代,地址还得保持不变,怎么做?
- 升级之后存储布局错位,链上数据会不会直接废掉?
- 测试不只是“能跑通”,怎么把权限、升级、安全边界一起覆盖?
这篇文章我会从一个中级开发者真正会遇到的场景出发,用 Solidity + Hardhat + OpenZeppelin Upgrades 搭一套可运行的可升级智能合约工程,并补上安全测试流程。目标不是只让你“会敲”,而是让你知道为什么这么做、哪里最容易翻车、上线前应该检查什么。
背景与问题
传统智能合约一旦部署,代码不可变。这种不可变性是区块链可信的来源之一,但同时也带来了现实问题:
-
业务会变
- 积分规则会改
- 权限控制会升级
- 运营活动要加新功能
-
bug 不会跟你讲武德
- 小 bug 可能只是功能异常
- 大 bug 可能直接锁死资产
-
用户和前端不希望频繁切地址
- 合约地址变了,前端、索引器、白名单、合作方配置全要改
- 老数据迁移麻烦,链上迁移成本高
于是,可升级合约成了很多中大型项目的标配方案。
但它不是“免费午餐”。你获得升级能力的同时,也引入了新的复杂度:
- 代理模式的调用链
- 实现合约与代理合约的职责分离
- 存储布局兼容性
- 初始化函数替代构造函数
- 升级权限的治理风险
所以本文的重点,不只是“怎么用插件部署”,而是把工程化、安全性、测试验证一起拉通。
前置知识与环境准备
如果你已经会写基础 Solidity 合约,这一节会很快。
你需要具备的基础
- 理解 Solidity 基础语法
- 知道
mapping、modifier、事件、继承 - 会使用 Node.js 与 npm
- 会简单跑 Hardhat 测试
环境版本建议
- Node.js 18+
- Hardhat 2.22+
- Solidity 0.8.24
- OpenZeppelin Contracts Upgradeable 最新稳定版
- OpenZeppelin Hardhat Upgrades 插件
初始化项目
mkdir hardhat-upgrade-demo
cd hardhat-upgrade-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades
初始化 Hardhat:
npx hardhat
选择一个基础 JavaScript 项目即可。
核心原理
可升级合约最常见的实现方式,是代理模式(Proxy Pattern)。用户实际交互的是代理合约,代理合约再通过 delegatecall 把调用转发给实现合约(Implementation)。
为什么要用代理
因为链上地址不可变,但代理合约内部指向的实现地址可以改。这样:
- 用户始终访问同一个代理地址
- 逻辑代码可以升级
- 状态数据保存在代理合约中,不随实现切换而丢失
调用关系图
flowchart LR
U[用户/前端] --> P[Proxy 代理合约]
P -->|delegatecall| I1[Implementation V1]
P -.升级后.-> I2[Implementation V2]
P --> S[(Storage 存储数据)]
为什么不能用构造函数
因为初始化发生在代理上下文中,构造函数只会在实现合约部署时执行,而不会作用到代理的存储。
所以可升级合约要改用:
initialize()initializerreinitializer
存储布局为什么关键
代理合约的数据槽位是固定的。如果你升级后随意改状态变量顺序,就可能出现:
- 原本的
owner被解释成uint256 - 原本的
mapping槽位偏移 - 业务数据彻底损坏
这是可升级合约最典型、也最危险的坑之一。
升级流程时序图
sequenceDiagram
participant Admin as 升级管理员
participant Proxy as Proxy
participant ImplV1 as 实现合约V1
participant ImplV2 as 实现合约V2
participant User as 用户
User->>Proxy: 调用 setScore()
Proxy->>ImplV1: delegatecall
ImplV1-->>Proxy: 修改代理存储
Admin->>ImplV2: 部署新实现
Admin->>Proxy: upgradeTo(ImplV2)
User->>Proxy: 调用新函数 level()
Proxy->>ImplV2: delegatecall
ImplV2-->>Proxy: 基于原有存储继续工作
常见代理方案简述
| 方案 | 特点 | 适用场景 |
|---|---|---|
| Transparent Proxy | 管理员与普通用户调用路径区分明确 | 通用项目、上手友好 |
| UUPS | 升级逻辑在实现合约里,部署成本更低 | 更灵活,但要求更严谨 |
| Beacon Proxy | 多个代理共享一个实现指针 | 批量合约升级 |
本文选择 UUPS,因为它更贴近中级开发者在真实项目中的需求:轻量、主流、工程化好。当然,如果你的团队偏保守,Transparent 也完全没问题。
项目结构设计
这次我们做一个简单但不玩具化的案例:可升级积分合约 PointVault。
功能目标:
- 用户可被管理员设置积分
- V1 支持增减积分
- V2 增加用户等级计算逻辑
- 使用 UUPS 升级
- 编写权限与升级测试
推荐目录:
hardhat-upgrade-demo/
├─ contracts/
│ ├─ PointVaultV1.sol
│ └─ PointVaultV2.sol
├─ scripts/
│ ├─ deploy.js
│ └─ upgrade.js
├─ test/
│ └─ PointVault.test.js
├─ hardhat.config.js
└─ package.json
实战代码(可运行)
1. 配置 Hardhat
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
module.exports = {
solidity: "0.8.24",
};
2. 编写 V1 可升级合约
contracts/PointVaultV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract PointVaultV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
mapping(address => uint256) internal _scores;
uint256 public totalUsers;
event ScoreSet(address indexed user, uint256 score);
event ScoreIncreased(address indexed user, uint256 amount, uint256 newScore);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function setScore(address user, uint256 score) external onlyOwner {
if (_scores[user] == 0 && score > 0) {
totalUsers += 1;
}
_scores[user] = score;
emit ScoreSet(user, score);
}
function increaseScore(address user, uint256 amount) external onlyOwner {
if (_scores[user] == 0 && amount > 0) {
totalUsers += 1;
}
_scores[user] += amount;
emit ScoreIncreased(user, amount, _scores[user]);
}
function scoreOf(address user) external view returns (uint256) {
return _scores[user];
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
这份代码里几个关键点
-
Initializable- 替代构造函数初始化逻辑
-
OwnableUpgradeable- 使用可升级版本,不要误导入普通版
Ownable
- 使用可升级版本,不要误导入普通版
-
UUPSUpgradeable- 提供 UUPS 升级能力
-
_disableInitializers()- 防止实现合约本身被人直接初始化
-
_authorizeUpgrade- 升级权限控制核心入口,这里限制为
onlyOwner
- 升级权限控制核心入口,这里限制为
3. 编写 V2 升级合约
contracts/PointVaultV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./PointVaultV1.sol";
contract PointVaultV2 is PointVaultV1 {
event ScoreDecreased(address indexed user, uint256 amount, uint256 newScore);
function decreaseScore(address user, uint256 amount) external onlyOwner {
require(_scores[user] >= amount, "insufficient score");
_scores[user] -= amount;
emit ScoreDecreased(user, amount, _scores[user]);
}
function levelOf(address user) external view returns (string memory) {
uint256 s = _scores[user];
if (s >= 1000) return "Diamond";
if (s >= 500) return "Gold";
if (s >= 100) return "Silver";
return "Bronze";
}
}
注意这里为什么是“继承 V1”
因为升级版需要保留原有存储布局,最稳妥的方式之一就是:
- V2 继承 V1
- 新增变量只能往后加
- 不要删、不改顺序、不改类型
我们这个例子里 V2 没有新增状态变量,只增加了逻辑函数,因此更安全。
4. 编写部署脚本
scripts/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploy by:", deployer.address);
const PointVaultV1 = await ethers.getContractFactory("PointVaultV1");
const proxy = await upgrades.deployProxy(
PointVaultV1,
[deployer.address],
{ initializer: "initialize", kind: "uups" }
);
await proxy.waitForDeployment();
const proxyAddress = await proxy.getAddress();
console.log("Proxy deployed to:", proxyAddress);
const implAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
console.log("Implementation V1:", implAddress);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
npx hardhat run scripts/deploy.js
5. 编写升级脚本
scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const proxyAddress = "请替换为部署后的代理地址";
const PointVaultV2 = await ethers.getContractFactory("PointVaultV2");
const upgraded = await upgrades.upgradeProxy(proxyAddress, PointVaultV2);
await upgraded.waitForDeployment();
const newImpl = await upgrades.erc1967.getImplementationAddress(proxyAddress);
console.log("Upgraded implementation:", newImpl);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
npx hardhat run scripts/upgrade.js
6. 编写测试:验证功能、权限、升级一致性
test/PointVault.test.js
const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");
describe("PointVault UUPS Upgradeable Contract", function () {
let owner, alice, bob;
let proxy;
beforeEach(async function () {
[owner, alice, bob] = await ethers.getSigners();
const PointVaultV1 = await ethers.getContractFactory("PointVaultV1");
proxy = await upgrades.deployProxy(
PointVaultV1,
[owner.address],
{
initializer: "initialize",
kind: "uups",
}
);
await proxy.waitForDeployment();
});
it("should initialize owner correctly", async function () {
expect(await proxy.owner()).to.equal(owner.address);
});
it("should allow owner to set and increase score", async function () {
await proxy.setScore(alice.address, 120);
expect(await proxy.scoreOf(alice.address)).to.equal(120);
await proxy.increaseScore(alice.address, 30);
expect(await proxy.scoreOf(alice.address)).to.equal(150);
});
it("should reject non-owner operations", async function () {
await expect(
proxy.connect(alice).setScore(alice.address, 100)
).to.be.reverted;
});
it("should preserve state after upgrade", async function () {
await proxy.setScore(alice.address, 600);
expect(await proxy.scoreOf(alice.address)).to.equal(600);
const PointVaultV2 = await ethers.getContractFactory("PointVaultV2");
const upgraded = await upgrades.upgradeProxy(
await proxy.getAddress(),
PointVaultV2
);
expect(await upgraded.scoreOf(alice.address)).to.equal(600);
expect(await upgraded.levelOf(alice.address)).to.equal("Gold");
});
it("should support new V2 function after upgrade", async function () {
await proxy.setScore(alice.address, 1000);
const PointVaultV2 = await ethers.getContractFactory("PointVaultV2");
const upgraded = await upgrades.upgradeProxy(
await proxy.getAddress(),
PointVaultV2
);
await upgraded.decreaseScore(alice.address, 200);
expect(await upgraded.scoreOf(alice.address)).to.equal(800);
expect(await upgraded.levelOf(alice.address)).to.equal("Gold");
});
it("should reject decrease when insufficient score", async function () {
const PointVaultV2 = await ethers.getContractFactory("PointVaultV2");
const upgraded = await upgrades.upgradeProxy(
await proxy.getAddress(),
PointVaultV2
);
await expect(
upgraded.decreaseScore(alice.address, 1)
).to.be.revertedWith("insufficient score");
});
});
运行测试:
npx hardhat test
逐步验证清单
我建议你不要“代码一把梭全写完再跑”。中级阶段最容易浪费时间的,就是出错后不知道是哪一步坏了。下面这个清单可以边做边验。
第一阶段:基础部署验证
-
deployProxy能成功执行 - 能拿到代理地址和实现地址
-
owner()返回初始化传入地址
第二阶段:V1 业务验证
-
setScore()正常 -
increaseScore()正常 - 非 owner 调用会 revert
-
totalUsers计数符合预期
第三阶段:升级验证
- 能成功升级到 V2
- 升级前的积分数据仍在
-
levelOf()可正常调用 -
decreaseScore()权限正确、边界正确
第四阶段:安全验证
- 实现合约不能被初始化
- 非 owner 不能升级
- 升级后关键状态未损坏
- 测试覆盖异常分支
用一张图看工程流转
flowchart TD
A[编写 V1 合约] --> B[deployProxy 部署代理]
B --> C[编写测试验证 V1]
C --> D[编写 V2 合约]
D --> E[upgradeProxy 升级]
E --> F[验证状态保留]
F --> G[验证新增逻辑]
G --> H[补充权限与异常测试]
常见坑与排查
这一部分我尽量说得实战一点,因为很多问题不是“不会”,而是“看起来没问题但就是报错”。
1. 把普通版合约库用到了可升级合约里
错误示例思路:
- 导入
@openzeppelin/contracts/access/Ownable.sol - 在可升级合约里直接写构造函数
这会导致初始化路径不正确,甚至引发存储与继承问题。
正确做法
使用 upgradeable 版本:
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
并用 initialize() 取代构造函数。
2. 忘记调用父类初始化函数
比如你继承了 OwnableUpgradeable 和 UUPSUpgradeable,却只写了自己的初始化逻辑,没有调用:
__Ownable_init(...)__UUPSUpgradeable_init()
这种情况会表现为:
- owner 没设置成功
- 升级机制异常
- 权限判断失败
3. 升级后存储错位
这是最要命的一类问题。
错误方式
在 V2 中把 V1 的变量顺序改掉:
// 错误示例,不要这样做
uint256 public totalUsers;
mapping(address => uint256) internal _scores;
V1 原来是:
mapping(address => uint256) internal _scores;
uint256 public totalUsers;
你以为只是“整理一下代码更好看”,实际上链上存储槽位已经变了。
排查建议
- 不要修改已有状态变量顺序
- 不要修改已有变量类型
- 不要删除旧变量
- 新变量只往后追加
- 升级前运行 Hardhat Upgrades 的存储检查
OpenZeppelin 插件会帮你拦下大量明显问题,但不要过度依赖插件,它不是形式化验证工具。
4. 实现合约被单独初始化
如果没有在构造函数中执行 _disableInitializers(),攻击者可能直接初始化实现合约,造成混乱甚至权限风险。
虽然代理仍然是主要交互入口,但这类隐患最好从源头封死。
5. _authorizeUpgrade 写得过于宽松
错误示例:
function _authorizeUpgrade(address) internal override {}
这相当于任何人都可能触发升级,等同于把项目后门敞开。
正确做法
至少要加上:
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
如果是生产环境,建议进一步升级为:
- 多签控制
- Timelock 延迟生效
- 升级前链下审计和灰度流程
6. 测试只测“成功路径”
很多人写测试时习惯只验证:
- 部署成功
- 函数返回正常
- 升级不报错
但真正应该重点补的是失败路径:
- 非 owner 升级是否失败
- 非 owner 业务操作是否失败
- 不足积分是否失败
- 升级后旧数据是否一致
- 升级后旧接口是否仍符合预期
我自己早期就吃过这个亏:测试全绿,上测试网一交互,发现初始化权限人写错了,整个升级流程直接卡住。
安全/性能最佳实践
可升级合约的最佳实践,核心是:把升级视为高危操作,而不是普通发版动作。
1. 升级权限必须收紧
推荐优先级:
- 单人 owner:仅开发和测试环境
- 多签钱包:生产环境起步配置
- 多签 + Timelock:较成熟团队推荐
这样做的目的不是“更复杂”,而是避免:
- 私钥泄漏
- 误操作升级
- 内部越权发版
2. 把升级测试分成三层
第一层:功能测试
- 函数是否按预期执行
第二层:状态兼容测试
- 升级前写入的数据,升级后是否保留且可解释
第三层:权限与异常测试
- 非授权账号不能升级
- 异常输入能正确 revert
- 新增逻辑不会破坏旧逻辑
这三层缺一不可。只做第一层,其实不算做完。
3. 预留存储空间时要谨慎
某些复杂继承结构中,会看到保留 gap 的写法,例如:
uint256[50] private __gap;
它的作用是为未来新增变量预留空间,避免父类变更带来布局冲突。
不过现在如果你主要依赖 OpenZeppelin 的标准可升级基类,并遵守“只在尾部追加变量”的原则,很多场景下不需要自己乱加 gap。
我的建议是:只有在你清楚继承链和布局约束时再使用它。
4. 业务逻辑里减少不必要的存储写入
链上性能本质上是 gas 成本问题。比如:
- 同一个值不必重复写
- 能缓存到内存的先缓存
- 事件不要无意义堆参数
例如你可以在某些场景中先判断值是否变化再写入:
function setScore(address user, uint256 score) external onlyOwner {
uint256 oldScore = _scores[user];
if (oldScore == 0 && score > 0) {
totalUsers += 1;
}
if (oldScore != score) {
_scores[user] = score;
emit ScoreSet(user, score);
}
}
当然,这种优化要结合业务语义,别为了省一点 gas 把逻辑绕复杂了。
5. 上线前做一次“升级演练”
非常建议在测试网或本地 fork 环境完整走一遍:
- 部署 V1
- 写入真实风格数据
- 升级到 V2
- 对比升级前后状态
- 验证前端 ABI 与事件兼容性
- 验证脚本、监控、索引是否正常
这一步常常能提前发现:
- ABI 漏更新
- 前端调用了旧方法签名
- 索引器没适配新增事件
- 权限账号配错
6. 重要升级要做“不可逆影响清单”
上线前至少问自己这几个问题:
- 这次升级是否改了状态变量?
- 是否新增了外部可调用入口?
- 是否改变了权限边界?
- 是否影响历史数据解释方式?
- 是否有回滚方案?
区块链里最可怕的不是报错,而是悄悄成功但结果错了。
一个更完整的状态视角
stateDiagram-v2
[*] --> DeployedV1
DeployedV1 --> Initialized
Initialized --> RunningV1
RunningV1 --> Upgrading : owner upgradeTo
Upgrading --> RunningV2 : success
Upgrading --> RunningV1 : revert
RunningV2 --> [*]
进阶建议:什么时候不该上可升级
虽然这篇文章讲的是可升级,但我不建议把它当成默认银弹。下面这些场景,可以认真考虑“不升级”或“弱升级”方案:
适合可升级的场景
- 业务长期迭代
- 权限治理成熟
- 有测试和审计流程
- 合约承担平台型逻辑
不太适合的场景
- 极简、一次性发行类合约
- 强调极致去信任、最小治理
- 团队没有完整升级流程
- 对升级中心化风险非常敏感
边界条件很重要:
如果你的团队还没有能力安全地管理升级权限,那“可升级”本身就可能是最大的安全风险。
总结
这篇文章我们完整走了一遍中级开发者最常见的一条链路:
- 用 Hardhat + OpenZeppelin Upgrades 初始化工程
- 基于 UUPS 编写可升级 Solidity 合约
- 用
initialize替代构造函数 - 通过代理部署并升级到新版本
- 用测试验证功能、权限和状态一致性
- 梳理了存储布局、初始化、升级授权等关键风险点
如果你准备把这套方案用于真实项目,我建议按下面顺序落地:
- 先在本地把 V1/V2 升级流程跑通
- 补齐失败路径测试,而不只是成功路径
- 用测试网做一次完整升级演练
- 生产环境把升级权限交给多签
- 每次升级前检查存储布局与权限影响
最后送你一个比较务实的判断标准:
能升级,不代表应该频繁升级;
能通过测试,不代表具备上线条件;
真正成熟的链上工程,重点从来不只是“写出来”,而是“可验证、可演练、可治理”。
如果你已经会写普通 Solidity 合约,那么把本文里的工程跑通之后,你就算真正跨进了可升级智能合约的实战门槛。