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

《Web3 中级实战:基于 Solidity 与 Ethers.js 构建可升级智能合约的部署、交互与安全校验》

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

Web3 中级实战:基于 Solidity 与 Ethers.js 构建可升级智能合约的部署、交互与安全校验

可升级智能合约,是很多人从“会写合约”走向“能做线上系统”的分水岭。

我第一次把普通合约改成可升级架构时,最直观的感受是:代码不难,难的是脑子里要同时装下“代理合约、实现合约、存储布局、初始化、升级权限、安全校验”这几件事。一旦其中某个环节想当然,部署可以成功,但线上一升级就可能把状态打坏,损失往往不可逆。

这篇文章不打算只讲概念,而是带你完整走一遍一个中级实战流程:

  1. 用 Solidity 写一个可升级计数器合约
  2. 用 Hardhat + OpenZeppelin Upgrades + Ethers.js 部署
  3. 用 Ethers.js 交互读写
  4. 升级到 V2 版本并保留旧状态
  5. 做基础安全校验与常见问题排查

如果你已经会写普通 Solidity 合约,也知道 Ethers.js 基本用法,那么这篇正适合你把“会写”升级成“会用、会查、会避坑”。


背景与问题

普通智能合约一旦部署,代码通常不可改。这符合区块链“不可篡改”的核心特性,但在真实业务里会马上遇到几个问题:

  • 逻辑发现 bug,想修复
  • 业务规则变化,需要加新功能
  • 需要逐步迭代,而不是一次性写死
  • 前期快速上线,后期优化 gas 或安全策略

于是就出现了可升级智能合约。它的核心思想不是“修改已经部署的代码”,而是:

  • 用户始终访问一个代理合约(Proxy)
  • 代理把调用转发给实现合约(Implementation)
  • 状态数据保存在代理合约里
  • 升级时只替换实现合约地址,不动代理地址和状态

所以从前端、脚本、集成方视角看,合约地址没变;从系统维护视角看,逻辑却能演进。

但问题也随之而来:

  • 构造函数不能随便用,要改成 initialize
  • 存储布局不能乱改
  • 升级权限必须控制好
  • 部署成功不代表升级安全
  • 与 Ethers.js 交互时要分清你连的是代理还是实现

这些正是中级开发者最容易踩的坑。


前置知识与环境准备

本文使用以下技术栈:

  • Node.js 16+
  • Hardhat
  • Solidity ^0.8.20
  • Ethers.js
  • OpenZeppelin Contracts Upgradeable
  • OpenZeppelin Hardhat Upgrades

先初始化项目:

mkdir upgradeable-counter-demo
cd upgradeable-counter-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades ethers
npx hardhat

选择创建一个 JavaScript 项目。

项目结构大致如下:

.
├── contracts
   ├── CounterV1.sol
   └── CounterV2.sol
├── scripts
   ├── deploy.js
   ├── interact.js
   └── upgrade.js
├── test
├── hardhat.config.js
└── package.json

核心原理

1. 代理模式在做什么

可升级合约最常见的是 Proxy Pattern。你可以简单理解为:

  • Proxy:门面,地址固定,对外服务
  • Implementation:真正的逻辑代码,可替换
  • Storage:数据实际存在 Proxy 中
flowchart LR
    User[用户 / 前端 / 脚本] --> Proxy[Proxy 合约]
    Proxy -->|delegatecall| Impl1[Implementation V1]
    Impl1 -.读写状态.- Proxy
    Proxy -->|升级后 delegatecall| Impl2[Implementation V2]
    Impl2 -.继续读写旧状态.- Proxy

这里最关键的是 delegatecall

  • 执行的是实现合约代码
  • 但使用的是代理合约的存储上下文

所以升级时,只要存储布局保持兼容,旧数据就不会丢。


2. 为什么不能直接用构造函数

普通合约部署时,构造函数会自动执行一次;但在代理模式里,真正对外服务的是代理,不是实现合约本体。

因此可升级合约通常使用初始化函数,例如:

function initialize(uint256 _count) public initializer

并配合:

  • initializer
  • reinitializer
  • __Ownable_init()
  • __UUPSUpgradeable_init() 等父类初始化器

3. 为什么存储布局极其重要

代理把状态存在自己身上,而实现合约只是“解释这些槽位的规则”。

如果你在 V1 里这样定义:

uint256 public count;
address public owner;

到了 V2 你若改成:

address public owner;
uint256 public count;

那就不是“变量顺序小调整”,而是把原来的存储槽位解释错了,后果通常是灾难性的。

安全升级的基本原则:

  • 只在末尾追加新变量
  • 不删除老变量
  • 不调整已有变量顺序
  • 不随意改类型
  • 尽量预留 storage gap

4. 部署、交互、升级关系图

sequenceDiagram
    participant Dev as 开发者
    participant Script as Hardhat脚本
    participant Proxy as Proxy合约
    participant ImplV1 as 实现V1
    participant ImplV2 as 实现V2

    Dev->>Script: 部署 V1
    Script->>ImplV1: deploy implementation
    Script->>Proxy: deploy proxy + initialize
    Dev->>Proxy: 调用 increment()
    Proxy->>ImplV1: delegatecall
    Dev->>Script: 执行升级
    Script->>ImplV2: deploy implementation
    Script->>Proxy: upgradeTo(ImplV2)
    Dev->>Proxy: 调用 decrement()
    Proxy->>ImplV2: delegatecall

实战代码(可运行)

这部分我们做一个最小但完整的例子:

  • CounterV1:初始化、递增、读取
  • CounterV2:新增递减功能,保留原状态
  • 使用 UUPS 升级模式
  • 用 Ethers.js 完成交互

1. 配置 Hardhat

hardhat.config.js

require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  }
};

2. 编写 V1 合约

contracts/CounterV1.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";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract CounterV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public count;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(uint256 _initialCount) public initializer {
        __Ownable_init(msg.sender);
        __UUPSUpgradeable_init();
        count = _initialCount;
    }

    function increment() public {
        count += 1;
    }

    function getVersion() public pure returns (string memory) {
        return "V1";
    }

    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

这里有几个关键点:

  • constructor() 里调用 _disableInitializers():防止实现合约本体被人单独初始化
  • initialize() 替代构造函数
  • _authorizeUpgrade()onlyOwner 限制升级权限
  • 采用 UUPSUpgradeable,升级逻辑由实现合约自己控制

3. 编写 V2 合约

contracts/CounterV2.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./CounterV1.sol";

contract CounterV2 is CounterV1 {
    function decrement() public {
        require(count > 0, "count is already zero");
        count -= 1;
    }

    function getVersion() public pure override returns (string memory) {
        return "V2";
    }
}

注意这里是继承 V1,并在末尾追加能力。这样最容易保持布局兼容。

不过上面的 getVersion 要求父合约函数支持重写,因此把 CounterV1.sol 里的函数改成这样更规范:

function getVersion() public pure virtual returns (string memory) {
    return "V1";
}

所以,最终 CounterV1.solgetVersion() 记得加 virtual


4. 部署脚本

scripts/deploy.js

const { ethers, upgrades } = require("hardhat");

async function main() {
  const CounterV1 = await ethers.getContractFactory("CounterV1");

  const counter = await upgrades.deployProxy(
    CounterV1,
    [10],
    {
      initializer: "initialize",
      kind: "uups"
    }
  );

  await counter.waitForDeployment();

  const proxyAddress = await counter.getAddress();
  console.log("Proxy deployed to:", proxyAddress);

  const current = await counter.count();
  console.log("Initial count:", current.toString());

  const version = await counter.getVersion();
  console.log("Version:", version);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

执行:

npx hardhat run scripts/deploy.js

如果要发测试网,可以加 --network sepolia,并在配置中补充 RPC 和私钥。


5. 用 Ethers.js 交互

scripts/interact.js

const { ethers } = require("hardhat");

async function main() {
  const proxyAddress = "你的代理合约地址";

  const counter = await ethers.getContractAt("CounterV1", proxyAddress);

  let count = await counter.count();
  console.log("Before increment:", count.toString());

  const tx = await counter.increment();
  await tx.wait();

  count = await counter.count();
  console.log("After increment:", count.toString());

  const version = await counter.getVersion();
  console.log("Version:", version);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

这里虽然拿的是 CounterV1 的 ABI,但地址填的是 Proxy 地址。这点非常重要:

  • ABI 决定你怎么编码/解码调用
  • 地址决定你到底调用谁

代理地址 + V1 ABI,实际执行的还是代理当前指向的逻辑。


6. 升级脚本

scripts/upgrade.js

const { ethers, upgrades } = require("hardhat");

async function main() {
  const proxyAddress = "你的代理合约地址";

  const CounterV2 = await ethers.getContractFactory("CounterV2");
  const upgraded = await upgrades.upgradeProxy(proxyAddress, CounterV2);

  await upgraded.waitForDeployment();

  console.log("Proxy upgraded at:", await upgraded.getAddress());

  const version = await upgraded.getVersion();
  console.log("Current version:", version);

  const before = await upgraded.count();
  console.log("Count before decrement:", before.toString());

  const tx = await upgraded.decrement();
  await tx.wait();

  const after = await upgraded.count();
  console.log("Count after decrement:", after.toString());
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

执行升级:

npx hardhat run scripts/upgrade.js

7. 升级前后验证清单

建议你按这个顺序自己验证一遍:

  1. 部署 V1,初始值为 10
  2. 调用 increment(),确认变成 11
  3. 调用 getVersion(),确认返回 V1
  4. 执行升级到 V2
  5. 再次读取 count,确认还是 11,没有丢失
  6. 调用 getVersion(),确认返回 V2
  7. 调用 decrement(),确认变成 10

这一步的意义,不只是“脚本跑通”,而是要确认:升级前后的状态连续性


用测试补一层保险

真实项目里,升级前只跑部署脚本是不够的,至少要有一个自动化测试。

test/Counter.js

const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");

describe("Upgradeable Counter", function () {
  it("should preserve state after upgrade", async function () {
    const CounterV1 = await ethers.getContractFactory("CounterV1");
    const counterV1 = await upgrades.deployProxy(CounterV1, [5], {
      initializer: "initialize",
      kind: "uups"
    });
    await counterV1.waitForDeployment();

    await (await counterV1.increment()).wait();
    expect(await counterV1.count()).to.equal(6n);
    expect(await counterV1.getVersion()).to.equal("V1");

    const proxyAddress = await counterV1.getAddress();

    const CounterV2 = await ethers.getContractFactory("CounterV2");
    const counterV2 = await upgrades.upgradeProxy(proxyAddress, CounterV2);

    expect(await counterV2.count()).to.equal(6n);
    expect(await counterV2.getVersion()).to.equal("V2");

    await (await counterV2.decrement()).wait();
    expect(await counterV2.count()).to.equal(5n);
  });
});

运行:

npx hardhat test

常见坑与排查

这部分我建议你认真看,因为很多问题不是“不会写”,而是“以为自己写对了”。

1. 报错:Initializable: contract is already initialized

通常有几种原因:

  • initialize() 被重复调用
  • 部署脚本把代理和实现逻辑弄混了
  • 升级后错误使用了 initializer 而不是 reinitializer

排查思路:

  1. 看你是否通过 deployProxy() 自动初始化过
  2. 看脚本里是否又手动执行了一次 initialize()
  3. 如果是 V2 新增初始化逻辑,应使用 reinitializer(2)

示例:

function initializeV2() public reinitializer(2) {
    // 新增模块初始化
}

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

典型现象:

  • Hardhat Upgrades 插件直接拒绝升级
  • 提示 variable order changed / type changed / deleted variable

这其实是好事,说明工具帮你挡雷了。

错误示例:

// V1
uint256 public count;
address public user;
// V2 错误写法
address public user;
uint256 public count;

正确做法:

// V2 正确思路:保留原顺序,在末尾新增
uint256 public count;
address public user;
uint256 public lastUpdatedAt;

3. 升级成功了,但前端调用不到新方法

常见原因:

  • 地址还是对的,但 ABI 还是旧的
  • 前端缓存了旧合约对象
  • 你连接的是实现合约地址,不是代理地址

排查建议:

  • 升级后重新生成前端 ABI
  • 确认前端使用的还是代理地址
  • 在脚本里打印 getVersion() 作为快速确认

4. OwnableUnauthorizedAccount 或升级权限不足

出现这个错误,通常说明:

  • 当前 signer 不是 owner
  • 部署时 owner 初始化错了
  • 多签/代理管理权限未配置好

可以先检查:

const owner = await counter.owner();
console.log("owner:", owner);
console.log("signer:", signer.address);

如果线上项目要升级,建议不要把 owner 直接给个人地址,而是给:

  • 多签钱包
  • Timelock 合约
  • 专门的治理模块

5. 忘了锁死实现合约初始化

如果实现合约本体可以被初始化,攻击者可能直接初始化实现合约,污染权限认知,甚至在某些错误集成场景里制造更大风险。

所以这段代码不要省:

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

这是很多人初学可升级合约时最容易忽略的点之一。


安全/性能最佳实践

可升级合约的“安全”,不只是防重入、整数溢出这些传统问题,还包括升级本身的治理与操作安全。

1. 升级权限最小化

最基本要求:

  • 升级函数必须受控
  • 不要把升级权交给热钱包长期持有
  • 生产环境优先多签治理

如果项目资金量较大,建议组合:

  • onlyOwner + 多签
  • 多签 + Timelock
  • 升级前链下审计 + 主网模拟

2. 严格维护存储布局

这是可升级项目的生命线。

建议执行规则:

  • 老变量不删不改序
  • 新变量只追加在末尾
  • 父合约升级也要谨慎
  • 版本迭代前跑 validateUpgrade

虽然 Hardhat Upgrades 通常会自动做校验,但不要完全依赖工具,自己要知道规则。


3. 初始化逻辑要幂等、可审计

初始化函数里不要写太多复杂逻辑,尤其避免:

  • 外部调用过多
  • 依赖动态输入过多
  • 权限设置分散在多个路径

更推荐:

  • 初始化只做必要赋值和模块 init
  • 复杂业务通过后续受控函数完成
  • 对关键初始化参数做事件记录

4. 对外部调用保持谨慎

如果你的升级版本里开始引入:

  • ERC20 转账
  • 预言机回调
  • 跨合约调用
  • delegatecall 扩展模块

那安全风险会迅速上升。至少要补:

  • 重入保护
  • 调用返回值检查
  • 权限边界验证
  • 暂停机制(Pausable)

5. 性能层面:不要把“可升级”当“随便改”

可升级不是鼓励频繁上线,而是给系统留修正能力。

实务里建议:

  • 升级频率低于普通 Web 服务
  • 每次升级只做小范围改动
  • 升级前后做 gas 对比
  • 复杂功能模块化,减少单次升级面

如果一个版本同时改存储、改权限、改业务流、改事件结构,那排查成本会非常高。


6. 增加事件,方便审计与追踪

比如:

event Increment(address indexed caller, uint256 newCount);
event Decrement(address indexed caller, uint256 newCount);

在链上系统里,事件既是调试工具,也是审计线索。很多时候用户反馈“数据不对”,你第一时间不是看前端,而是去看链上事件和交易输入。


一个更完整的心智模型

如果你总觉得可升级合约容易绕,我建议记住这张图:

stateDiagram-v2
    [*] --> DeployV1
    DeployV1 --> Initialized
    Initialized --> RunningV1
    RunningV1 --> UpgradeCheck
    UpgradeCheck --> UpgradeRejected: 存储不兼容/权限不足
    UpgradeCheck --> RunningV2: 升级成功
    RunningV2 --> RunningV2

把它想成一个受控的软件发布流程,而不是“链上改代码”的魔法:

  • 部署是一次发布
  • 初始化是第一次配置
  • 升级是一次受控发布
  • 存储兼容性是数据库迁移约束
  • 代理地址就是对外稳定入口

这样理解后,很多抽象概念就落地了。


逐步验证清单

如果你准备把这套流程用于自己的项目,我建议按下面的检查表执行:

开发阶段

  • 构造函数中调用 _disableInitializers()
  • 使用 initialize() 替代 constructor
  • _authorizeUpgrade() 做权限控制
  • 新版本仅追加状态变量
  • 关键函数有事件日志

测试阶段

  • V1 部署成功
  • 初始化参数正确
  • 核心读写功能正常
  • 升级到 V2 后旧状态保留
  • 新方法可调用
  • 非 owner 升级被拒绝

上线前

  • 核对代理地址与实现地址
  • 核对前端 ABI 是否更新
  • 升级脚本在测试网完整演练
  • owner 是否为多签/治理地址
  • 有回滚预案或紧急暂停方案

总结

可升级智能合约的难点,从来不只是“怎么调用插件部署”,而是要建立一套完整的工程认知:

  • 代理地址不变,逻辑地址可变
  • 状态在代理中,逻辑在实现中
  • 初始化替代构造函数
  • 存储布局兼容是升级成败的底线
  • 升级权限控制决定你的系统有没有治理安全

如果你是中级开发者,我建议下一步不要急着上复杂业务,而是先把这篇文章的示例自己扩展两次:

  1. 在 V2 里新增事件与权限控制
  2. 再做一个 V3,增加新变量,验证状态仍能保留

当你能稳定地做完这两步,并且知道每一步为什么安全,说明你已经不只是“会用可升级合约”,而是开始具备线上交付能力了。

最后给一个很实用的建议:把每次升级都当成一次数据库迁移 + 权限变更 + 生产发布。只要你用这个标准要求自己,很多低级坑自然会避开。


分享到:

上一篇
《Web逆向实战:基于浏览器 DevTools 与 AST 还原前端签名算法的完整方法》
下一篇
《Java 中基于 CompletableFuture 的异步编排实战:从并行聚合到超时降级设计》