合约测试、部署与升级
# 合约测试、部署与升级
合约交付不是编译通过,而是规则经过测试、部署结果可核验、权限可交接、出问题有处理方案。
# 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
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));
}
}
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
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
2
3
4
5
6
7
8
9
记录输出中的部署地址与交易哈希,核对本地成功回执。下一篇 dApp 示例使用这个地址,并让测试钱包连接同一节点、选择这个 owner 账户。若自定义过 Anvil 助记词或账户,必须使用实际查询出的地址,不能沿用示例地址。
# 还需要哪些测试
| 层次 | 要证明什么 | 例子 |
|---|---|---|
| 单元测试 | 单条规则正确 | 非管理员不能铸造、零金额失败 |
| Fuzz 测试 | 大量输入下仍满足规则 | 不同金额与地址的边界 |
| Invariant 测试 | 多次随机操作后,不变量仍成立 | 已记账负债不超过可支配资金;按具体资产模型定义 |
| Fork 测试 | 与指定历史区块的真实合约兼容 | 真实代币返回行为、路由器调用 |
| 测试网集成 | 钱包、RPC、后端和部署流程连接正确 | 签名、回执、事件同步与页面回读 |
Fork 是在本地使用链上某个区块状态,不是在主网真实交易。固定区块能保证可复现,但不能证明未来状态也相同。安全相关的外部回调需要专门构造恶意接收者测试。
# 部署顺序与成功证据
- 固定代码 commit、编译器、依赖、优化参数和目标 chainId。
- 本地运行测试与部署模拟,明确初始管理员、构造参数和权限交接方案。
- 审查后才广播到目标网络;私钥使用受控签名方式,不进入文档或命令历史。
- 等待成功回执,核对目标链上的字节码、构造参数与管理员,再记录地址。
- 做源码验证,更新前端 ABI/地址,最后验证后端从部署区块补扫。
模拟阶段得到的地址不能直接当作部署成功证据。代码验证服务只是帮助别人比对源码和字节码,不等于安全审计通过。项目的部署与记录分离方法见 Foundry 项目实践。
# 代理升级到底改了什么
普通合约部署后,不能在原地址直接修改代码。比如一个资金池上线后需要修复提款逻辑,如果重新部署普通合约,用户就得改用新地址,还可能涉及旧资产和数据的迁移。
代理升级就是:保留用户使用的合约地址和已有数据,只更换执行的业务代码。它需要一开始就把合约设计成两部分:
- 代理合约:固定的对外入口,保存用户余额等数据,并记录当前使用哪个实现合约。
- 实现合约:提供存款、提款等业务代码;升级时部署一份新版本,供代理使用。
以修复资金池的提款逻辑为例:
- 升级前:用户向代理地址发起提款,代理使用 V1 的代码处理。
- 升级时:部署修复后的 V2,由有升级权限的账户发交易,把代理记录的实现地址从 V1 切到 V2。
- 升级后:用户仍向原代理地址提款,已有余额仍保存在代理里,但执行的是 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 写入的数据。
# 升级测试怎么做?
以资金池为例,测试要走完一次真实的版本切换流程:
- 先使用 V1:通过代理存入 100 个代币,记录用户余额、存款总额和管理员地址。
- 再升级到 V2:使用有权限的账户,调用正式的升级入口。
- 核对旧数据和功能:确认余额仍是 100、总额和管理员未变,用户能正常提款;再测试 V2 新增的功能。
- 验证不该成功的操作:普通用户升级必须失败,重复初始化必须失败,失败后原数据和权限仍应保持不变。
只部署一个空的 V2 来测试,只能证明新版本单独能运行,不能证明升级不会破坏旧数据。
# 不变量测试要覆盖操作组合
单次提款测试只能证明一个输入下可以提款。真正的资金风险可能出现在 “存入 → 部分提取 → 退款 → 再提取” 的组合中。对一个不含收益和手续费的教学资金池,可以定义:可支配资产应覆盖已记账负债,每个用户累计提款不能超过其可提款额度。
Invariant 测试通过处理器约束可执行操作,再随机组合多次调用,每轮检查这些关系。处理器还要记录有效调用次数,避免所有随机请求都 revert,最后不变量因为什么也没发生而假通过。
Fuzz 更常用于扩大输入覆盖;Invariant 进一步检查多步骤状态演变。两者都需要合理的业务断言,不能因为跑了很多次就声称经过形式化证明或安全审计。
# 面试时可以这样回答
合约上线前最重要的检查是什么?
建议回答:我先明确资产和权限的不变量,再覆盖正常、越权、重复操作和外部调用失败的测试。部署时核对链、字节码、构造参数和管理员,不能只看脚本退出成功。涉及升级时还要检查初始化、存储兼容和升级权限,并准备暂停或迁移方案,因为代码回滚不一定能回滚链上数据。
升级合约测试和普通单元测试有什么不同?
建议回答:升级测试必须带着旧状态升级,而不是只测试全新的实现。我会先在 V1 代理上写入代表性的余额和权限,再升级到 V2,核对旧数据和业务行为,并检查初始化和升级权限。存储布局兼容只是基础,新代码对旧数据的含义也要兼容,否则回滚实现并不能修复已经改变的状态。
代码覆盖率很高,为什么合约仍可能有漏洞?
建议回答:覆盖率只说明代码被执行过,不说明资产和权限规则被正确验证。比如正常提款覆盖了转账语句,却没有测试恶意回调或重复退款。我会从资金守恒、权限和状态互斥定义断言,补 Fuzz、状态序列和恶意合约测试,再核对部署权限;不能用单个百分比替代安全证据。