Web3 中级实战:用 Solidity + Hardhat 开发并部署可升级智能合约的完整流程
很多人第一次写智能合约时,都会默认“部署即永恒”:代码一上链,逻辑就定了。可真实项目往往不是这样。业务会变、权限模型会调整、Bug 会暴露,甚至早期为了快上线,合约设计也可能不够完善。这时候,“可升级智能合约”就从一个高级话题,变成了工程刚需。
这篇文章我不打算只讲概念,而是带你完整走一遍:用 Solidity + Hardhat 开发、测试、部署一个可升级合约,并完成一次升级。如果你已经会写普通合约,但对 Proxy、Storage Layout、initializer 这些概念还没真正串起来,这篇会比较适合你。
背景与问题
先说结论:普通合约不能直接修改代码。链上字节码是不可变的,这是区块链可信的基础之一。
那问题也来了:
- 如果上线后发现一个逻辑 Bug,怎么办?
- 如果要新增字段,比如用户等级、积分、手续费参数,怎么办?
- 如果后续要接入新的模块,比如治理、暂停、黑名单,怎么办?
一种朴素方案是“重新部署新合约,然后迁移数据”。但这个方案在真实项目里通常很痛:
- 旧地址已经被前端、脚本、用户、第三方协议绑定。
- 状态迁移非常麻烦,尤其是映射、权限、历史数据。
- 用户体验差,外部集成成本高。
所以行业里常见做法是:地址不变,逻辑可替换,状态保留。这就是 Proxy 模式下的可升级合约。
前置知识与环境准备
建议你在开始前具备这些基础:
- 会写基本 Solidity 合约
- 知道
mapping、modifier、event - 用过 Hardhat 的编译与测试命令
- 对部署脚本有基础了解
环境版本建议
本文示例基于以下工具组合:
- Node.js 18+
- Hardhat
- Solidity 0.8.x
- 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 项目即可。
核心原理
1. 可升级合约到底升级了什么?
严格来说,升级的不是 Proxy 地址,而是 Proxy 指向的实现合约地址。
用户始终调用 Proxy 合约,Proxy 再通过 delegatecall 把调用转发到实现合约(Implementation)。由于 delegatecall 会在 Proxy 的存储上下文中执行,所以:
- 代码来自实现合约
- 数据存储在 Proxy 中
这就是“逻辑可换、数据保留”的根本原因。
2. 最常见的结构
flowchart LR
User[用户 / 前端] --> Proxy[Proxy 合约]
Proxy -->|delegatecall| ImplV1[Implementation V1]
Proxy -.升级后.-> ImplV2[Implementation V2]
Proxy --> Storage[(Proxy Storage)]
3. 为什么不能用 constructor?
因为 Proxy 模式下,真正被用户交互的是 Proxy,不是实现合约本体。实现合约的 constructor 只会在实现合约部署时运行一次,而不会自动作用到 Proxy 的存储上。
因此,可升级合约里要用:
initialize()- 或
reinitializer(x)
而不是普通 constructor。
4. 常见代理模式
OpenZeppelin 生态里常见两类:
- Transparent Proxy
- UUPS Proxy
这篇教程用 UUPS,原因很简单:
- 更轻量
- 当前比较常用
- 升级逻辑放在实现合约内部,结构清晰
当然,前提是你真的理解权限控制,不然 _authorizeUpgrade 写错,后果会很严重。
5. 升级过程时序
sequenceDiagram
participant Dev as 开发者
participant Proxy as Proxy
participant V1 as Logic V1
participant V2 as Logic V2
participant Admin as Upgrade Admin
Dev->>V1: 部署实现合约 V1
Dev->>Proxy: 部署 Proxy 并初始化
Proxy->>V1: delegatecall initialize()
Dev->>V2: 部署实现合约 V2
Admin->>Proxy: upgradeTo(V2)
Proxy->>V2: 后续调用 delegatecall 到 V2
项目结构
我们用一个简单但足够真实的例子:一个可升级的“记分板”合约。
- V1:支持设置和读取分数
- V2:新增
increment()方法,并增加version()便于验证升级
目录大致如下:
hardhat-upgrade-demo/
├─ contracts/
│ ├─ ScoreV1.sol
│ └─ ScoreV2.sol
├─ scripts/
│ ├─ deploy.js
│ └─ upgrade.js
├─ test/
│ └─ Score.js
├─ hardhat.config.js
└─ package.json
配置 Hardhat
编辑 hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {},
},
};
如果你后面要部署测试网,可以再补 RPC 和私钥配置。本文先专注本地流程,先把原理跑通。
实战代码(可运行)
第一步:编写 V1 合约
contracts/ScoreV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract ScoreV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public score;
address public operator;
event ScoreUpdated(uint256 newScore);
event OperatorUpdated(address newOperator);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address _operator, uint256 _score) public initializer {
__Ownable_init(msg.sender);
__UUPSUpgradeable_init();
operator = _operator;
score = _score;
}
function setOperator(address _operator) external onlyOwner {
operator = _operator;
emit OperatorUpdated(_operator);
}
function setScore(uint256 _score) external {
require(msg.sender == operator, "not operator");
score = _score;
emit ScoreUpdated(_score);
}
function version() external pure returns (string memory) {
return "V1";
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
这段代码里要注意什么?
- 继承的是
Initializable、UUPSUpgradeable、OwnableUpgradeable - 使用
initialize()而不是 constructor 初始化业务状态 - constructor 里调用
_disableInitializers(),防止实现合约被恶意初始化 _authorizeUpgrade()必须实现,用来限制谁能升级
这个点我当时刚接触时也绕了半天:可升级合约最危险的部分,往往不是业务逻辑,而是升级权限本身。
第二步:编写 V2 合约
contracts/ScoreV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./ScoreV1.sol";
contract ScoreV2 is ScoreV1 {
event ScoreIncremented(uint256 newScore);
function increment() external {
require(msg.sender == operator, "not operator");
score += 1;
emit ScoreIncremented(score);
}
function version() external pure override returns (string memory) {
return "V2";
}
}
注意这里我们没有改动已有存储变量顺序,仍然沿用:
scoreoperator
这是可升级合约里最重要的纪律之一。后面会详细讲。
如果父合约函数要被重写
由于 version() 在 V1 中被 V2 重写,V1 中最好将它标记为 virtual。把 V1 里该函数改成:
function version() external pure virtual returns (string memory) {
return "V1";
}
这是一个很小但很常见的编译点,很多人在跟着教程敲时容易漏。
第三步:编写部署脚本
scripts/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const [deployer, operator] = await ethers.getSigners();
console.log("deployer:", deployer.address);
console.log("operator:", operator.address);
const ScoreV1 = await ethers.getContractFactory("ScoreV1");
const proxy = await upgrades.deployProxy(
ScoreV1,
[operator.address, 100],
{ initializer: "initialize", kind: "uups" }
);
await proxy.waitForDeployment();
const proxyAddress = await proxy.getAddress();
console.log("Proxy deployed to:", proxyAddress);
console.log("version:", await proxy.version());
console.log("score:", (await proxy.score()).toString());
console.log("operator:", await proxy.operator());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat compile
npx hardhat run scripts/deploy.js
第四步:编写升级脚本
scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const proxyAddress = "替换成上一步输出的 Proxy 地址";
const ScoreV2 = await ethers.getContractFactory("ScoreV2");
const upgraded = await upgrades.upgradeProxy(proxyAddress, ScoreV2);
await upgraded.waitForDeployment();
console.log("Upgraded proxy:", await upgraded.getAddress());
console.log("version:", await upgraded.version());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat run scripts/upgrade.js
第五步:写测试,验证升级前后状态是否保留
说实话,我做链上开发越来越依赖测试,不是因为“规范”,而是因为升级一旦出错,代价比普通后端大得多。尤其是存储布局问题,测试能帮你提前踩刹车。
test/Score.js
const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");
describe("Upgradeable Score Contract", function () {
async function deployFixture() {
const [owner, operator, other] = await ethers.getSigners();
const ScoreV1 = await ethers.getContractFactory("ScoreV1");
const proxy = await upgrades.deployProxy(
ScoreV1,
[operator.address, 100],
{ initializer: "initialize", kind: "uups" }
);
await proxy.waitForDeployment();
return { proxy, owner, operator, other };
}
it("should initialize correctly", async function () {
const { proxy, operator } = await deployFixture();
expect(await proxy.score()).to.equal(100n);
expect(await proxy.operator()).to.equal(operator.address);
expect(await proxy.version()).to.equal("V1");
});
it("should allow operator to set score", async function () {
const { proxy, operator } = await deployFixture();
await proxy.connect(operator).setScore(200);
expect(await proxy.score()).to.equal(200n);
});
it("should upgrade to V2 and keep storage", async function () {
const { proxy, operator } = await deployFixture();
await proxy.connect(operator).setScore(300);
const ScoreV2 = await ethers.getContractFactory("ScoreV2");
const upgraded = await upgrades.upgradeProxy(await proxy.getAddress(), ScoreV2);
expect(await upgraded.score()).to.equal(300n);
expect(await upgraded.operator()).to.equal(operator.address);
expect(await upgraded.version()).to.equal("V2");
await upgraded.connect(operator).increment();
expect(await upgraded.score()).to.equal(301n);
});
it("should block non-owner from upgrading", async function () {
const { proxy, other } = await deployFixture();
const ScoreV2 = await ethers.getContractFactory("ScoreV2", other);
await expect(
upgrades.upgradeProxy(await proxy.getAddress(), ScoreV2)
).to.be.reverted;
});
});
运行测试:
npx hardhat test
存储布局为什么是重中之重
如果只记住一条升级规则,我建议记这个:
不要修改已有状态变量的顺序、类型和语义。新增变量尽量只往后追加。
错误示例
V1:
uint256 public score;
address public operator;
错误地改成 V2:
address public operator;
uint256 public score;
这会导致 Proxy 原有槽位被按错误类型解释,结果就是数据乱掉。
可以这样理解:
classDiagram
class ProxyStorage {
slot0: score
slot1: operator
}
class ImplV1 {
slot0 => uint256 score
slot1 => address operator
}
class ImplV2Wrong {
slot0 => address operator
slot1 => uint256 score
}
ProxyStorage --> ImplV1
ProxyStorage --> ImplV2Wrong
同样地,下面这些操作也要谨慎:
- 删除已有变量
- 在已有变量中间插入新变量
- 修改变量类型
- 修改继承顺序导致布局变化
如果你用 OpenZeppelin 插件,升级时它会帮助检查一部分布局兼容性,但别把它当“绝对保险”。
逐步验证清单
如果你想像做实验一样确认整个流程,这个清单可以直接照着走。
本地开发阶段
- 合约能正常编译
-
initialize()只执行一次 -
setScore()权限正常 -
_authorizeUpgrade()只允许 owner 升级 - 升级到 V2 后旧数据仍保留
- V2 新增方法
increment()可用
上测试网前
- 确认 Proxy 地址和实现地址分别记录
- 确认 owner 是否为多签或安全地址
- 确认升级脚本支持指定网络
- 确认前端 ABI 已更新到最新实现版本
- 确认测试覆盖初始化、权限、升级后回归
常见坑与排查
1. constructor 里初始化了状态,结果读取不到
现象
部署后发现 owner、score、operator 等值不对,或者是默认值。
原因
你把逻辑写进了实现合约 constructor,而不是 initialize()。
处理
- 把初始化逻辑迁移到
initialize() - constructor 中只保留
_disableInitializers()
2. 升级时报存储布局不兼容
现象
运行 upgradeProxy 时报错,提示 storage layout incompatible。
原因
通常是:
- 改了变量顺序
- 改了变量类型
- 父合约继承结构变化
排查路径
- 对比 V1 与 V2 的状态变量顺序
- 对比父合约继承顺序是否变化
- 检查新增变量是否只追加在尾部
- 检查是否从普通 OpenZeppelin 合约误切到 Upgradeable 版本
3. 调用升级失败:OwnableUnauthorizedAccount
现象
升级脚本执行时报权限错误。
原因
执行升级的账号不是当前 owner。
处理
先检查:
console.log(await proxy.owner());
再确认升级脚本使用的 signer 是否对应这个地址。
这类问题在测试网很常见,尤其你本地部署用的是一个账号,CI 或脚本运行时换了另一个账号。
4. 忘了调用父级初始化函数
现象
升级合约部署成功,但 owner() 异常、权限失效或内部模块行为不正常。
原因
在 initialize() 中漏掉:
__Ownable_init(...)__UUPSUpgradeable_init()
处理
每个升级版模块的初始化函数都要明确调用。不要想当然觉得继承后会自动完成。
5. 实现合约被初始化
现象
安全审计指出 Implementation 可被初始化。
风险
攻击者可能直接初始化实现合约,造成管理混乱,某些场景甚至会影响升级安全。
处理
确保 constructor 中有:
constructor() {
_disableInitializers();
}
安全/性能最佳实践
1. 升级权限尽量交给多签,不要长期放个人钱包
开发阶段你可以先用 EOA 测试,但正式环境里更稳妥的做法通常是:
- Proxy owner 使用多签
- 升级流程走治理或审批
- 升级前后保留审计和变更记录
如果项目资产规模稍大,这不是“加分项”,而是基本盘。
2. 初始化函数必须防重入、防重复调用
虽然 initializer 已经能防止重复初始化,但你仍要注意:
- 初始化里不要做太复杂的外部调用
- 依赖多个模块时,明确初始化顺序
- 新版本增加初始化逻辑时,用
reinitializer(x)
比如 V2 如果新增了状态变量并需要一次性初始化,可以写:
function initializeV2(uint256 someValue) public reinitializer(2) {
// set new state here
}
3. 不要在升级版本里随意改语义
即使存储布局没坏,语义漂移也会带来业务风险。
比如原来 score 表示“累计得分”,升级后偷偷改成“余额”,技术上能跑,业务上却会炸。前端、索引器、第三方脚本都可能误解。
我的建议是:
- 保持变量语义稳定
- 重大语义变化宁可新增字段
- 为版本升级补充迁移说明和事件
4. 对外接口尽量保持兼容
升级前后,如果函数名、返回值、事件结构改动太大,会让前端和集成方一起出问题。
建议:
- 已上线接口尽量不删除
- 变更事件字段要非常谨慎
- ABI 更新后同步通知前端和数据服务
5. 充分利用测试和本地回归
至少覆盖这些测试:
- 初始化成功与重复初始化失败
- 权限校验
- 升级成功
- 升级后数据保留
- 新功能正常
- 非法升级失败
如果你已经在做更完整的工程化,可以把“升级前部署 V1 -> 写入状态 -> 升级到 V2 -> 回归验证”做成 CI 流水线的一部分。
6. 性能上别神化 Proxy,也别忽略它的成本
Proxy 调用会有额外开销,因为中间多了一层转发。大多数业务场景这点开销可以接受,但如果你的函数是极高频、极重 gas 的路径,就要认真评估。
边界条件很明确:
- 治理、配置、资产管理类合约:通常适合升级
- 极致追求 gas 的高频核心逻辑:要权衡升级性和成本
- 完全不希望任何人改代码的协议模块:可能更适合不可升级设计
部署到测试网时的补充建议
虽然本文用的是本地网络,但如果你准备上 Sepolia 或其他测试网,建议增加这些配置。
示例 hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
require("dotenv").config();
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {},
sepolia: {
url: process.env.SEPOLIA_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
},
},
};
.env:
SEPOLIA_RPC_URL=your_rpc_url
PRIVATE_KEY=your_private_key
部署命令:
npx hardhat run scripts/deploy.js --network sepolia
升级命令:
npx hardhat run scripts/upgrade.js --network sepolia
上测试网后,建议你把这几项单独记录下来:
- Proxy 地址
- Implementation V1 地址
- Implementation V2 地址
- Proxy owner
- 部署交易哈希
- 升级交易哈希
后面排查问题时,这些信息非常有用。
一个更完整的升级思维模型
很多人把“可升级”理解成一个 Solidity 语法点,其实更准确地说,它是一个合约架构 + 权限治理 + 状态兼容的问题。
可以把它抽象成下面这个流程:
flowchart TD
A[设计 V1 存储布局] --> B[实现 initialize 与权限]
B --> C[部署 Proxy + V1]
C --> D[编写测试并写入状态]
D --> E[设计 V2 仅追加变量/功能]
E --> F[执行升级检查]
F --> G[升级 Proxy 到 V2]
G --> H[验证旧状态保留]
H --> I[验证新功能与权限]
这个流程背后的关键,不是“怎么调一个 upgradeProxy API”,而是:
- 你是否能控制升级权限
- 你是否能保证状态兼容
- 你是否能证明升级后系统仍按预期运行
总结
如果你把本文内容压缩成一套最实用的结论,大概就是这几条:
- 可升级合约的核心是 Proxy + delegatecall
- 数据在 Proxy,逻辑在实现合约
- 初始化要用
initialize(),不要依赖 constructor - 升级时最怕存储布局破坏,新增变量只往后加
- 升级权限一定要严格控制,正式环境优先多签
- 每次升级都要做回归测试,验证“旧状态保留 + 新逻辑可用”
如果你是第一次真正上手,我建议不要一开始就做复杂业务,先像本文这样做一个最小可运行案例,把以下动作练熟:
- 部署 V1
- 写入状态
- 升级到 V2
- 验证数据不丢
- 验证新功能生效
只要这个闭环你能独立完成,后面再接 ERC20、NFT、治理模块、金库合约,难度就会小很多。
可升级智能合约并不神秘,但它确实比普通合约更考验工程纪律。学会它,往往也是从“会写合约”走向“能做链上系统”的分水岭。