合约测试、部署与升级

# 合约测试、部署与升级

合约交付不是编译通过,而是规则经过测试、部署结果可核验、权限可交接、出问题有处理方案。

# Foundry 和 Hardhat 分别适合什么

Foundry (opens new window) 的 forge 负责编译、测试和脚本,anvil 提供本地链,cast 负责命令行 RPC 交互。Hardhat 也提供合约开发、测试和部署能力,更容易与 JavaScript/TypeScript 工程结合。选择依据是团队语言、插件和测试习惯,不是一个工具绝对替代另一个。

项目中的选型理由复用 Solidity、Foundry 与 Hardhat。下面用 Foundry 把上一篇的合约跑起来。

# 从一个权限规则写出测试

在独立的练习目录执行,避免覆盖已有项目;先从官方网站安装 Foundry。初始化会下载依赖,实际工程应固定 Foundry、Solidity 和 forge-std 版本。

# 创建新的本地练习项目;不会广播链上交易。
forge init completion-lab
cd completion-lab
# 将上一篇合约保存为 src/CompletionRegistry.sol,下面测试保存为 test/CompletionRegistry.t.sol。
# 编译并执行本地 EVM 测试;-vvvv 显示调用轨迹,帮助定位失败层级。
forge test --match-contract CompletionRegistryTest -vvvv
1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {CompletionRegistry} from "../src/CompletionRegistry.sol";

contract CompletionRegistryTest is Test {
    CompletionRegistry internal registry;
    address internal learner = address(0xBEEF); // 固定测试地址,不对应生产用户。

    function setUp() public {
        // 测试合约部署 registry,因此测试合约自身是 owner。
        registry = new CompletionRegistry();
    }

    function testOwnerCanMark() public {
        registry.markCompleted(learner);
        assertTrue(registry.completed(learner)); // 不只检查没报错,还检查结果。
    }

    function testRejectsOtherCaller() public {
        // 准确检查自定义错误及调用者参数,避免其他错误让测试误通过。
        vm.expectRevert(abi.encodeWithSelector(
            CompletionRegistry.Unauthorized.selector, learner
        ));
        vm.prank(learner); // 只把下一次业务调用的 msg.sender 改为 learner。
        registry.markCompleted(learner);
        assertFalse(registry.completed(learner));
    }

    function testRejectsDuplicate() public {
        registry.markCompleted(learner);
        vm.expectRevert(abi.encodeWithSelector(
            CompletionRegistry.AlreadyCompleted.selector, learner
        ));
        registry.markCompleted(learner);
    }

    function testRejectsZeroAddress() public {
        vm.expectRevert(CompletionRegistry.ZeroAddress.selector);
        registry.markCompleted(address(0));
    }

    function testFuzzMarkNonzeroAddress(address candidate) public {
        // Fuzz 自动生成多组输入;排除业务本来就禁止的零地址。
        vm.assume(candidate != address(0));
        registry.markCompleted(candidate);
        assertTrue(registry.completed(candidate));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

这里 vm 是 forge-std 暴露的测试能力,不是 Solidity 或链上合约自带的变量。expectRevert 检查下一次调用失败,prank 模拟调用者;不要把测试工具写进生产合约。

# 把合约部署到本地链

先在终端一启动本地链,只监听本机。Anvil 显示的账户与私钥都是公开测试资料,不能用于存放真实资产:

# 保持这个终端运行;不连接主网,也不会产生真实链上费用。
anvil --host 127.0.0.1 --port 8545
1
2

终端二进入 completion-lab,先查询本地账户,再部署:

# 查看本地节点账户;默认 Anvil 的第一个账户是下方公开测试地址。
cast rpc eth_accounts --rpc-url http://127.0.0.1:8545
# 使用本地节点的解锁测试账户发送部署交易。
# --broadcast 表示真的写入这条本地链,而不只是模拟;不要改成生产 RPC。
forge create src/CompletionRegistry.sol:CompletionRegistry \
  --rpc-url http://127.0.0.1:8545 \
  --unlocked \
  --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
  --broadcast
1
2
3
4
5
6
7
8
9

记录输出中的部署地址与交易哈希,核对本地成功回执。下一篇 dApp 示例使用这个地址,并让测试钱包连接同一节点、选择这个 owner 账户。若自定义过 Anvil 助记词或账户,必须使用实际查询出的地址,不能沿用示例地址。

# 还需要哪些测试

层次 要证明什么 例子
单元测试 单条规则正确 非管理员不能铸造、零金额失败
Fuzz 测试 大量输入下仍满足规则 不同金额与地址的边界
Invariant 测试 多次随机操作后,不变量仍成立 已记账负债不超过可支配资金;按具体资产模型定义
Fork 测试 与指定历史区块的真实合约兼容 真实代币返回行为、路由器调用
测试网集成 钱包、RPC、后端和部署流程连接正确 签名、回执、事件同步与页面回读

Fork 是在本地使用链上某个区块状态,不是在主网真实交易。固定区块能保证可复现,但不能证明未来状态也相同。安全相关的外部回调需要专门构造恶意接收者测试。

# 部署顺序与成功证据

  1. 固定代码 commit、编译器、依赖、优化参数和目标 chainId。
  2. 本地运行测试与部署模拟,明确初始管理员、构造参数和权限交接方案。
  3. 审查后才广播到目标网络;私钥使用受控签名方式,不进入文档或命令历史。
  4. 等待成功回执,核对目标链上的字节码、构造参数与管理员,再记录地址。
  5. 做源码验证,更新前端 ABI/地址,最后验证后端从部署区块补扫。

模拟阶段得到的地址不能直接当作部署成功证据。代码验证服务只是帮助别人比对源码和字节码,不等于安全审计通过。项目的部署与记录分离方法见 Foundry 项目实践。

# 代理升级到底改了什么

普通合约部署后,不能在原地址直接修改代码。比如一个资金池上线后需要修复提款逻辑,如果重新部署普通合约,用户就得改用新地址,还可能涉及旧资产和数据的迁移。

代理升级就是:保留用户使用的合约地址和已有数据,只更换执行的业务代码。它需要一开始就把合约设计成两部分:

  • 代理合约:固定的对外入口,保存用户余额等数据,并记录当前使用哪个实现合约。
  • 实现合约:提供存款、提款等业务代码;升级时部署一份新版本,供代理使用。

以修复资金池的提款逻辑为例:

  1. 升级前:用户向代理地址发起提款,代理使用 V1 的代码处理。
  2. 升级时:部署修复后的 V2,由有升级权限的账户发交易,把代理记录的实现地址从 V1 切到 V2。
  3. 升级后:用户仍向原代理地址提款,已有余额仍保存在代理里,但执行的是 V2 的代码。

切换实现地址需要链上交易,因为它修改的是链上保存的状态。只部署 V2 或修改前端配置,都不会让代理自动升级;即使通过多签或治理流程批准,最终也要执行链上交易才会生效。

这里用到的 delegatecall 可以理解为 “借别人的代码,操作自己的数据” :代理执行实现合约的代码,读写的却是代理自己的存储,并不是把余额搬到 V2。更多区别见 call 与 delegatecall。

# Transparent 与 UUPS:优缺点与选择

它们是两种常见的代理升级方案,主要区别是升级逻辑放在哪里:Transparent Proxy 放在代理一侧,通常由 ProxyAdmin 管理;UUPS 放在实现合约里。两者都必须限制谁能升级,因为能换业务代码,就可能改变资金处理规则。

  • Transparent Proxy(透明代理):升级管理与业务代码分开。优点是实现合约不必自己提供升级功能,更换业务代码时,代理一侧仍保留升级入口。缺点是代理包含更多管理逻辑,通常还要部署 ProxyAdmin 管理合约,部署成本较高,也多一层管理关系。
  • UUPS:代理更轻量,升级方式更灵活。优点是代理代码少,部署成本通常更低,升级规则也能随实现合约更新。缺点是每版实现都要维护好升级功能和权限检查;如果错误修改或禁用了升级功能,可能导致以后无法再升级。OpenZeppelin 有兼容性检查来降低误操作风险,但不能替代权限和升级测试。

怎么选:希望升级管理独立于业务实现,可以选 Transparent;更看重轻量和部署成本,可以选 UUPS,但要认真验证每版实现的升级能力。两者都要保护管理权限、检查存储兼容性,不能简单认为某一种天然更安全。

参考:OpenZeppelin:Transparent 与 UUPS 对比 (opens new window)。

# 合约升级的风险与测试

升级不只是让新功能能用,还要保证旧余额不出错、管理权限不被抢走。因此不能只测试全新的 V2,还要验证从 V1 升到 V2 后,原来的业务能否继续正常运行。

# 为什么保留下来的数据还可能读错?

合约长期保存数据的空间叫 storage,其中的槽位(slot)就像有编号的储物格。存储布局就是:每项数据放在哪个位置、按什么类型读取。执行时按位置取数据,不是按变量名查找。

假设 V1 用两个槽位分别保存存款总额和管理员地址,V2 却把这两个变量的声明顺序调换了:

存储位置 V1 原来保存的数据 V2 调换顺序后怎么读
0 号槽位 存款总额 totalDeposits 错当成管理员地址
1 号槽位 管理员地址 owner 错当成存款总额

数据还在原处,但新代码把它认错了。因此不能随意调换旧变量的顺序、改变类型或继承关系;新增变量也要用升级工具检查布局是否兼容,不能只看代码能否编译通过。

# 除了数据,还要防哪些问题?

  • 管理员被重新设置:初始化就是首次设置管理员等配置。实现合约的构造函数不会替代理完成这些设置,需要通过代理执行初始化函数。应在部署代理时一起完成,并禁止重复执行,避免别人抢先或再次初始化;实现合约自身不需要的初始化入口也应禁用。
  • 未经授权更换代码:只有指定的管理员或治理流程能升级。否则攻击者可能把实现换成能转走资金的代码。
  • 误以为切回旧版就能恢复:切回 V1 只是换回旧代码,不会撤销 V2 已执行的操作。例如 V2 已把钱转走,切回 V1 也不会让钱自动回来;还要检查 V1 能否正确读取 V2 写入的数据。

# 升级测试怎么做?

以资金池为例,测试要走完一次真实的版本切换流程:

  1. 先使用 V1:通过代理存入 100 个代币,记录用户余额、存款总额和管理员地址。
  2. 再升级到 V2:使用有权限的账户,调用正式的升级入口。
  3. 核对旧数据和功能:确认余额仍是 100、总额和管理员未变,用户能正常提款;再测试 V2 新增的功能。
  4. 验证不该成功的操作:普通用户升级必须失败,重复初始化必须失败,失败后原数据和权限仍应保持不变。

只部署一个空的 V2 来测试,只能证明新版本单独能运行,不能证明升级不会破坏旧数据。

# 不变量测试要覆盖操作组合

单次提款测试只能证明一个输入下可以提款。真正的资金风险可能出现在 “存入 → 部分提取 → 退款 → 再提取” 的组合中。对一个不含收益和手续费的教学资金池,可以定义:可支配资产应覆盖已记账负债,每个用户累计提款不能超过其可提款额度。

Invariant 测试通过处理器约束可执行操作,再随机组合多次调用,每轮检查这些关系。处理器还要记录有效调用次数,避免所有随机请求都 revert,最后不变量因为什么也没发生而假通过。

Fuzz 更常用于扩大输入覆盖;Invariant 进一步检查多步骤状态演变。两者都需要合理的业务断言,不能因为跑了很多次就声称经过形式化证明或安全审计。

# 面试时可以这样回答

合约上线前最重要的检查是什么?

建议回答:我先明确资产和权限的不变量,再覆盖正常、越权、重复操作和外部调用失败的测试。部署时核对链、字节码、构造参数和管理员,不能只看脚本退出成功。涉及升级时还要检查初始化、存储兼容和升级权限,并准备暂停或迁移方案,因为代码回滚不一定能回滚链上数据。

升级合约测试和普通单元测试有什么不同?

建议回答:升级测试必须带着旧状态升级,而不是只测试全新的实现。我会先在 V1 代理上写入代表性的余额和权限,再升级到 V2,核对旧数据和业务行为,并检查初始化和升级权限。存储布局兼容只是基础,新代码对旧数据的含义也要兼容,否则回滚实现并不能修复已经改变的状态。

代码覆盖率很高,为什么合约仍可能有漏洞?

建议回答:覆盖率只说明代码被执行过,不说明资产和权限规则被正确验证。比如正常提款覆盖了转账语句,却没有测试恶意回调或重复退款。我会从资金守恒、权限和状态互斥定义断言,补 Fuzz、状态序列和恶意合约测试,再核对部署权限;不能用单个百分比替代安全证据。

# 官方参考

上次更新时间: 2026年10月06日 19:34:07