智能合约形式化验证:Certora 与 K 框架

从霍尔逻辑与不变量出发,讲解智能合约形式化验证的三条技术路线:Certora Prover 的 CVL 规格语言与 SMT 求解、K 框架的可执行语义与 KEVM、以及符号执行与抽象解释工具,并给出规格编写、参数化规则、反例分析与 CI 集成的落地方法。

导语:当测试覆盖不到"所有输入"

单元测试和模糊测试都是抽样验证:跑过的输入通过了,没跑过的仍然是未知。对于一个管理十亿美元资产的合约,“我测了 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_lastAMM 的 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 的分工

维度CertoraKEVM
输入Solidity + CVLEVM 字节码 + K 规格
需要源码是否
表达力中(SMT 可判定)高(任意重写性质)
自动化高中(需引导)
典型用途合约业务逻辑字节码级等价性、编译器验证

KEVM 的经典应用是验证编译器正确性:证明 Solidity 源码编译出的字节码与源码语义等价。这类工作强度极高,通常只用于基础设施级项目。

3.4 其他值得知道的工具

工具路线特点
Slither静态分析快,模式匹配常见漏洞
Mythril符号执行无需源码,易用
Halmos符号执行Foundry 原生,测试即规格
Manticore符号执行支持多链
Echidna属性模糊测试用不变量做 fuzz 目标

其中 Halmos 值得关注:它把 Foundry 测试函数当作符号执行的断言,无需另写规格语言,迁移成本最低:

halmos --function check_transferPreservesTotal -v

4. 规格怎么写:从测试到不变量的跃迁

4.1 好规格的三个特征

  1. 可证伪:规格必须能被违反,否则它什么都没说;
  2. 完备:关键路径都有对应断言,不留空白;
  3. 独立:规格描述"应该怎样",不复述"代码怎样写"(否则是循环论证)。

反例规格: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. 局限:形式化验证证明不了什么

必须清醒认识三点:

  1. 规格错误:若规格本身写错(例如漏掉了一个隐含前提),证明成立但合约仍不安全;
  2. 环境假设:链上环境(gas、预言机、重入、跨合约调用)难以完全建模,验证通常只覆盖合约内部逻辑;
  3. 经济攻击:MEV、闪电贷、治理攻击属于激励层问题,形式化验证无法触及。

因此形式化验证是安全体系的一环而非全部。它最擅长的是证明"内部状态机在给定前提下不出错",而对"前提本身是否合理"、“对手是否会操纵前提"无能为力。这也解释了为什么顶级项目同时投入形式化验证(工程实践的完整案例可参考 区块链形式化验证 )、安全审计、模糊测试与漏洞赏金。

小结

智能合约形式化验证的三条路线各有清晰定位:Certora 用 CVL + SMT 做高自动化的业务逻辑证明,适合绝大多数 DeFi 协议;K 框架 / KEVM 用可执行语义做字节码级推理,适合编译器与基础设施验证;符号执行工具(Halmos、Mythril)以最低门槛提供路径覆盖,适合快速上手。

落地的关键不在工具选择,而在规格质量:先把"什么永远不能变"写成可证伪的不变量,再让工具去证。把 3~5 条核心不变量挂进 CI,配合审计与模糊测试,就能把安全水位显著抬高。若对底层抽象解释与静态分析原理感兴趣,可延伸阅读 编译器抽象解释与验证 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「blockchain」更多文章

  1. DePIN 去中心化物理基础设施网络
  2. DeFi 衍生品:期权、永续合约与合成资产
  3. 链上数据分析:Dune、Flipside 与数据仓库