.. _formal_verification: ################################## SMT检查器和形式化验证 ################################## 使用形式化验证,有可能进行自动数学证明, 证明您的源代码符合某种形式化规范。 该规范仍然是正式的(就像源代码一样),但通常要简单得多。 请注意,形式化验证本身只能帮助您理解您所做的(规范)和您如何做的(实际实现)之间的区别。 您仍然需要检查规范是否是您想要的,以及您没有遗漏任何意想不到的效果。 Solidity 实现了基于 `SMT(可满足性模型理论(Satisfiability Modulo Theories) `_ 和 `Horn `_ 解决的形式验证方法。 SMT检查器模块自动尝试证明代码满足由 ``require`` 和 ``assert`` 语句给出的规范。 也就是说,它把 ``require`` 语句视为假设,并试图证明 ``assert`` 语句中的条件总是真的。 如果发现断言失败,则可以向用户提供一个反例,说明断言是如何被违反的。 如果 SMT 检查器对某一属性没有给出警告,这意味着该属性是安全的。 SMT 检查器在编译时检查的其他验证目标有: - 算术上的下溢和溢出。 - 除以0的除法。 - 无用的条件和无法访问的代码。 - 弹出一个空数组。 - 超出界限的索引访问。 - 转账资金不足。 如果所有检查引擎都被启用,上述所有目标都被默认为自动检查, 除了 Solidity >=0.8.7 的下溢和溢出。 SMT 检查器所报告的潜在警告是: - ``<失败的属性> 发生在这里``。这意味着 SMT 检查器证明了某一属性失败。可能会给出一个反例,但是在复杂的情况下,也可能不会显示反例。在某些情况下,当 SMT 编码为 Solidity 代码添加了难以表达或无法表达的抽象时,这个结果也可能是一个假阳性。 - ``<失败的属性> 可能发生在这里``。这意味着求解器无法在给定的超时时间内证明两种情况。由于结果是未知的,SMT 检查器会报告潜在的健全性失败。这可以通过增加查询超时时间来解决,但问题也可能只是对引擎来说太难解决。 要启用SMT检查器,您必须选择 :ref:`应该运行哪一个引擎 `, 其中默认的是没有引擎。选择引擎可以在所有文件上启用SMT检查器。 .. note:: 在 Solidity 0.8.4 之前,启用SMT检查器的默认方式是通过 ``pragma experimental SMTChecker;`` 并且只有包含 pragma 的合约才会被分析。该 pragma 已被弃用, 尽管它仍能使SMT检查器向后兼容,但它将在 Solidity 0.9.0 中被移除。 还要注意的是,现在即使在一个文件中使用 pragma,也会对所有文件启用SMT检查器。 .. note:: 假设SMT检查器和底层求解器中没有错误, 那么验证目标没有警告就代表了一个无可争议的正确性数学证明。 请记住,这些问题在一般情况下是 *很难* 的,有时是 *不可能* 自动解决的。 因此,有几个属性可能无法解决,或者可能导致大型合约的假阳性。 每一个被证明的属性都应该被看作是一个重要的成就。 对于高级用户,请参阅 :ref:`SMT检查器 调优 ` 来了解一些可能有助于证明更复杂属性的选项。 ******** 教程 ******** 溢出 ======== .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; contract Overflow { uint immutable x; uint immutable y; function add(uint x_, uint y_) internal pure returns (uint) { return x_ + y_; } constructor(uint x_, uint y_) { (x, y) = (x_, y_); } function stateAdd() public view returns (uint) { return add(x, y); } } 上面的合约显示了一个溢出检查的例子。 对于 Solidity >=0.8.7,SMT检查器默认不检查下溢和溢出, 所以我们需要使用命令行选项 ``--model-checker-targets "underflow,overflow"`` 或者JSON选项 ``settings.modelChecker.targets = ["underflow", "overflow"]``。 参见 :ref:`本节的目标配置 `。此处,它报告如下: .. code-block:: text Warning: CHC: Overflow (resulting value larger than 2**256 - 1) happens here. Counterexample: x = 1, y = 115792089237316195423570985008687907853269984665640564039457584007913129639935 = 0 Transaction trace: Overflow.constructor(1, 115792089237316195423570985008687907853269984665640564039457584007913129639935) State: x = 1, y = 115792089237316195423570985008687907853269984665640564039457584007913129639935 Overflow.stateAdd() Overflow.add(1, 115792089237316195423570985008687907853269984665640564039457584007913129639935) -- internal call --> o.sol:9:20: | 9 | return x_ + y_; | ^^^^^^^ 如果我们添加了过滤掉溢出情况的 ``require`` 语句, SMT检查器就会证明没有溢出是可以达到的(会通过不报告警告表现出来)。 .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; contract Overflow { uint immutable x; uint immutable y; function add(uint x_, uint y_) internal pure returns (uint) { return x_ + y_; } constructor(uint x_, uint y_) { (x, y) = (x_, y_); } function stateAdd() public view returns (uint) { require(x < type(uint128).max); require(y < type(uint128).max); return add(x, y); } } 断言 ====== 断言表示代码中的一个不变量: *对于所有的事务,包括所有的输入和存储值*, 一个属性必须为真,否则就会出现错误。 下面的代码定义了一个保证没有溢出的函数 ``f``。 函数 ``inv`` 定义了 ``f`` 是单调递增的规范: 对于每个可能的数值对 ``(a, b)``,如果 ``b > a``,那么 ``f(b) > f(a)``。 由于 ``f`` 确实是单调增长的,SMT检查器证明了我们的属性是正确的。 我们鼓励您试试这个属性和函数定义,看看会有什么样的结果! .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; contract Monotonic { function f(uint x) internal pure returns (uint) { require(x < type(uint128).max); return x * 42; } function inv(uint a, uint b) public pure { require(b > a); assert(f(b) > f(a)); } } 我们还可以在循环中添加断言,以验证更多的复杂的属性。 下面的代码搜索一个不受限制的数字数组的最大元素, 并断言找到的元素必须大于或等于数组中的每个元素的属性。 .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; contract Max { function max(uint[] memory a) public pure returns (uint) { uint m = 0; for (uint i = 0; i < a.length; ++i) if (a[i] > m) m = a[i]; for (uint i = 0; i < a.length; ++i) assert(m >= a[i]); return m; } } 注意,在这个例子中,SMT检查器将自动尝试证明三个属性: 1. 第一个循环中的 ``++i`` 不会溢出。 2. 第二个循环中的 ``++i`` 不会溢出。 3. 该断言始终是正确的。 .. note:: 这些属性涉及到循环,这使得它比前面的例子 *更加* 难了,所以要当心循环的问题! 所有的属性都被正确证明是安全的。 可以随意改变属性和/或在数组上添加限制,以看到不同的结果。例如,将代码改为 .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; contract Max { function max(uint[] memory a) public pure returns (uint) { require(a.length >= 5); uint m = 0; for (uint i = 0; i < a.length; ++i) if (a[i] > m) m = a[i]; for (uint i = 0; i < a.length; ++i) assert(m > a[i]); return m; } } 我们得到的结果: .. code-block:: text Warning: CHC: Assertion violation happens here. Counterexample: a = [0, 0, 0, 0, 0] = 0 Transaction trace: Test.constructor() Test.max([0, 0, 0, 0, 0]) --> max.sol:14:4: | 14 | assert(m > a[i]); 状态属性 ================ 到目前为止,这些例子只展示了SMT检查器在纯代码上的使用, 证明了关于特定操作或算法的属性。 智能合约中常见的属性类型是涉及合约状态的属性。 对于这样的属性,可能需要多个交易来使断言失效。 举一个例子,考虑一个二维网格,其中两个轴的坐标都在(-2^128, 2^128 - 1)范围内。 让我们在位置(0,0)放置一个机器人。该机器人只能在对角线上移动,一次只能走一步, 不能在网格外移动。机器人的状态机可以用下面的智能合约来表示。 .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; contract Robot { int x = 0; int y = 0; modifier wall { require(x > type(int128).min && x < type(int128).max); require(y > type(int128).min && y < type(int128).max); _; } function moveLeftUp() wall public { --x; ++y; } function moveLeftDown() wall public { --x; --y; } function moveRightUp() wall public { ++x; ++y; } function moveRightDown() wall public { ++x; --y; } function inv() public view { assert((x + y) % 2 == 0); } } 函数 ``inv`` 代表状态机的一个不变量,即 ``x + y`` 必须是偶数。 SMT检查器设法证明,无论我们给机器人多少条命令, 即使是无限多的命令,这个不变量都 *不会* 失败。 有兴趣的读者可能也想手动证明这个事实。 提示:这个不变量是归纳性的。 我们也可以欺骗SMT检查器,让它给我们提供一条通往某个我们认为可能是可访问的位置的路径。 我们可以通过添加以下函数,来增加(2, 4)是 *不* 可访问的属性。 .. code-block:: solidity function reach_2_4() public view { assert(!(x == 2 && y == 4)); } 这个属性是假的,在证明这个属性是假的同时, SMT检查器准确地告诉我们 *如何* 访问到(2, 4)。 .. code-block:: text Warning: CHC: Assertion violation happens here. Counterexample: x = 2, y = 4 Transaction trace: Robot.constructor() State: x = 0, y = 0 Robot.moveLeftUp() State: x = (- 1), y = 1 Robot.moveRightUp() State: x = 0, y = 2 Robot.moveRightUp() State: x = 1, y = 3 Robot.moveRightUp() State: x = 2, y = 4 Robot.reach_2_4() --> r.sol:35:4: | 35 | assert(!(x == 2 && y == 4)); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 请注意,上面的路径不一定是确定的, 因为还有其他路径可以访问(2,4)。 选择哪条路径可能会根据所使用的解算器,其使用版本,或者只是随机地改变。 外部调用和重入 ============================= 每个外部调用都被SMT检查器视为对未知代码的调用。 这背后的原因是,即使被调用合约的代码在编译时是可用的, 也不能保证部署的合约确实与编译时接口所在的合约相同。 在某些情况下,有可能在状态变量上自动推断出属性, 即使外部调用的代码可以做任何事情,包括重新进入调用者合约, 这些属性仍然是真的。 .. code-block:: solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0; interface Unknown { function run() external; } contract Mutex { uint x; bool lock; Unknown immutable unknown; constructor(Unknown u) { require(address(u) != address(0)); unknown = u; } modifier mutex { require(!lock); lock = true; _; lock = false; } function set(uint x_) mutex public { x = x_; } function run() mutex public { uint xPre = x; unknown.run(); assert(xPre == x); } } 上面的例子显示了一个使用互斥标志来禁止重入的合约。 解算器能够推断出,当 ``unknown.run()`` 被调用时,合约已经被 “锁定”, 所以无论未知的调用代码做什么,都不可能改变 ``x`` 的值。 如果我们“忘记”在函数 ``set`` 上使用 ``mutex`` 修饰符, SMT检查器就能合成外部调用代码的行为,从而使断言失败: .. code-block:: text Warning: CHC: Assertion violation happens here. Counterexample: x = 1, lock = true, unknown = 1 Transaction trace: Mutex.constructor(1) State: x = 0, lock = false, unknown = 1 Mutex.run() unknown.run() -- untrusted external call, synthesized as: Mutex.set(1) -- reentrant call --> m.sol:32:3: | 32 | assert(xPre == x); | ^^^^^^^^^^^^^^^^^ .. _smtchecker_options: ***************************** SMT检查器选项和调试 ***************************** 超时 ======= SMT检查器使用了一个硬编码的资源限制( ``rlimit`` ), 这个限制是根据每个求解器选择的,与时间没有确切的关系。 我们选择 ``rlimit`` 选项作为默认值,因为它比求解器内部的时间提供了更多的确定性保证。 这个选项大致转化为每个查询 “几秒钟超时”。 当然,许多属性非常复杂,需要大量的时间来解决,而决定并不重要。 如果SMT检查器不能用默认的 ``rlimit`` 选项处理合约属性, 则可以通过命令行界面(CLI)选项 ``--model-checker-timeout