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

《区块链中级实战:基于 Solidity 与 Hardhat 搭建可升级智能合约及安全测试流程》

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

区块链中级实战:基于 Solidity 与 Hardhat 搭建可升级智能合约及安全测试流程

很多人学 Solidity 时,前半程都挺顺:写个 ERC20、部署一下、调几个函数,链上世界一片祥和。可一旦项目进入“长期维护”阶段,问题就来了:

  • 合约部署后不能直接改代码,发现 bug 怎么办?
  • 业务逻辑要迭代,地址还得保持不变,怎么做?
  • 升级之后存储布局错位,链上数据会不会直接废掉?
  • 测试不只是“能跑通”,怎么把权限、升级、安全边界一起覆盖?

这篇文章我会从一个中级开发者真正会遇到的场景出发,用 Solidity + Hardhat + OpenZeppelin Upgrades 搭一套可运行的可升级智能合约工程,并补上安全测试流程。目标不是只让你“会敲”,而是让你知道为什么这么做、哪里最容易翻车、上线前应该检查什么


背景与问题

传统智能合约一旦部署,代码不可变。这种不可变性是区块链可信的来源之一,但同时也带来了现实问题:

  1. 业务会变

    • 积分规则会改
    • 权限控制会升级
    • 运营活动要加新功能
  2. bug 不会跟你讲武德

    • 小 bug 可能只是功能异常
    • 大 bug 可能直接锁死资产
  3. 用户和前端不希望频繁切地址

    • 合约地址变了,前端、索引器、白名单、合作方配置全要改
    • 老数据迁移麻烦,链上迁移成本高

于是,可升级合约成了很多中大型项目的标配方案。

但它不是“免费午餐”。你获得升级能力的同时,也引入了新的复杂度:

  • 代理模式的调用链
  • 实现合约与代理合约的职责分离
  • 存储布局兼容性
  • 初始化函数替代构造函数
  • 升级权限的治理风险

所以本文的重点,不只是“怎么用插件部署”,而是把工程化、安全性、测试验证一起拉通。


前置知识与环境准备

如果你已经会写基础 Solidity 合约,这一节会很快。

你需要具备的基础

  • 理解 Solidity 基础语法
  • 知道 mappingmodifier、事件、继承
  • 会使用 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()
  • initializer
  • reinitializer

存储布局为什么关键

代理合约的数据槽位是固定的。如果你升级后随意改状态变量顺序,就可能出现:

  • 原本的 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 {}
}

这份代码里几个关键点

  1. Initializable

    • 替代构造函数初始化逻辑
  2. OwnableUpgradeable

    • 使用可升级版本,不要误导入普通版 Ownable
  3. UUPSUpgradeable

    • 提供 UUPS 升级能力
  4. _disableInitializers()

    • 防止实现合约本身被人直接初始化
  5. _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. 忘记调用父类初始化函数

比如你继承了 OwnableUpgradeableUUPSUpgradeable,却只写了自己的初始化逻辑,没有调用:

  • __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. 升级权限必须收紧

推荐优先级:

  1. 单人 owner:仅开发和测试环境
  2. 多签钱包:生产环境起步配置
  3. 多签 + 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 环境完整走一遍:

  1. 部署 V1
  2. 写入真实风格数据
  3. 升级到 V2
  4. 对比升级前后状态
  5. 验证前端 ABI 与事件兼容性
  6. 验证脚本、监控、索引是否正常

这一步常常能提前发现:

  • 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 替代构造函数
  • 通过代理部署并升级到新版本
  • 用测试验证功能、权限和状态一致性
  • 梳理了存储布局、初始化、升级授权等关键风险点

如果你准备把这套方案用于真实项目,我建议按下面顺序落地:

  1. 先在本地把 V1/V2 升级流程跑通
  2. 补齐失败路径测试,而不只是成功路径
  3. 用测试网做一次完整升级演练
  4. 生产环境把升级权限交给多签
  5. 每次升级前检查存储布局与权限影响

最后送你一个比较务实的判断标准:

能升级,不代表应该频繁升级;
能通过测试,不代表具备上线条件;
真正成熟的链上工程,重点从来不只是“写出来”,而是“可验证、可演练、可治理”。

如果你已经会写普通 Solidity 合约,那么把本文里的工程跑通之后,你就算真正跨进了可升级智能合约的实战门槛


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》