跳转到内容
123xiao | 无名键客

《Web3 中级实战:用 Solidity + Hardhat 开发并部署可升级智能合约的完整流程》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:用 Solidity + Hardhat 开发并部署可升级智能合约的完整流程

很多人第一次写智能合约时,都会默认“部署即永恒”:代码一上链,逻辑就定了。可真实项目往往不是这样。业务会变、权限模型会调整、Bug 会暴露,甚至早期为了快上线,合约设计也可能不够完善。这时候,“可升级智能合约”就从一个高级话题,变成了工程刚需。

这篇文章我不打算只讲概念,而是带你完整走一遍:用 Solidity + Hardhat 开发、测试、部署一个可升级合约,并完成一次升级。如果你已经会写普通合约,但对 Proxy、Storage Layout、initializer 这些概念还没真正串起来,这篇会比较适合你。


背景与问题

先说结论:普通合约不能直接修改代码。链上字节码是不可变的,这是区块链可信的基础之一。

那问题也来了:

  • 如果上线后发现一个逻辑 Bug,怎么办?
  • 如果要新增字段,比如用户等级、积分、手续费参数,怎么办?
  • 如果后续要接入新的模块,比如治理、暂停、黑名单,怎么办?

一种朴素方案是“重新部署新合约,然后迁移数据”。但这个方案在真实项目里通常很痛:

  1. 旧地址已经被前端、脚本、用户、第三方协议绑定。
  2. 状态迁移非常麻烦,尤其是映射、权限、历史数据。
  3. 用户体验差,外部集成成本高。

所以行业里常见做法是:地址不变,逻辑可替换,状态保留。这就是 Proxy 模式下的可升级合约。


前置知识与环境准备

建议你在开始前具备这些基础:

  • 会写基本 Solidity 合约
  • 知道 mappingmodifierevent
  • 用过 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 {}
}

这段代码里要注意什么?

  1. 继承的是 InitializableUUPSUpgradeableOwnableUpgradeable
  2. 使用 initialize() 而不是 constructor 初始化业务状态
  3. constructor 里调用 _disableInitializers(),防止实现合约被恶意初始化
  4. _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";
    }
}

注意这里我们没有改动已有存储变量顺序,仍然沿用:

  • score
  • operator

这是可升级合约里最重要的纪律之一。后面会详细讲。

如果父合约函数要被重写

由于 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 里初始化了状态,结果读取不到

现象

部署后发现 ownerscoreoperator 等值不对,或者是默认值。

原因

你把逻辑写进了实现合约 constructor,而不是 initialize()

处理

  • 把初始化逻辑迁移到 initialize()
  • constructor 中只保留 _disableInitializers()

2. 升级时报存储布局不兼容

现象

运行 upgradeProxy 时报错,提示 storage layout incompatible。

原因

通常是:

  • 改了变量顺序
  • 改了变量类型
  • 父合约继承结构变化

排查路径

  1. 对比 V1 与 V2 的状态变量顺序
  2. 对比父合约继承顺序是否变化
  3. 检查新增变量是否只追加在尾部
  4. 检查是否从普通 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”,而是:

  • 你是否能控制升级权限
  • 你是否能保证状态兼容
  • 你是否能证明升级后系统仍按预期运行

总结

如果你把本文内容压缩成一套最实用的结论,大概就是这几条:

  1. 可升级合约的核心是 Proxy + delegatecall
  2. 数据在 Proxy,逻辑在实现合约
  3. 初始化要用 initialize(),不要依赖 constructor
  4. 升级时最怕存储布局破坏,新增变量只往后加
  5. 升级权限一定要严格控制,正式环境优先多签
  6. 每次升级都要做回归测试,验证“旧状态保留 + 新逻辑可用”

如果你是第一次真正上手,我建议不要一开始就做复杂业务,先像本文这样做一个最小可运行案例,把以下动作练熟:

  • 部署 V1
  • 写入状态
  • 升级到 V2
  • 验证数据不丢
  • 验证新功能生效

只要这个闭环你能独立完成,后面再接 ERC20、NFT、治理模块、金库合约,难度就会小很多。

可升级智能合约并不神秘,但它确实比普通合约更考验工程纪律。学会它,往往也是从“会写合约”走向“能做链上系统”的分水岭。


分享到:

上一篇
《区块链中间件实战:基于事件索引与智能合约日志构建高可用链上数据服务》
下一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-343》