合约测试、部署与升级
# 合约测试、部署与升级
合约交付不是编译通过,而是规则经过测试、部署结果可核验、权限可交接、出问题有处理方案。
# 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 项目实践。
# 代理升级到底改了什么
通常保留代理地址和存储,让代理通过 delegatecall 执行新实现代码。Transparent Proxy 主要通过代理管理权限控制升级;UUPS 把升级逻辑放到实现合约,必须严格保护升级入口。两者不是 “加一个代理就自动安全” 。
升级主要检查:初始化只能按预期执行、实现合约不能被恶意初始化、升级权限受控、旧 storage 不被误解释、新旧版本业务兼容。普通构造函数不会替代理初始化状态;不能随意调换原变量顺序、改变类型或继承布局。即使使用命名空间存储,也要验证每个命名空间中的兼容性。
回滚实现代码不等于撤销已经产生的数据变化。如果新版本已经改变数据含义或转走资产,切回旧地址不能自动恢复。非升级合约则需要新部署、明确迁移和用户授权,不能把代理当作默认选择。
# 用两个槽位理解升级风险
假设 V1 用槽位 0 保存 uint256 totalDeposits,槽位 1 保存 address owner。V2 如果把这两个变量调换,原来的存款总额就可能被当成地址,原来的地址则被当成金额。代码编译通过,也不能挽救错误的存储解释。
升级测试应在代理上先执行 V1 的存款、授权等操作,再升级到 V2,断言旧余额、owner 和业务状态仍正确,并测试新功能。只部署一个空的 V2 跑测试,完全看不到旧数据损坏。还要测试非管理员升级失败、初始化不能重放,以及新状态生成后是否仍允许回退。
普通合约的 constructor 初始化自身存储;代理的存储要通过受保护的初始化入口设置。实现地址也要防止被非预期初始化或接管。新增变量、继承顺序、命名空间布局都应交给对应升级工具检查,并保留业务级断言,而不是只靠人工数槽位。
# 不变量测试要覆盖操作组合
单次提款测试只能证明一个输入下可以提款。真正的资金风险可能出现在 “存入 → 部分提取 → 退款 → 再提取” 的组合中。对一个不含收益和手续费的教学资金池,可以定义:可支配资产应覆盖已记账负债,每个用户累计提款不能超过其可提款额度。
Invariant 测试通过处理器约束可执行操作,再随机组合多次调用,每轮检查这些关系。处理器还要记录有效调用次数,避免所有随机请求都 revert,最后不变量因为什么也没发生而假通过。
Fuzz 更常用于扩大输入覆盖;Invariant 进一步检查多步骤状态演变。两者都需要合理的业务断言,不能因为跑了很多次就声称经过形式化证明或安全审计。
# 面试时可以这样回答
合约上线前最重要的检查是什么?参考答案
我先明确资产和权限的不变量,再覆盖正常、越权、重复操作和外部调用失败的测试。部署时核对链、字节码、构造参数和管理员,不能只看脚本退出成功。涉及升级时还要检查初始化、存储兼容和升级权限,并准备暂停或迁移方案,因为代码回滚不一定能回滚链上数据。
升级合约测试和普通单元测试有什么不同?参考答案
升级测试必须带着旧状态升级,而不是只测试全新的实现。我会先在 V1 代理上写入代表性的余额和权限,再升级到 V2,核对旧数据和业务行为,并检查初始化和升级权限。存储布局兼容只是基础,新代码对旧数据的含义也要兼容,否则回滚实现并不能修复已经改变的状态。
代码覆盖率很高,为什么合约仍可能有漏洞?参考答案
覆盖率只说明代码被执行过,不说明资产和权限规则被正确验证。比如正常提款覆盖了转账语句,却没有测试恶意回调或重复退款。我会从资金守恒、权限和状态互斥定义断言,补 Fuzz、状态序列和恶意合约测试,再核对部署权限;不能用单个百分比替代安全证据。