导语:当测试覆盖不到"所有输入"
单元测试和模糊测试都是抽样验证:跑过的输入通过了,没跑过的仍然是未知。对于一个管理十亿美元资产的合约,“我测了 500 个用例"并不构成安全论证。形式化验证(Formal Verification)换了个承诺:用数学证明断言在全部输入空间上恒成立。
本文梳理智能合约形式化验证的三条技术路线,重点讲清 Certora Prover 与 K 框架的工作机制、规格怎么写、以及为什么"验证通过"不等于"绝对安全”。
一句话总结:形式化验证把"我没找到 bug"升级为"在给定规格下不存在 bug"——但规格本身写错了,证明再漂亮也没用。
1. 理论基础:从霍尔逻辑说起
1.1 霍尔三元组
形式化验证的语言基础是霍尔逻辑(Hoare Logic),它把程序行为写成三元组:
{ P } S { Q }
前置条件 P,执行语句 S,若 S 终止则后置条件 Q 成立
例如"余额充足则转账后总额不变"可以写成:
{ balance[a] >= amount ∧ total = balance[a] + balance[b] }
transfer(a, b, amount)
{ total = balance[a] + balance[b] }
合约验证的目标,就是把这类断言变成机器可检查的命题。
1.2 不变量:合约安全的核心
对合约而言最有价值的性质是不变量(Invariant)——在所有可达状态下都成立的命题。典型例子:
| 不变量 | 含义 | 保护的漏洞 |
|---|---|---|
sum(balances) == totalSupply | 余额之和恒等于总量 | 增发 / 双花 |
lpSupply > 0 → k >= k_last | AMM 的 k 值单调不减 | 价格操纵 |
totalDebt <= totalCollateral * LTV | 借贷不超抵押上限 | 坏账 |
owner != address(0) | 所有权不被置空 | 权限丢失 |
不变量是规格的灵魂:先把"什么永远不能变"说清楚,再让工具去证。
1.3 可判定性的边界
并非所有性质都可自动证明。停机问题告诉我们通用程序的性质不可判定,因此所有工具都必须在表达力与自动化程度之间取舍:
| 路线 | 表达力 | 自动化 | 代表 |
|---|---|---|---|
| 模型检验 | 低 | 高 | 状态空间枚举 |
| SMT 求解 | 中 | 高 | Certora |
| 交互式定理证明 | 高 | 低 | Coq / Lean |
| 可执行语义 + 重写 | 高 | 中 | K 框架 |
2. Certora Prover:CVL + SMT
2.1 工作流
Certora 的核心是规格语言 CVL(Certora Verification Language),与 Solidity 源码分离。它把合约编译成中间表示(TAC),再连同 CVL 规则翻译成 SMT 公式,交给 Z3 等求解器判定。
Solidity + CVL
↓ 编译
TAC(三地址码)
↓ 编码
SMT-LIB 公式
↓ 求解
SAT(反例) / UNSAT(证明成立)
2.2 一条规则的结构
CVL 规则形如"对任意合法的初始状态与任意参数,若前置条件成立,则后置条件必须成立":
rule transferPreservesTotal(address a, address b, uint256 amount) {
// 前置:调用者余额充足
require balanceOf(a) >= amount;
uint256 totalBefore = totalSupply();
// 执行待验证函数(调用者设为 a)
transfer@withrevert(a, b, amount);
// 后置:无论是否 revert,总量不变
assert totalSupply() == totalBefore;
}
关键点:@withrevert 让规则同时覆盖正常返回与 revert 两条路径,因此断言必须在两种情况下都成立。这正是形式化验证强于单元测试的地方——它自动探索了 revert 分支。
2.3 参数化规则:覆盖所有地址
一条规则只验证一个 (a, b, amount) 组合是没意义的。CVL 的 param 方法让工具在任意地址集合上证明:
methods {
function totalSupply() external returns (uint256) envfree;
function balanceOf(address) external returns (uint256) envfree;
function transfer(address, uint256) external;
}
rule totalSupplyInvariant(method f) {
env e;
calldataarg args;
uint256 before = totalSupply();
f(e, args);
assert totalSupply() == before,
"total supply must never change";
}
method f + calldataarg args 表示"对合约中任意一个外部函数、任意一组参数"。这样一条规则就等价于对所有入口的穷举——这是人工测试无法企及的覆盖面。
2.4 反例与调试
当求解器返回 SAT 时,Certora 给出一条反例(Counterexample),包含完整的调用序列与变量取值:
Counterexample:
Call trace:
1. transfer(0xdead..., 0xbeef..., 100)
2. mint(0xdead..., 1)
Violated assertion:
totalSupply() == before
Assignment:
totalSupply_before = 1000
totalSupply_after = 1001
反例的价值在于它直接指出漏洞路径,而不是让你猜。工程实践中,反例往往能发现测试用例从未想到的调用序列。
2.5 成本与适用边界
Certora 的代价是规格编写成本高且求解时间可能很长(复杂规则数小时)。实践中应优先验证:
- 核心资金流函数(转账、铸造、清算);
- 权限控制(谁能调用什么);
- 不变量(总量守恒、价格单调性)。
对纯视图函数、事件发射这类无状态逻辑,投入产出比很低,用测试覆盖即可。
3. K 框架与 KEVM:可执行语义
3.1 语义即定义
K 框架的思路完全不同:它不翻译成 SMT,而是为语言写一份可执行的形式语义(Executable Semantics),再用重写逻辑(Rewriting Logic)做推理。
KEVM = EVM 的完整形式语义,用 K 框架写成
含义:每个 EVM 操作码的行为都被精确定义为一条重写规则
一旦语义完整,就能做三件事:
| 能力 | 说明 |
|---|---|
| 执行 | 语义可当作解释器直接跑字节码 |
| 符号执行 | 把输入符号化,探索路径 |
| 定理证明 | 证明程序满足 K 中写下的规格 |
3.2 一条 K 规则长什么样
rule <k> PUSH1 0x01 ~> PUSH1 0x02 ~> ADD ~> REST => 0x03 ~> REST ... </k>
<stack> STACK => 0x03 : STACK </stack>
这条规则精确描述了 ADD 把栈顶两元素相加并压回。KEVM 的完整性意味着任何 EVM 字节码都可以在 KEVM 中被符号执行,而无需源码。
3.3 与 Certora 的分工
| 维度 | Certora | KEVM |
|---|---|---|
| 输入 | Solidity + CVL | EVM 字节码 + K 规格 |
| 需要源码 | 是 | 否 |
| 表达力 | 中(SMT 可判定) | 高(任意重写性质) |
| 自动化 | 高 | 中(需引导) |
| 典型用途 | 合约业务逻辑 | 字节码级等价性、编译器验证 |
KEVM 的经典应用是验证编译器正确性:证明 Solidity 源码编译出的字节码与源码语义等价。这类工作强度极高,通常只用于基础设施级项目。
3.4 其他值得知道的工具
| 工具 | 路线 | 特点 |
|---|---|---|
| Slither | 静态分析 | 快,模式匹配常见漏洞 |
| Mythril | 符号执行 | 无需源码,易用 |
| Halmos | 符号执行 | Foundry 原生,测试即规格 |
| Manticore | 符号执行 | 支持多链 |
| Echidna | 属性模糊测试 | 用不变量做 fuzz 目标 |
其中 Halmos 值得关注:它把 Foundry 测试函数当作符号执行的断言,无需另写规格语言,迁移成本最低:
halmos --function check_transferPreservesTotal -v
4. 规格怎么写:从测试到不变量的跃迁
4.1 好规格的三个特征
- 可证伪:规格必须能被违反,否则它什么都没说;
- 完备:关键路径都有对应断言,不留空白;
- 独立:规格描述"应该怎样",不复述"代码怎样写"(否则是循环论证)。
反例规格:assert transfer(a,b,x) == transfer(a,b,x) —— 恒真,无意义。
4.2 用不变量驱动开发
推荐流程是先写不变量,再写实现:
// 不变量:所有账户余额之和 == totalSupply
function invariant_totalSupply() public view {
assertEq(token.totalSupply(), sumOfAllBalances());
}
配合 Foundry 的 invariant_ 前缀,fuzz 会尝试用任意调用序列打破它;一旦打破就得到一条可复现的调用链。这把"测试"从"验证已知用例"变成"搜索未知反例",是形式化思维在轻量场景下的最佳实践。更系统的审计流程可参考智能合约安全审计与常见漏洞
。
4.3 与测试框架配合
形式化验证不取代测试,而是分工:
| 层次 | 工具 | 覆盖 |
|---|---|---|
| 单元测试 | Foundry test_ | 具体用例、边界值 |
| 模糊测试 | Foundry invariant_ / Echidna | 随机调用序列 |
| 符号执行 | Halmos / Mythril | 路径覆盖 |
| 形式化证明 | Certora / KEVM | 全输入空间 |
实践中先跑单元测试与 fuzz 快速发现低级错误,再对核心模块上形式化验证。测试与 mock 的具体写法见Foundry 测试与模拟 。
5. CI 集成与工程落地
5.1 把验证挂进流水线
# .github/workflows/verify.yml
name: formal-verification
on: [pull_request]
jobs:
certora:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Certora CLI
run: pip install certora-cli
- name: Run verification
env:
CERTORAKEY: ${{ secrets.CERTORAKEY }}
run: certoraRun certora/conf/transfer.conf
原则是:规格文件随代码一起评审,任何改动合约的 PR 都必须让验证重新跑通。
5.2 处理超时与不确定性
SMT 求解是 NP 难问题,实践中经常遇到:
| 现象 | 原因 | 对策 |
|---|---|---|
| 求解超时 | 状态空间过大 | 拆分规则、加 require 缩小范围 |
| 结果不稳定 | 求解器启发式 | 固定求解器版本与超时参数 |
| 内存爆掉 | 循环展开过深 | 限制循环上界、抽象化外部调用 |
常用手段是给循环加上界(require i < 100),把无限状态空间截断为有限。这牺牲了完备性,但换来了可判定的结果——工程上往往是值得的。
5.3 团队协作的现实建议
- 从 3~5 条核心不变量起步,不要一上来追求全量验证;
- 规格与审计报告一起交付,让外部审计方复核规格;
- 把反例变成回归测试,每条被修掉的反例都写成一个单测;
- 明确验证边界:在报告里写清"验证了什么、没验证什么"。
6. 局限:形式化验证证明不了什么
必须清醒认识三点:
- 规格错误:若规格本身写错(例如漏掉了一个隐含前提),证明成立但合约仍不安全;
- 环境假设:链上环境(gas、预言机、重入、跨合约调用)难以完全建模,验证通常只覆盖合约内部逻辑;
- 经济攻击:MEV、闪电贷、治理攻击属于激励层问题,形式化验证无法触及。
因此形式化验证是安全体系的一环而非全部。它最擅长的是证明"内部状态机在给定前提下不出错",而对"前提本身是否合理"、“对手是否会操纵前提"无能为力。这也解释了为什么顶级项目同时投入形式化验证(工程实践的完整案例可参考 区块链形式化验证 )、安全审计、模糊测试与漏洞赏金。
小结
智能合约形式化验证的三条路线各有清晰定位:Certora 用 CVL + SMT 做高自动化的业务逻辑证明,适合绝大多数 DeFi 协议;K 框架 / KEVM 用可执行语义做字节码级推理,适合编译器与基础设施验证;符号执行工具(Halmos、Mythril)以最低门槛提供路径覆盖,适合快速上手。
落地的关键不在工具选择,而在规格质量:先把"什么永远不能变"写成可证伪的不变量,再让工具去证。把 3~5 条核心不变量挂进 CI,配合审计与模糊测试,就能把安全水位显著抬高。若对底层抽象解释与静态分析原理感兴趣,可延伸阅读 编译器抽象解释与验证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。