合约安全评估不能只看表面结果
“主要流程跑通”和“人工看过一遍”只说明少量样本没有立即失败,不能说明资金账本、权限和外部依赖在异常顺序下仍然正确。合约测试要保存环境、输入和断言,代码或依赖变化后才能重放同一结论。
自动化测试、静态分析和人工审计各自覆盖一部分风险。单函数测试检查边界与权限,不变性测试探索调用序列,fork 测试验证某个链上快照下的集成行为;经济模型和治理风险仍要单独讨论。覆盖率是观察测试范围的信号,不能换算成笼统的“安全分数”。
1. 合约安全评估的客观度量体系
建立评估材料时,可以从覆盖信息、业务不变性和外部依赖三个角度组织证据。
1.1 分支覆盖率与 opcode 指令覆盖率
行覆盖率容易掩盖条件组合。例如require(a || b)所在行被执行过,不表示a为假、b为真等路径都经过断言。因此应关注分支和失败路径。opcode 覆盖可以补充观察字节码执行范围,但覆盖率高不代表断言正确,也不代表跨合约经济行为已经验证。
1.2 属性不变性(Invariant Check)的随机游走次数
不变性描述在允许状态下始终应成立的约束,例如账面总额等于用户份额之和。模糊测试会探索许多输入与调用顺序,但次数增加不等于覆盖完整状态空间。运行次数、序列深度和输入分布应按合约复杂度、CI 时间和历史缺陷调整,并保存失败种子方便复现。
1.3 Fork 主网状态下的组合攻击抗性
涉及外部池、预言机或治理合约时,本地 mock 容易遗漏真实接口和状态组合。主网 fork 能固定到某个区块重放调用,适合检查集成假设,但它不会自动模拟未来流动性、区块构建和跨链条件。测试还要主动构造价格变化、更新延迟和权限变更。
2. Solidity 合约与 Foundry Fuzzing 套件实现
下面用一个简化 Vault 展示 Foundry Handler 和 ghost 变量。它没有利息、份额价格、管理员或升级逻辑,不应被当成生产金库实现。
2.1 待测 Vault 目标合约
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; contract YieldVault is ReentrancyGuard { using SafeERC20 for IERC20; IERC20 public immutable stakingToken; uint256 public totalSupply; mapping(address => uint256) private _balances; event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); constructor(address _stakingToken) { require(_stakingToken != address(0), "Invalid token address"); stakingToken = IERC20(_stakingToken); } function balanceOf(address account) external view returns (uint256) { return _balances[account]; } function deposit(uint256 amount) external nonReentrant { require(amount > 0, "Cannot deposit 0"); uint256 balanceBefore = stakingToken.balanceOf(address(this)); stakingToken.safeTransferFrom(msg.sender, address(this), amount); uint256 balanceAfter = stakingToken.balanceOf(address(this)); // 抵御支持转账扣税/通缩型代币的实际到账校验 uint256 actualDeposited = balanceAfter - balanceBefore; require(actualDeposited > 0, "Zero deposit amount"); _balances[msg.sender] += actualDeposited; totalSupply += actualDeposited; emit Deposited(msg.sender, actualDeposited); } function withdraw(uint256 amount) external nonReentrant { require(amount > 0, "Cannot withdraw 0"); require(_balances[msg.sender] >= amount, "Insufficient balance"); _balances[msg.sender] -= amount; totalSupply -= amount; stakingToken.safeTransfer(msg.sender, amount); emit Withdrawn(msg.sender, amount); } }通过前后余额计算实际到账量,可以避免入账金额高于金库收到的金额。但对扣费型代币,取款者最终收到多少仍取决于代币的转账语义;若产品不支持这类资产,更清晰的做法是在资产准入时拒绝,而不是只在存款端兼容。
2.2 Foundry Invariant 模糊测试与属性断言
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "./YieldVault.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; // 基础 Mock;扣费、回调和返回值异常应使用单独的恶意 Token 测试 contract MockERC20 is ERC20 { constructor() ERC20("Mock Token", "MTK") { _mint(msg.sender, 1_000_000_000 * 10**18); } function mint(address to, uint256 amount) external { _mint(to, amount); } } // Handler 模式:限制随机调用的边界 contract VaultHandler is Test { YieldVault public vault; MockERC20 public token; uint256 public ghost_sumDeposits; address[] public actors; address internal currentActor; constructor(YieldVault _vault, MockERC20 _token) { vault = _vault; token = _token; actors.push(address(0x1111)); actors.push(address(0x2222)); actors.push(address(0x3333)); for (uint i = 0; i < actors.length; i++) { token.mint(actors[i], 1_000_000 * 10**18); vm.prank(actors[i]); token.approve(address(vault), type(uint256).max); } } function deposit(uint256 actorIndex, uint256 amount) public { currentActor = actors[bound(actorIndex, 0, actors.length - 1)]; amount = bound(amount, 1, 100_000 * 10**18); vm.prank(currentActor); vault.deposit(amount); ghost_sumDeposits += amount; } function withdraw(uint256 actorIndex, uint256 amount) public { currentActor = actors[bound(actorIndex, 0, actors.length - 1)]; uint256 actorBalance = vault.balanceOf(currentActor); if (actorBalance == 0) return; amount = bound(amount, 1, actorBalance); vm.prank(currentActor); vault.withdraw(amount); ghost_sumDeposits -= amount; } } // 主 Invariant 测试合约 contract YieldVaultInvariantTest is Test { YieldVault public vault; MockERC20 public token; VaultHandler public handler; function setUp() public { token = new MockERC20(); vault = new YieldVault(address(token)); handler = new VaultHandler(vault, token); // 目标 Target 仅指向 Handler targetContract(address(handler)); } /// 核心不变性 1: Vault 内代币余额必须始终大于等于总记账 supply function invariant_solvency() public view { assertGe( token.balanceOf(address(vault)), vault.totalSupply(), "Vault is insolvent! Contract balance lower than totalSupply." ); } /// 核心不变性 2: 总 totalSupply 必须精确等于 Ghost 变量记录的用户存款和 function invariant_supplyEqualsGhostSum() public view { assertEq( vault.totalSupply(), handler.ghost_sumDeposits(), "TotalSupply desynchronized with actual user deposits." ); } }3. 端到端 Mainnet Fork 集成测试规范
对于依赖真实流动性池或预言机接口的合约,可以增加主网 Fork 集成测试。区块高度必须固定,否则同一用例会随链上状态变化;RPC 地址通过测试环境注入,不应写入仓库或日志。
下面代码只展示固定区块和账户模拟。地址、区块与余额都属于该快照的前提,使用前应核对;末尾没有实现价格冲击和协议断言,因此不能把用例名称当成已经验证的安全结论。
import { expect } from "chai"; import { ethers, network } from "hardhat"; describe("Mainnet Fork Integration Security Suite", function () { const UNISWAP_V3_ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564"; const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // 示例账户必须在固定区块上核对资产余额 const WHALE_ADDRESS = "0x47ac0Fb3F2D84898e4D9E7b4DaB3C24507a6D503"; before(async function () { // 强制 Fork 指定高度的主网快照 await network.provider.request({ method: "hardhat_reset", params: [ { forking: { jsonRpcUrl: process.env.MAINNET_RPC_URL || "", blockNumber: 18500000, }, }, ], }); }); it("为价格冲击场景准备固定的 fork 状态", async function () { const whaleSigner = await ethers.getImpersonatedSigner(WHALE_ADDRESS); // 给鲸鱼账户补充 ETH 支付 Gas await network.provider.send("hardhat_setBalance", [ WHALE_ADDRESS, "0x1000000000000000000", ]); const USDC = await ethers.getContractAt("IERC20", USDC_ADDRESS, whaleSigner); const initialBalance = await USDC.balanceOf(WHALE_ADDRESS); expect(initialBalance).to.be.gt(0); // 后续必须执行真实池交易,并断言被测协议使用的价格与清算结果。 // 如果缺少这些步骤,此用例只能验证测试夹具,不验证抗操纵能力。 }); });4. 落地测试策略的建立路线
测试策略应当和资产、权限及依赖一起演进,流水线负责稳定重放,人工评审负责判断断言是否覆盖业务风险。
第一步是在 CI 中运行编译、单元测试和静态分析。静态工具的告警要分类处理:确认的问题修复,误报记录理由和适用范围。简单要求“零 Warning”容易诱导屏蔽规则,也无法替代人工判断。
第二步为关键资产操作写不变性,并让 Handler 只调用业务允许的入口。快速配置可在每次提交运行,更深的序列放到定时任务;次数与深度记录在配置中,失败时保存随机种子和最小化后的调用序列。
第三步针对外部协议做固定区块的 Fork 测试,覆盖正常交换、价格源过期、流动性变化和权限变更。另设更新区块的兼容测试可以发现依赖升级,但它与可重复的固定快照用例应分开。
最后把测试未覆盖项写进评审报告,例如治理密钥、跨链消息、经济攻击成本和部署参数。分层测试能提供更可靠的证据,却不能证明“没有漏洞”;真正有价值的是每个结论都能回到具体断言、输入和环境。