Foundry操作手册

Foundry 是一个智能合约开发工具链,用于管理您的依赖关系、编译项目、运行测试、部署,并允许您通过命令行和 Solidity 脚本与链交互。

Foundry分为四个板块:

  • Forge 是 Foundry 附带的命令行工具。 Forge 可用来测试、构建和部署您的智能合约。
  • Cast 是 Foundry 用于执行以太坊 RPC 调用的命令行工具。 可以进行智能合约调用、发送交易或检索任何类型的链数据。
  • Anvil 是 Foundry 附带的本地测试网节点。 可以使用它从前端测试合约或通过 RPC 进行交互。
  • Chisel 是随 Foundry 提供的高级 Solidity REPL。可以在命令行快速的有效的实时的写合约,测试合约。可用于在本地或分叉网络上快速测试 Solidity 片段。

Foundry命令相关文档:https://learnblockchain.cn/docs/foundry/i18n/en/reference/index.html

中文文档:https://learnblockchain.cn/docs/foundry/i18n/zh

安装地址:https://getfoundry.sh

Foundry套件

Forge

创建合约

安装好Foundry之后,创建一个 foundry-simple-storage 文件夹

先用forge init 初始化项目,然后将 SimpleStorage.sol 代码复制到 src 目录下,执行forge compile编译脚本。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract SimpleStorage {
    uint256 myFavoriteNumber; // 0

    function store(uint256 _favoriteNumber) public virtual  {
        myFavoriteNumber = _favoriteNumber;
    }

    function retrieve() public view returns(uint256){
        return myFavoriteNumber;
    }

    struct Person {
        uint256 favoriteNumber;
        string name;
    }

    Person[] public listOfPeople;

    mapping(string => uint256) public nameToFacoriteNumber;

    function addPerson(string memory _name, uint256 _favoriteNumber) public {
        listOfPeople.push( Person(_favoriteNumber, _name) );
        nameToFacoriteNumber[_name] = _favoriteNumber;
    }
}

部署合约

合约的部署可以分为脚本部署和直接部署,我们先来看看脚本部署,在 script 目录下编写部署脚本 DelpoySimpleStorage.s.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {Script} from "forge-std/Script.sol";
import {SimpleStorage} from "../src/SimpleStorage.sol";

contract DeploySimpleStorage is Script {
    function run() external returns (SimpleStorage) {
        // 在 env 文件中配置私钥
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployerPrivateKey);
        SimpleStorage simpleStorage = new SimpleStorage();
        vm.stopBroadcast();
        return simpleStorage;
    }
}

上述代码中的 Broadcast 是指交易上链。startBroadcast 和 stopBroadcast 之间的交易会被收集起来,以便在stopBroadcast 之后将这些交易在链上广播

参考资料:https://learnblockchain.cn/docs/foundry/i18n/zh/cheatcodes/broadcast.html

此时目录结构如下

image-20241022223150732

执行部署命令 forge script script/DelpoySimpleStorage.s.sol

这时 SimpleStorage 合约就部署到了 anvil 的临时区块链上了

执行部署命令 forge script script/DelpoySimpleStorage.s.sol --rpc-url YourURL --broadcast --verify

你也可以创建一个 .env 文件,将所有配置都写进入

// 区块链 RPC 节点地址
SEPOLIA_RPC_URL=xxxx
// 钱包私钥
PRIVATE_KEY=xxxx
// 区块链浏览器的 API KEY TOKEN
ETHERSCAN_API_KEY=xxxx

编辑 foundry.toml 文件,将以下行添加到文件末尾,这里指定了 .env 中配置的变量。

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"

[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }

然后通过如下命令部署并验证合约

# 加载 .env 文件中的变量
source .env

# 部署并验证合约
forge script script/DelpoySimpleStorage.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast --verify -vvvv

单独验证合约

如果你在部署的时候忘了验证合约,可以通过下面的方法手动验证

当然也还是需要在 foundry.toml 文件中添加 Etherscan API 密钥:

[etherscan]
sepolia = { key = "YOUR_ETHERSCAN_API_KEY" }

也可以在命令中直接通过参数指定

--etherscan-api-key <your_etherscan_api_key> 

使用 forge verify-contract 命令手动触发验证过程

forge verify-contract <deployed-contract-address> <contract-name> --chain <chain-id> --watch

image-20241022222535241

也可以使用 Forge create 来部署并验证合约

forge create --rpc-url <your_rpc_url> --private-key <your_private_key> --verify src/SimpleStorage.sol:SimpleStorage --constructor-args <constructor_args>
  • etherscan-api-key: 即区块链浏览器的 API KEY TOKEN,用于验证合约。
  • verify: 验证合约,即在浏览器中开源合约的代码
  • :MyContract: 实际部署的合约,由于一个 solidity 中允许存在多个合约,因此这里指定需要部署的合约名称。
  • constructor-args: 合约的构造参数,如果没有,可以不设置该属性

Cast

CastFoundry 用于执行以太坊 RPC 调用的命令行工具。

可以使用 Cast 进行智能合约调用、发送交易或检索任何类型的链数据。

比如我们可以使用 cast 来获取 DAI 代币的总供应量:

cast call 0x6b175474e89094c44da98b954eedeac495271d0f "totalSupply()(uint256)" --rpc-url <your rpc url> 8603853182003814300330472690

这里的 rpc url 应该是以太坊主网的url

cast chain-id

使用 cast chain-id 命令可以快速获取当前链的 ID,这是识别和确认你正在正确的区块链上操作的一个关键步骤。例如,在部署合约或验证交易时,确保链 ID 的准确性至关重要。

cast chain-id --rpc-url <your rpc url>

还有如下常用命令

  • cast chain 获取当前链的名称

  • cast client 获取当前客户端的版本

  • cast gas-price 命令获取当前 gas 价格

  • cast block-number 命令查询最新的区块号

  • cast basefee 命令允许你获取指定区块的基础费用。基础费用是每个区块中必须支付的最低 gas 价格。它是动态的,由网络自动调整

  • cast block 命令,我们可以获取到指定区块的详细信息,如区块高度、时间戳、交易数等

  • cast age 命令用于获取指定区块的时间戳,即区块生成的具体时间

  • cast balance 命令用于获取特定以太坊账户地址或 ENS(Ethereum Name Service)名称的当前余额,单位是 wei

  • cast etherscan-source 命令允许你快速从 Etherscan 网站获取指定合约的源代码

cast send 命令还可以调用合约上的任何函数。使用方法示例:

1cast send --private-key <private_key_addr> <contract_addr> "exampleFunc(uint256)" <argument_value_of_the_function>

示例:存款函数

例如,要向合约发送一个存款请求,你可以使用以下命令:

cast send --private-key 0x123... 0xabc... "deposit(uint256)" 10

这条命令会向地址为 0xabc... 的合约发送一个调用 deposit 函数的请求,存款金额为 10 wei。

触发 Fallback 函数

如果调用合约中不存在的函数,将自动触发 Fallback 函数。这可以用于测试合约的异常处理或特定的功能。

使用方法示例:

cast send --private-key <private_key_addr> <contract_addr> "dummy()"

触发 Receive 函数

通过向合约发送以太币(Ether),可以触发合约的 Receive 函数。这对于接受捐款或处理支付非常有用。

使用方法示例:

cast send --private-key <private_key_addr> <contract_addr> --value 10gwei

示例:发送以太币

以下命令展示了如何发送 10 gwei 的以太币到合约,触发接收函数:

cast send --private-key 0x123... 0xabc... --value 10gwei

Anvil

Anvil 是 Foundry 套件的一部分,允许开发者在本地环境中运行一个轻量级的以太坊节点

要启动 Anvil,只需在命令行中输入 anvil,它将自动启动一个本地节点。启动后,你将看到一系列已生成的开发账户和私钥,以及节点侦听的地址和端口信息。

输出将包括多个开发账户、私钥以及监听的端口。

$ anvil

Available Accounts
==================

(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000.000000000000000000 ETH)
.....

Private Keys
==================

(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80........
.....


Chain ID
==================

31337

Base Fee
==================

1000000000

Gas Limit
==================

30000000

Genesis Timestamp
==================

1729608812

Listening on 127.0.0.1:8545

anvil -h,可以查看所有 Anvil 提供的配置选项,以下是一些基本的配置参数:

生成和配置开发账户数量

默认情况下,Anvil 生成10个开发账户,但你可以通过以下命令指定生成更多或更少的账户:

anvil -a  <NUM>
          Number of dev accounts to generate and configure
          [default: 10]

例如,如果你需要20个开发账户,可以使用:

anvil -a 20

设置使用的 EVM 硬分叉版本

虽然默认情况下 Anvil 使用最新的 EVM 硬分叉版本,但开发者可能需要针对特定的硬分叉版本进行测试,这可以通过以下命令完成:

anvil --hardfork <HARDFORK>

例如,如果你需要设置为 “istanbul” 硬分叉,可以使用:

anvil --hardfork istanbul

设置节点监听的端口号

默认端口为8545,但如果该端口已被占用或你需要运行多个节点实例,可以通过以下命令更改端口号:

anvil -p, --port <PORT>

例如,设置为端口8546:

anvil -p 8546

Chisel

Chisel 提供了一个交互式环境,用于编写和执行 Solidity 代码。

启动 Chisel 非常简单,只需在命令行中输入 chisel 即可。启动后,你可以直接在命令行中编写和测试 Solidity 代码。

示例

获取地址余额:

address(0).balance

编码多个参数:

abi.encode(256, bytes32(0), "Chisel!")

位运算

1 << 8  // 输出结果为 256 的 uint256

Foundry Test

Foundry 可以为以太坊智能合约提供强大的测试功能。通过使用 Forge Standard Library 中的 Test 合约,开发者可以利用一系列高级功能和 cheatcodes(作弊码)来模拟各种区块链状态和交易行为。

基本用法

首先需要导入forge-std/Test.sol库,并让测试合约继承自Test

pragma solidity 0.8.10;

import "forge-std/Test.sol";

contract MyTest is Test {
    // 测试代码
}

这样,测试合约就可以访问Test合约提供的断言方法、日志功能和 cheatcodes 等。

setUp函数

setUp是一个可选函数,会在每个测试用例运行前被调用,用于初始化测试环境:

function setUp() public {
    // 初始化代码
}

test函数

test为前缀的函数会被识别为测试用例并执行:

function test_MyFunctionality() public {
    // 断言和测试逻辑
}

testFail函数

test前缀相反,testFail用于标识一个预期失败的测试。如果该函数没有触发revert,则测试失败:

function testFail_MyFailingCase() public {
    // 预期失败的测试逻辑
}

共享设置

通过创建抽象合约并在测试合约中继承它们,可以实现设置的共享:

abstract contract SetupHelper {
    // 共享的设置代码
}

contract MyTest is Test, SetupHelper {
    function setUp() public {
        // 使用共享设置
    }
}

注意事项

●测试函数必须声明为externalpublic

●使用共享设置可以避免在每个测试合约中重复相同的初始化代码,提高代码的复用性和可维护性。

Cheatcode

作弊码允许开发者在测试中执行一系列非标准操作,比如更改区块号、修改调用者身份等。

身份切换:vm.prank

在进行权限相关的测试时,我们经常需要模拟不同用户的行为。通过vm.prank函数,我们可以暂时切换调用者身份。例如,在测试一个仅允许合约所有者调用的函数时,我们可以使用vm.prank来模拟非所有者的调用尝试。

pragma solidity 0.8.10;

import "forge-std/Test.sol";

contract OwnerUpOnlyTest is Test {
    OwnerUpOnly upOnly;

    function setUp() public {
        upOnly = new OwnerUpOnly();
    }

    function testFail_IncrementAsNotOwner() public {
        vm.prank(address(0));
        upOnly.increment();
    }
}

预期报错:vm.expectRevert

测试合约在特定条件下是否正确还原是合约安全性验证的重要部分。vm.expectRevert允许我们指定一个特定的错误类型或信息,然后执行可能触发该错误的操作。如果合约按预期报错,则测试通过。

contract OwnerUpOnlyTest is Test {
    OwnerUpOnly upOnly;

    // ...

    function test_RevertWhen_CallerIsNotOwner() public {
        vm.expectRevert(Unauthorized.selector);
        vm.prank(address(0));
        upOnly.increment();
    }
}

事件验证:vm.expectEmit

智能合约中的事件提供了一种在区块链上记录信息的方式。在测试中验证特定事件是否被正确触发及其参数是否符合预期,对于确保合约行为的正确性至关重要。vm.expectEmit允许我们预先指定期望的事件特征,并验证合约操作中是否触发了相应的事件。

contract EmitContractTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 amount);

    function test_ExpectEmit() public {
        ExpectEmit emitter = new ExpectEmit();
        vm.expectEmit(true, true, false, true);
        emit Transfer(address(this), address(1337), 1337);
        emitter.t();
    }
}

更多Cheatcode请参考:https://learnblockchain.cn/docs/foundry/i18n/en/cheatcodes/index.html

执行测试

基本测试运行

使用forge test命令,我们可以运行项目中的所有测试。Forge 会自动搜索源代码目录下所有的测试合约,并执行以test开头的函数。默认情况下,测试文件通常放置在test/目录中,并以.t.sol作为文件后缀。

运行forge test的示例输出可能如下:

$ forge test
No files changed, compilation skipped

Ran 2 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 30474, ~: 31252)
[PASS] test_Increment() (gas: 31225)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 15.15ms (14.74ms CPU time)

也可以通过传递过滤条件来运行特定的测试用例或合约:

比如只运行 test/Contract.t.sol 中的 BigTest 合约且以 testFail 开头的测试:

forge test --match-path test/Contract.t.sol --match-contract BigTest \
  --match-test "testFail*"

---match-test OR --mt: 只运行与指定正则匹配的测试函数。

-v: EVM 的日志级别。

多次传递,以提高日志级别 (例如 -v, -vv, -vvv):

  • Level2(-vv) 会打印出测试中的日志,断言,预期结果,错误原因,这些更详尽的信息。
  • Level3(-vvv) 会打印出测试失败中的失败堆栈调用。
  • Level4(-vvvv) 不仅会打印失败结果的堆栈调用,会把所有的测试中的堆栈调用,全部打印出来。
  • Level5(-vvvvv) 始终显示堆栈跟踪和设置跟踪。还显示了对象的创建,每一步的具体分析。

--fork-url URL: 通过远程端点获取状态,而不是从一个空的状态开始,会真的向这个链发起RPC调用

生成一个 Gas 报告:

forge test --gas-report

参考文档:https://learnblockchain.cn/docs/foundry/i18n/zh/reference/forge/forge-test.html

解析测试

解析测试执行轨迹

在使用 Foundry 进行智能合约测试时,了解和解析测试执行轨迹( traces )是评估合约调用和交互效果的关键。本课旨在帮助开发者理解测试执行轨迹,包括格式、颜色标识以及如何解读执行轨迹中的信息。

测试执行轨迹格式

Foundry 测试执行轨迹遵循一种层级结构,以清晰地展示合约调用链和各个调用的结果。基本格式如下:

[<Gas Usage>] <Contract>::<Function>(<Parameters>)
  ├─ [<Gas Usage>] <Contract>::<Function>(<Parameters>)
  │   └─ ← <Return Value>
  └─ ← <Return Value>
  • Gas Usage:方括号内显示的是整个函数调用过程中消耗的总Gas。
  • Contract::Function:显示调用的合约名和函数名。
  • Parameters:函数调用时传入的参数。
  • Return Value:函数调用的返回值。

每条执行轨迹可以包含多个子执行轨迹( subtraces ),每个子执行轨迹代表一个合约调用及其返回值。

颜色标识

如果你的终端支持颜色显示,Foundry 测试执行轨迹将使用不同的颜色来区分不同类型的调用:

  • 绿色:表示没有触发 revert 的调用。
  • 红色:表示触发 revert 的调用。
  • 蓝色:表示对 Cheatcodes 的调用。
  • 青色:表示已发出的日志。
  • 黄色:表示合约部署。

这些颜色标识可以帮助开发者快速识别测试过程中的关键信息。

Gas 消耗不匹配问题

有时候,你可能会发现某个调用的 Gas 消耗与其所有子调用的 Gas 总和不完全匹配。这是因为在调用之间可能发生了额外的操作,如算术运算和存储读/写操作。例如:

[24661] OwnerUpOnlyTest::testIncrementAsOwner()
  ├─ [2262] OwnerUpOnly::count()
  │   └─ ← 0
  ├─ [20398] OwnerUpOnly::increment()
  │   └─ ← ()
  ├─ [262] OwnerUpOnly::count()
  │   └─ ← 1
  └─ ← ()

在这个示例中,testIncrementAsOwner调用的总 Gas 消耗是24661,但它的子调用 Gas 总和不等于这个数值,这是由于在子调用之间执行了其他操作。

解码和未解码的执行轨迹

Foundry 会尽可能地解码签名和值。然而,在某些情况下,如果无法解码,追踪会以以下格式显示:

[<Gas Usage>] <Address>::<Calldata>
  └─ ← <Return Data>

在这种格式中,<Address> 表示调用的合约地址,<Calldata> 表示传给函数的原始数据,而**<Return Data>** 则是函数调用返回的原始数据。这种情况通常发生在调用的合约或函数签名未知或不在 Foundry 测试环境中注册时。

FFI

FFI (Foreign Function Interface) 是 Foundry 提供的一个功能,允许在测试过程中执行外部命令或脚本。这对于与外部程序交互、执行复杂计算或模拟真实环境特别有用。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "forge-std/console.sol";

contract FFITest is Test {
    // 启用 FFI
    function setUp() public {
        vm.setEnv("FOUNDRY_FFI", "true");
    }

    // 调用外部 shell 命令
    function testFFIBasic() public {
        string[] memory inputs = new string[](2);
        inputs[0] = "echo";
        inputs[1] = "Hello FFI";
        
        bytes memory result = vm.ffi(inputs);
        console.log(string(result)); // 输出: Hello FFI
    }

    // 调用 Python 脚本
    function testFFIPython() public {
        string[] memory inputs = new string[](3);
        inputs[0] = "python3";
        inputs[1] = "-c";
        inputs[2] = "print(2 ** 256)";
        
        bytes memory result = vm.ffi(inputs);
        console.log(string(result));
    }

    // 调用 Node.js 脚本
    function testFFINode() public {
        string[] memory inputs = new string[](3);
        inputs[0] = "node";
        inputs[1] = "-e";
        inputs[2] = "console.log(JSON.stringify({test: 'data'}))";
        
        bytes memory result = vm.ffi(inputs);
        console.log(string(result));
    }
}

启用和使用 FFI 的方法:

  1. 通过配置文件启用(foundry.toml):
[ffi]
enable = true
  1. 通过命令行启用:
FOUNDRY_FFI=true forge test

分叉测试

分叉测试( Fork Testing )是区块链开发中一项重要的测试方法,尤其对于基于以太坊这类可编程区块链的智能合约开发来说尤为重要。使用 Forge 工具进行分叉测试有两种不同方法:分叉模式( Forking Mode )和分叉作弊码( Forking Cheatcodes )。

分叉模式

分叉模式允许开发者通过指定的 RPC URL 在一个分叉的环境中运行所有测试。使用分叉模式时,可以通过**--fork-url**标志传递一个 RPC URL,如下所示:

forge test --fork-url <your_rpc_url>

在分叉模式下,以下值将反映在分叉时刻链的状态:

  • block_number 区块号
  • chain_id 链ID
  • gas_limit 燃料上限
  • gas_price 燃料价格
  • block_base_fee_per_gas 每单位燃料的基础费用
  • block_timestamp 区块时间戳

如果需要从特定区块开始分叉,可以使用**--fork-block-number**标志:

forge test --fork-url <your_rpc_url> --fork-block-number 1

如果同时指定了**--fork-url--fork-block-number**,那么该区块的数据将被缓存以供未来的测试运行使用。数据缓存位置在~/.foundry/cache/rpc/<chain name>/<block number>。清除缓存可以通过删除该目录或运行forge clean(这将移除所有构建产物和缓存目录)。

分叉作弊码

分叉作弊码提供了一种在 Solidity 测试代码中以编程方式进入分叉模式的方法。这种技术使得开发者能够在基于测试的基础上使用分叉模式,并在测试中处理多个分叉。

与通过 forge CLI 参数配置分叉模式不同,分叉作弊码允许我们在 Solidity 测试代码中直接创建、选择和管理多个分叉。每个分叉通过一个唯一的 uint256 标识符来识别。

测试函数的隔离性

重要的是要记住,所有测试函数都是隔离的,这意味着每个测试函数都在setUp后的状态副本中执行,并在其自己的独立 EVM 中执行。因此,在setUp期间创建的分叉在测试中可用。

创建和选择分叉的实例

以下示例展示了如何使用分叉作弊码在测试合约中创建和选择分叉。

定义分叉标识符

contract ForkTest is Test {
    uint256 mainnetFork;
    uint256 optimismFork;
}

ForkTest合约中,我们首先定义两个变量mainnetForkoptimismFork来存储主网和Optimism网络分叉的标识符。

创建分叉

function setUp() public {
    mainnetFork = vm.createFork(MAINNET_RPC_URL);
    optimismFork = vm.createFork(OPTIMISM_RPC_URL);
}

setUp函数中,使用vm.createFork方法创建两个不同的分叉,并将它们的标识符分别赋值给mainnetForkoptimismFork

选择分叉

function testCanSelectFork() public {
    vm.selectFork(mainnetFork);
    assertEq(vm.activeFork(), mainnetFork);
}

通过vm.selectFork方法,我们选择并激活一个特定的分叉,然后通过**vm.activeFork()**验证当前激活的分叉是否正确。

分叉的独立和持久性

每个分叉都是一个独立的 EVM,使用完全独立的存储。msg.sender和测试合约本身的状态在分叉间是持久的。换句话说,当分叉 A 处于活动状态时所做的所有更改仅记录在分叉 A 的存储中,并且在选择另一个分叉时不可用。

创建新合约示例

function testCreateContract() public {
    vm.selectFork(mainnetFork);
    SimpleStorageContract simple = new SimpleStorageContract();
    simple.set(100);
    assertEq(simple.value(), 100);
    vm.selectFork(optimismFork);
    // 这里尝试访问simple.value()将会失败,因为simple仅存在于mainnetFork中
}

此示例展示了在活动分叉上创建新合约的过程。当从一个分叉切换到另一个分叉时,只有那些被标记为持久的账户和合约才能跨分叉访问。

持久合约的创建

在测试智能合约时,尤其是在需要跨不同区块链分叉环境测试时,持久性账户(或合约)的概念变得非常重要。持久性账户允许我们在一个分叉环境中创建和修改状态,并在另一个分叉环境中保持这些状态不变。下面的代码示例展示了如何创建一个持久性合约并在不同的分叉环境中使用它。

contract SimpleStorageContract {
    uint256 public value;

    function set(uint256 _value) public {
        value = _value;
    }
}

// 创建并测试持久性合约
function testCreatePersistentContract() public {
    // 首先,选择一个分叉环境
    vm.selectFork(mainnetFork);
    // 在该分叉环境中部署并初始化合约
    SimpleStorageContract simple = new SimpleStorageContract();
    simple.set(100);
    // 确认合约的状态设置正确
    assertEq(simple.value(), 100);

    // 接下来,将合约标记为持久性
    vm.makePersistent(address(simple));
    // 验证合约已被正确标记为持久性
    assert(vm.isPersistent(address(simple)));

    // 然后,切换到另一个分叉环境
    vm.selectFork(optimismFork);
    // 验证即使在新的分叉环境中,合约仍被标记为持久性
    assert(vm.isPersistent(address(simple)));

    // 最后,确认持久性合约的状态在新的分叉环境中保持不变
    assertEq(simple.value(), 100);
}

通过 vm.makePersistent(address) 方法,我们将 simple 合约标记为持久性,确保其状态(在这个例子中是通过 set 方法设置的值)在不同分叉环境中保持不变。接着,通过 vm.isPersistent(address) 验证该合约是否被正确标记为持久性。当我们切换到另一个分叉环境(如 Optimism 分叉)时,通过再次调用 vm.isPersistent(address) assertEq(simple.value(), 100) 来确认合约仍然保持其持久性状态以及其存储的值。

注:绝大部分账户(或合约)都默认是持久的,除非合约实现了 selfdestruct 函数

模糊测试

Fuzz Testing(模糊测试)是一种自动化的软件测试技术,通过自动生成大量随机输入数据来测试程序。在智能合约的开发中,Fuzz Testing 被用来发现合约中潜在的漏洞和异常行为。

首先,需要安装并配置 Foundry 环境。然后,创建一个 Solidity 智能合约项目,并编写合约代码及测试文件。例如,有一个简单的Safe合约,实现了存款和取款功能。

pragma solidity 0.8.10;

contract Safe {
    receive() external payable {}

    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

单元测试

单元测试关注于测试代码中特定的功能点。通过编写测试用例,可以验证给定条件下,代码的行为是否符合预期。

import "forge-std/Test.sol";

contract SafeTest is Test {
    Safe safe;

    function setUp() public {
        safe = new Safe();
    }

    function test_Withdraw() public {
        payable(address(safe)).transfer(1 ether);
        uint256 preBalance = address(this).balance;
        safe.withdraw();
        uint256 postBalance = address(this).balance;
        assertEq(preBalance + 1 ether, postBalance);
    }
}

与单元测试不同,模糊测试不是测试特定的输入和输出,而是验证一般性的属性或行为是否为真。模糊测试通过随机生成大量的输入数据来测试代码,以确保在各种情况下代码的行为都符合预期。

在 Foundry 中,任何带有参数的测试函数都会被视为模糊测试。修改SafeTest合约中的测试函数,引入 Fuzz Testing。

function testFuzz_Withdraw(uint256 amount) public {
    payable(address(safe)).transfer(amount);
    uint256 preBalance = address(this).balance;
    safe.withdraw();
    uint256 postBalance = address(this).balance;
    assertEq(preBalance + amount, postBalance);
}

处理高值问题

在进行 Fuzz Testing 时,可能会遇到高值输入导致测试失败的情况。例如,当amount超过合约拥有的余额时,测试将失败。为了解决这个问题,可以限制amount的类型为uint96,以确保输入值在合理的范围内。

function testFuzz_Withdraw(uint96 amount) public {
    // 测试逻辑...
}

排除特定情况

使用vm.assume作弊码,可以排除某些不希望进行测试的特定情况。例如,如果不想测试低于0.1 ETH 的取款,可以如下编写:

function testFuzz_Withdraw(uint96 amount) public {
    vm.assume(amount > 0.1 ether);
    // 测试逻辑...
}

解读测试结果

Fuzz Testing 的测试结果提供了几个关键信息:

  • runs:测试运行的次数,默认情况下,Fuzz 测试会生成256个场景。
  • μ(mu):所有 Fuzz 运行中使用的平均 gas 量。
  • ~(tilde):所有Fuzz运行中使用的中位数 gas 量。

通过观察这些指标,可以更好地理解合约在不同输入下的行为和性能。

Fuzz Testing 优势

  • 自动化测试:通过自动生成测试用例,减少了手动编写测试用例的需要,使得测试过程更加高效和系统化。
  • 广泛的覆盖率:通过随机化输入数据,Fuzz Testing 能够探索合约行为的不同可能性,揭示那些在常规测试中可能被忽视的错误。
  • 灵活性高:支持通过调整测试参数,如运行次数、输入类型等,来定制化 Fuzz 测试,满足不同的测试需求。

Fuzz Testing 最佳实践

  • 明确测试目标:在进行Fuzz Testing之前,应该明确你想要测试的属性或行为是什么,以便设计出有效的测试用例。
  • 合理设置输入范围:通过限制输入数据的类型和范围,可以避免不必要的测试失败,提高测试的针对性和效率。
  • 分析测试结果:仔细分析Fuzz Testing的输出,不仅要关注失败的测试用例,也要注意通过的测试用例可能隐藏的潜在问题。
  • 结合其他测试方法:虽然Fuzz Testing是一个强大的工具,但它不能覆盖所有的测试场景。将Fuzz Testing与单元测试、集成测试等其他测试方法结合使用,可以更全面地验证智能合约的正确性和安全性。

不变性测试

不变性测试(Invariant Testing)是一种基于属性的测试方法,用于验证智能合约在任何可能的状态下都满足特定的属性或条件。它通过随机生成输入数据和状态转换来测试合约的行为。

不变性测试有两个维度:运行(runs)和深度(depth)

运行:生成并运行一系列函数调用的次数。

深度:在给定运行中进行的函数调用次数。每次函数调用后都会断言所有定义的不变量。如果函数调用回退,深度计数器仍然会增加。

比如我们有下面这个合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";

contract Token {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;
    
    function mint(address to, uint256 amount) public {
        balances[to] += amount;
        totalSupply += amount;
    }
    
    function transfer(address to, uint256 amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

我们可以设计如下的不变形测试合约

不变量:总供应量等于所有账户余额之和

contract TokenTest is Test {
    Token token;
    address alice = address(0x1);
    address bob = address(0x2);
    
    function setUp() public {
        token = new Token();
        // 配置测试运行参数
        targetContract(address(token));
        targetSender(alice); // 设置默认调用者
    }

    // 不变量:总供应量等于所有账户余额之和
    function invariant_totalSupply() public {
        uint256 sum;
        sum += token.balances(alice);
        sum += token.balances(bob);
        assertEq(token.totalSupply(), sum);
    }
    
    // 用于随机调用的函数集合
    function mint(uint256 amount) public {
        amount = bound(amount, 0, 1e18); // 限制金额范围
        token.mint(alice, amount);
    }
    
    function transfer(uint256 amount) public {
        amount = bound(amount, 0, token.balances(alice));
        token.transfer(bob, amount);
    }
}

目标合约设置

不变性测试中的目标合约可以通过以下三种方法设置:

1.手动添加到targetContracts数组的合约将被添加到目标合约集。

2.在setUp函数中部署的合约将自动添加到目标合约集(如果没有使用第1种方法手动添加合约)。

3.如果在setUp中部署的合约被添加到excludeContracts数组,则可以从目标合约中移除。

可以通过以下方式配置runs和depth:

  1. 命令行配置:
# 设置100次运行,每次运行深度为25
forge test --match-contract TokenTest --match-test invariant -vv --runs 100 --depth 25
  1. foundry.toml配置:
[fuzz]
runs = 100
depth = 25

说明:

运行(runs):

  • 表示整个测试序列执行的次数
  • 每次运行都从初始状态开始
  • 增加runs可以测试更多不同的初始条件
  • 示例中每次运行都会重新部署Token合约

深度(depth):

  • 表示每次运行中调用测试函数的次数
  • 决定了状态转换的复杂程度
  • 增加depth可以测试更长的操作序列
  • 示例中depth决定了mint和transfer的调用次数

调优策略

  • 简单合约:低runs(50-100),低depth(10-20)
  • 复杂合约:高runs(1000+),高depth(50+)
  • 关键业务:超高runs(10000+),中等depth(30-50)
  • 状态依赖:中等runs(500),高depth(100+)

差异模糊测试

差异测试是一种通过比较同一功能的多个实现的输出来找出错误的测试方法。这种方法基于一个简单的假设:如果两个实现在相同输入下的输出不同,则至少一个实现存在错误。

差异测试的背景和原理

差异测试的核心是交叉验证。例如,如果我们有一个功能规范 F(X) 和该规范的两个实现 f1(X)f2(X),我们期望对于所有合理的输入 xf1(x) 应该等于 f2(x)。如果 f1(x) 不等于 f2(x),那么我们知道至少有一个实现是错误的。

差异测试特别适用于以下情况:

  • 将升级后的实现与其早期版本进行比较。
  • 对照已知的参考实现测试代码。
  • 确认与第三方工具和依赖项的兼容性。

假设我们要测试一个函数的两个实现是否一致,以下是一个简单的测试合约示例:

import "forge-std/Test.sol";

contract DifferentialFuzzTest is Test {
    function testInputDifferentialFuzz(uint256 input) public {
        uint256 result1 = implementation1(input);
        uint256 result2 = implementation2(input);
        assertEq(result1, result2, "The outputs of the two implementations do not match");
    }

    // 伪代码实现
    function implementation1(uint256 input) internal pure returns (uint256) {
        return input * 2;
    }

    function implementation2(uint256 input) internal pure returns (uint256) {
        return input << 1;
    }
}

使用 Foundry 和 JavaScript 进行差异测试

我们的目标是验证用 Solidity 和 JavaScript 编写的默克尔树根生成函数是否产生相同的输出。我们将使用 Foundry 的 ffi 功能来调用外部 JavaScript 实现,并将结果与 Solidity 实现进行比较。

我们首先创建一个简单的 Solidity 实现,它生成默克尔树的根:

// Solidity 版本的默克尔树实现
function generateMerkleRoot(bytes32[] memory leaves) public pure returns (bytes32) {
    // 示例代码:具体实现细节省略
    return keccak256(abi.encodePacked(leaves));
}

JavaScript 实现和 Foundry 的 ffi 调用

然后,我们使用 Foundry 的 ffi 功能来调用一个外部的 JavaScript 脚本,该脚本也生成默克尔树的根:

function testMerkleRootMatchesJSImplementation(bytes32[] memory leaves) public {
    string[] memory args = new string[](3);
    args[0] = "node";
    args[1] = "./calculateMerkleRoot.js";
    args[2] = leaves.toHexString();  // 假设已实现转换为 hex 字符串的功能

    bytes memory jsResult = vm.ffi(args);
    bytes32 jsMerkleRoot = abi.decode(jsResult, (bytes32));

    bytes32 solMerkleRoot = generateMerkleRoot(leaves);
    assertEq(solMerkleRoot, jsMerkleRoot, "Merkle roots do not match");
}

作者:加密鲸拓

版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!