最近,我参与了Yield Protocol的发布。对于那些还没有听说过的人,它是以太坊中的固定利率借贷平台。它还实现了智能合约所可能具有的优势的数学公式。
Yield协议也是一个部署即忘平台。一旦上市,用户立即可以与我们的软件交互,如果智能合约中存有任何bug,我们也无法修复。但是令人惊讶的是,我们核心的智能合约代码存有的bug数量非常少。
其中一个原因是我们使用模糊测试器来查找使用单元测试或集成测试无法找到的极端情况。
什么是模糊器?
通过模糊化,大量测试随机场景,以找出可能会出现的意外结果的值。
例如在Yield上,我们计算分数指数,该指数是迭代计算的,并且在以太坊上运行非常昂贵,因为以太坊每次操作都要花钱。
为了给用户省钱,在Yield上,我们破解了指数计算算法,以在一段时间后停止计算小数。不利之处在于,我们的智能合约现在将以偏离数学理想值随机数量的价格进行交易。
但是偏离了多远?我们可以设置价格,使其与理想价格相差1美元吗?
唯一知道的方法是用大量随机场景测试智能合约。Trail of Bits告诉我们在代码审核期间使用模糊测试器,这就是我们如何做到的。
如何使用模糊起?
首先您将必须安装echidna。在网站上的所有选项中,我设法下载了Ubuntu的预编译二进制文件。为此,您还需要安装cryptic-compile并进行更平滑的安装。我记得我花了一些时间才把它弄对,这是最近很少见的事情,所有事情都以npm软件包的形式出现。
echidna github有很多文档和一些教程,我无法完全理解。拯救我的是古斯塔沃·格里科(gustavogrieco)的一个运行示例,此后我对其进行了修改和重用。
简而言之,您需要一个config.yaml文件,这是我的:
seqLen: 50
testLimit: 20000
prefix: “crytic_”
deployer: “0x41414141”
sender: [“0x42424242”, “0x43434343”]
cryticArgs: [“ — compile-force-framework”, “Buidler”]
coverage: true
checkAsserts: true
然后用你想要测试的不变量来编写一个solidity合约,此合约将测试DecimalMath.sol中的muld函数。
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.6.10;
import “../helpers/DecimalMath.sol”;
/// @dev Implements simple fixed point math mul and div operations for 27 decimals.
contract DecimalMathInvariant is DecimalMath {
function muld_(uint256 x, uint256 y)
public pure returns (uint256)
{
uint z = muld(x, y);
assert((x * y) / UNIT == z); // Assert math
assert (divd(z, y) <= x); // We are rounding down
// Assert revert on overflow
if(y > UNIT) assert(z >= x); // x could be zero
if(y < UNIT) assert(z <= x); // y could be zero
}
}
我们将使用echidna在此合约中调用DecimalMathInvariant.muld_函数20,000次。Echidna知道它必须运行这个函数,因为它是公共函数。该函数有两个参数(x和y),因此echidna将在每次运行时使用随机值。
当echidna运行时,它认为由于require而恢复是成功的,而由于assert而导致失败而进行还原。
在上面的合约中,我要检查定点乘法的四个属性:
1. x * y == z,替换逗号。
2. DecimalMath.muld向下舍入(通过断言z / y <= x,替换逗号)。
3. 如果y大于1,则z大于x(如果发生溢出,则不正确)。
4. 也就是说,如果y小于1,z也会小于x(如果发生下溢,那将不是真的)。
编码不变量会迫使您思考如何通过查找始终为真的属性来测试某些内容,而不是重复原始智能合约中的代码。
现在您可以测试:
$ echidna-test . — contract DecimalMathInvariant — config contracts/invariants/config.yaml
这两个参数是我们在其中编码不变式的合约的名称(不是我们正在测试的目标合约,它也不是路径)。第二个参数是 path to the .yaml文件的路径。
Echidna将花费一些时间来分析合约并进行设置,最终您将看到以下内容:
您可能会注意到那里有些奇怪的东西。我们正在模糊的这个UNIT是什么?
打开和关闭不变量函数
现在肯定会在yaml文件中提供一种方法,但是我使用函数的可见性来打开和关闭不变量函数。
Echidna将对您合约中的每个public函数进行模糊处理。那可能不是您想要的。有时您可能只想测试一个麻烦的函数,而不是合约中的10个测试,其中有9次通过。
在这种情况下,您可以只声明internal函数,而echidna将忽略它。同时声明internal的所有状态变量,否则它们将变得毫无用处。
在此文件中,我具有一个辅助函数来帮助我计算Yield白皮书中的不变量。我将在其他任何地方使用该函数,并且我不想自己对其进行测试,因为我无法确认它是否脱离上下文。
在同一个文件中,我甚至有一个不变的测试函数,它被注释掉,使其internal化。这是开发遗留下来的,应该清理一下,这真令人尴尬。
在顶部,您将看到一堆常量,它们都是internal常量。如果它们是公开的,echidna将考虑它们的功能并对其进行模糊处理,就像UNIT一样,后者是从父合约继承并碰巧是public的,所以它被模糊化了。
这些是我写的第一个模糊测试合约,这是幸运的,因为作为库或无状态的合约,它们很容易被模糊化。在我开始处理模糊的有状态合约之后不久,这让我明白我做的每件事都是大错特错的。
模糊状态合约
我很快需要对保持状态的合约进行模糊处理,而不仅仅是数学库。想了想,四处打探,我意识到echidna进行的测试不是孤立地进行的。
Echidna随机运行契约中的函数,直到出现bug。我认为.yaml文件中的seqLen参数必须定义一个序列中有多少个函数调用。
在一个序列中,状态保持不变。与其让不变的合约继承自要测试的合约,不如将其链接在一起,就像在WETH10的此示例中那样,效果更好。
contract WETH10Fuzzing {
WETH10 internal weth;
address internal holder;
/// @dev Instantiate the WETH10 contract, and a holder address
/// that will return weth when asked to.
constructor () {
weth = new WETH10();
holder = address(new MockHolder(address(weth), address(this)));
}
以前我是单独测试每个函数,但现在我保留了状态。Echidna将在WETH10中执行函数,查找失败的断言。我的不变测试函数如下所示:
/// @dev Test that supply and balance hold on deposit.
function deposit(uint ethAmount) public {
uint supply = weth.totalSupply();
uint balance = weth.balanceOf(address(this));
weth.deposit{value: ethAmount}();
assert(weth.totalSupply() == add(supply, ethAmount));
assert(weth.balanceOf(address(this)) == add(balance, ethAmount));
assert(address(weth).balance == weth.totalSupply());
}
/// @dev Test that supply and balance hold on withdraw.
function withdraw(uint ethAmount) public {
uint supply = weth.totalSupply();
uint balance = weth.balanceOf(address(this));
weth.withdraw(ethAmount);
assert(weth.totalSupply() == sub(supply, ethAmount));
assert(weth.balanceOf(address(this)) == sub(balance, ethAmount));
assert(address(weth).balance == weth.totalSupply());
}
通过这一点,我在测试,无论WETH10之前处于什么状态,供给和平衡都会随着存款和取款而发生变化。您可以使用它来构建更复杂的多合约设置。
模糊器调测
所有测试通过都很棒。除非第一次发生这种情况,否则您应该会怀疑并假设您对测试进行了错误编码。首先您需要学会接受测试失败的结果。
当测试失败时,Echidna将找到使断言失败的函数调用组合。然后,它将找出导致该故障的最短顺序,通常将故障减少到一两个调用。Echidna会告诉您执行的函数,就是这样。
您不会获得日志、特定assert失败的信息或状态变量的内容。
您将需要在truffle控制台中运行该序列,或者在一个常规的测试文件中运行该序列,这样您就可以在特定情况下使用智能合约,并开始调查assert失败的原因。
如果echidna告诉我我的“购买Dai并反转交易”不变量在某些参数下失败,则将它们粘贴到测试文件中,以作为模糊合约的附件。然后我可以转储所有相关变量,并在传统设置下开始调试。
结论
模糊测试并不容易,目前测试工具还是粗糙,同时数学公式也很难,但这是值得的。