Forge 测试进阶

本文介绍了如何使用 Foundry 框架来提升智能合约的测试效果,包括通过 Fuzzing 增加测试覆盖率,以及将现有的测试合约复用于不变性测试。文章提供了一些实用技巧,例如如何通过修改器区分有状态和无状态测试,以及如何限制 Fuzzing 的作用域,从而提高测试的效率和准确性。

一些使用 Foundry 改进智能合约测试的技巧和窍门。

Forge 测试进阶

测试是智能合约安全领域一个强大的工具。它通常在智能合约开发套件中完成,比如 Forge (属于 Foundry 套件)。在本文中,我们讨论如何增强我们的 Forge 测试,并着眼于两个目标。第一,通过模糊测试实现更大的覆盖率,第二,复用我们的测试代码进行不变量测试。

我们假设读者已经熟悉使用 Forge 框架进行智能合约测试和模糊测试,无论是用于去中心化应用开发还是作为安全评估的一部分。

第一部分 — 通过模糊测试增加覆盖率

提高测试覆盖率通常需要大量的努力。一个原因是目标函数需要复杂的设置才能到达代码中所有可能的路径(包括成功和失败路径)。通常,测试人员会通过复制粘贴相同的代码并修改必要的部分,为每个路径编写一个测试函数。虽然这种方法看起来很有效,但它有一个缺点:如果复制的代码包含错误,修复它们需要检查代码的所有副本。

缓解这个问题的一种方法是通过模糊测试。考虑以下玩具 Vault 合约作为测试目标。

contract Vault {
    ...
    function deposit() public payable {
        ...
        if (msg.value == 0) {
            revert;
        } else {
            emit DepositReceived();
        }
        ...
    }
    ...
}

为了测试 Vault.deposit(),我们可以模糊化 msg.value 并根据随机输入评估每个分支。以下是一个测试函数如何覆盖成功路径和失败路径,然后在成功调用时评估值的示例。

    event DepositReceived();

    function test_deposit(uint256 msg_value) public {
        deal(address(this), msg_value);

        uint256 vaultBalanceBefore = vault.balance;

        if (msg_value == 0) {
            vm.expectRevert();
        } else {
            vm.expectEmit();
            emit DepositReceived();
        }

        vault.deposit{msg.value: msg_value}();

        // 验证
        if (msg_value > 0) {
            assertEq(vault.balance, vaultBalanceBefore + msg_value);
        }

    }

也很容易看到,通过这种结构,我们仍然可以通过覆盖 msg_value 的值来手动检查每个路径。

    function test_deposit(uint256 msg_value) public {
        msg_value = 0; // 用于手动检查 msg.value == 0 的附加行
        deal(address(this), msg_value);
        ...

现在,我们有一个简化的测试函数,能够评估多个路径。但是,如果单个测试包含的路径太多,代码的复杂性可能会显著增加。测试人员需要注意这一点,并努力在简单性和复杂性之间取得最佳平衡。管理大型函数的一个策略是将其划分为不同的部分:

  • 验证或检查以及辅助元素(例如,格式化程序);
  • 主要逻辑;
  • 调用内部函数。

可以独立测试每个部分。出于测试目的,可以通过辅助合约访问内部函数。

第 2 部分 — 将测试合约作为不变量测试的处理器

在第二部分中,我们以 WETH9 为例。WETH9(Wrapped Ether)是一个简单但流行的合约,用于将原生资产 Ether 包装成符合 ERC20 标准的 token。

不变量测试 是一种强大的方法,通过运行不同的路径或函数调用序列来严格检查代码中的反例。不变量测试主要由两部分组成:不变量语句和处理器合约。

不变量语句定义了系统不应违反的条件。例如,在 WETH9 中,我们应该确保 totalSupply() 始终等于合约持有的 Ether 余额。有了这个,我们可以构造一个不变量语句如下。

    ...
    WETH9 public weth;
    ...
    function invariant_eth_equality() public {
            assertEq(address(weth).balance, weth.totalSupply());
    }

不变量语句在测试期间执行的每个调用路径上进行评估。编写不变量语句并非易事,需要深入了解协议的机制。在本文中,我们不会讨论不变量语句的选择和构造;相反,我们将侧重于第二部分:处理器合约。

处理器合约限制了测试执行器选择的路径。它通过为函数提供成功执行所需的必要环境来包装目标函数。例如,考虑以下来自 WETH9 合约的 deposit() 函数。

    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

如果 msg.value 不为零,deposit() 函数在测试用例中才有意义,因为只有这样存储才会发生变化。因此,我们将在处理器合约中包含以下函数。

    function deposit(uint256 depositAmount) public payable {
        deal(address(this), depositAmount);
        weth.deposit{value: depositAmount}();
    }

如果我们已经有一个用于 WETH9 的测试合约(我们称之为 WETH9Test),我们可以将其用作针对 WETH9 的不变量测试的处理器。在这种情况下,我们不需要编写另一个处理器合约。但是,一些调整是必要的。

Forge 中的普通测试是无状态的,这意味着状态会在每次函数调用时重置。这也适用于标准模糊测试,其中状态会随着每次输入变化而重置。另一方面,不变量测试是有状态的。只有在执行和评估调用序列和不变量语句时,状态才会重置。因此,我们需要一种方法来区分无状态测试和有状态测试,以及其他调整。

计数器作为标志

由于有状态测试在整个测试过程中保留状态数据,我们可以使用基于存储的计数器来区分有状态测试和无状态测试。有很多方法可以实现这一点,但让我们修改 Remix 中著名的 Counter 合约。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Counter {
    uint256 public number;

    modifier functionCalled() {
        increment();
        _;
    }

    constructor() {
        number = 0;
    }

    function increment() public {
        number++;
    }

    function isStateless() public view returns (bool) {
        if (number == 1) {
            return true;
        } else {
            return false;
        }
    }
}

我们将 Counter 合约用作测试合约的父合约。这使我们可以访问修饰符 functionCalled() 和视图函数 isStateless()

contract WETH9Test is Counter {
...
}

很容易理解,修饰符 functionCalled() 会递增变量 number。递增操作也可以通过函数 increment() 完成。但是,修饰符允许对我们现有的测试代码结构进行最小的更改。修饰符附加到测试函数,以便我们稍后可以通过调用 Counter.isStateless() 函数来识别测试是处于无状态模式还是有状态模式。

    function test_deposit(uint256 depositAmount) public functionCalled {
        if (isStateless()) {
            deal(address(this), depositAmount);
        }
        weth.deposit{value: address(this).balance}();
    }

在上面的代码片段中,命令 deal 仅在无状态测试期间执行,而不是在有状态测试期间执行。这意味着在有状态测试中,存款金额取决于当时合约的余额。

值得注意的是,不变量测试中的任何运行都可能执行 isStateless() 块。当 test_deposit() 是会话中第一个甚至唯一调用的函数时,就会发生这种情况。但是,这对不变量测试的结果没有影响,因为 test_deposit() 无论如何都会通过。

编写可重用测试函数时要考虑的另一个有趣的行为是,即使某些调用恢复(其中 fail_on_revert = false,这是一个可以在 forge.toml 中重写的设置),默认情况下,不变量测试(有状态测试)也会继续执行。另一方面,我们不希望在标准测试(无状态测试)中因琐碎的事情而恢复。考虑到这一点,无论 user 的余额如何,以下代码段都适用于无状态测试和有状态测试。

    function test_deposit(address user) public functionCalled {
        if (isStateless()) {
            deal(user, 1 ether);
        }
        vm.startPrank(user);
        weth.deposit{value: 1 ether}();
        vm.stopPrank();
    }

限制变化

模糊测试活动(包括无状态和有状态测试方法)的成功通常取决于搜索范围的精度。例如,在涉及函数 test_deposit(address) 的攻击场景中,我们不希望将任何随机地址作为 user 用于调用中。相反,我们可以专注于一些地址,其中一个地址可能表现得像攻击者。

以下代码片段提供了类似于 Forge 中 bound 的示例辅助函数,但用于地址。它限制了模糊化 address user 的选项,使其仅返回 ALICEBOB

contract WETH9Test is Counter {

    address public ALICE = makeAddr("alice");
    address public BOB = makeAddr("bob");

    address[] users = [ALICE, BOB];

    function _helper_address_to_uint256(address addr) internal view returns (uint256) {
        uint256 i = uint256(uint160(addr));
        return i;
    }

    function _helper_index_to_user(uint256 index) internal view returns (address) {
        index = bound(index, 0, users.length - 1);
        return users[index];
    }

    function helper_address_to_user(address addr) public view returns (address) {
        // 检查地址是否已经是用户
        for (uint256 i = 0; i < users.length; i++) {
            if (users[i] == addr) {
                return addr;
            }
        }
        uint256 idx = _helper_address_to_uint256(addr);
        return _helper_index_to_user(idx);
    }
}

然后我们可以简单地在下面的修改后的 test_deposit() 中使用它们。

    function test_deposit(address user) public functionCalled {
        if (isStateless()) {
            deal(user, 1 ether);
        } else {
            user = helper_address_to_user(user);
        }
        vm.startPrank(user);
        weth.deposit{value: 1 ether}();
        vm.stopPrank();
    }

设置目标

现在我们有了所有的要素,我们需要将测试函数设置为目标。以下代码段假定有两个测试函数:test_deposit()test_withdraw()

与标准模糊测试类似,当新的调用序列开始时,会调用 function setup()

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {WETH9Test as Handler} from "./WETH9Test.t.sol";

contract WETH9TestInvariant is Test, Counter {

    Handler public handler;

    function setUp() public {
        handler = new Handler();
        handler.setUp();
        handler.overrideStateless();

        // 设置选择器
        bytes4[] memory selectors = new bytes4[](2);
        selectors[0] = handler.test_deposit.selector;
        selectors[1] = handler.test_withdraw.selector;

        targetSelector(FuzzSelector({
            addr: address(handler),
            selectors: selectors
        }));

        // 设置 targetContract
        targetContract(address(handler));
    }
}

上面的代码段中有几件事值得注意。首先,我们调用 handler.setUp(),这是我们的不变量测试合约上的 WETH9Test.setUp(),因为它不会在有状态测试期间自动调用。其次,调用了 handler.overrideStateless()。这通过将 Counter.number() 的值设置为 1 来准备我们的有状态/无状态标志。接下来对修饰符 functionCalled() 的调用将 Counter.number() 的值设置为大于 1,表示有状态测试(Counter.isStateless() == false)。

我们的 handler 合约中的函数 overrideStateless() 只是:

    function overrideStateless() public functionCalled {}

结论

通过模糊测试和不变量测试增强 Forge 测试为智能合约安全提供了一个强大的框架。模糊测试通过生成不同的输入场景来帮助增加测试覆盖率,而不变量测试通过严格的检查来确保系统的完整性。通过结合这些方法和利用可重用的测试合约,开发人员可以创建更全面和高效的测试程序。这种方法不仅可以识别潜在的漏洞,还可以确保智能合约在各种条件下按预期运行,最终有助于构建更安全和可靠的区块链应用程序。

  • 原文链接: blog.sigmaprime.io/forge...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
sigmaprime
sigmaprime
江湖只有他的大名,没有他的介绍。