合约开发框架 Foundry 介绍及使用心得
来源:    发布时间: 2023-12-28 17:29   138 次浏览   大小:  16px  14px  12px
Foundry 是为 Solidity 开发人员构建的 Rust 版合约开发框架。

Foundry 是什么

Foundry 是为 Solidity 开发人员构建的 Rust 版合约开发框架。

Foundry 的优点

  • 合约编译和测试执行速度飞快,快到会打到你免费版 Alchemy 的 rate limit 限制

  • 因为是用 Solidity 撰写测试,因此开发者只需要专注在 Solidity 本身,不需要担心用 JavaScript/TypeScript/Python 等等语言写测试时会遇到的语言的上手问题或额外的 bug

  • Foundry 虽然是开源项目,但开发效率比许多闭源项目还高上许多。非常频繁地更新新功能或 Bug fix

  • 相比 Hardhat 测试,多了 Fuzzing 测试,以及还在开发中的 Invariant 测试及 Symbolic Execution

安装 Foundry 及更新

详细可以参考 Foundry book 的 installation 页面

如果是 Linux 或 macOS,先安装 foundryup,接着直接用 foundryup 指令就可以安装。未来要升级 foundry 也只需要执行 foundryup 就好,非常简单直观。

// Install foundryup curl -L https://foundry.paradigm.xyz | bash // Install or update Foundry foundryup

注:安装 Foundry 会安装包含用来测试的 forge 功能及其他操作合约、读链资料、送交易的辅助功能例如 cast,本文只会聚焦在用来测试的 forge 功能。

安装套件

如果你需要用到像是 OpenZeppelin 或 Solmate 的 library,用 forge install ,后面接的参数是该 library 的 GitHub repo 名称(可包含 tag 或 commit)。

// Install dependencies forge install Rari-Capital/solmate forge install OpenZeppelin/openzeppelin-contracts@v3.4.2-solc-0.7 // Update dependencies forge update solmate // Remove dependencies forge remove openzeppelin-contracts

注:forge install 是用安装 git submodule 的方式安装,目前会固定安装在 lib 资料夹底下。

合约 library 会以 submodule 形式,固定安装在 lib 资料夹底下

设定档

Foundry 的设定档是 foundry.toml 档,不一定要有这个设定档,Foundry 会自动带入预设值。里面一些比较常用到的值例如:

# 合约资料夹  src = 'src' # 测试档资料夹 test = 'test' # Artifact 资料夹 out = 'out'
# 自动依照合约内容侦测所使用的 solidity compiler 版本 auto_detect_solc = true # 使用指定的 solidity compiler 版本,这会覆写`auto_detect_solc` #solc_version = '0.8.10'
# RPC url。注意如果有提供这个 url,测试会默认你是要用 fork network 执行测试 #eth_rpc_url = 'https://eth-mainnet.alchemyapi.io/v2/API_KEY'

待会在测试章节里还会看到其他设定值的使用。

注:其他参数或支援多个设定档并存的功能可以参考 Foundry book 的 configuration 页面

Hardhat compatible

Foundry 为了方便 Hardhat 开发者迁移到 Foundry,提供了能让 Foundry 和 Hardhat 同时并存的功能。这会需要做一些额外的修改和设定,但对原本 repo 太大的团队来说,能慢慢迁移过去也比较安心和顺畅。

Hardhat 的套件(包含例如 OpenZeppelin)会安装在 node_modules 资料夹底下,Foundry 的套件会安装在 lib 资料夹底下。所以要能让套件不管安装在哪里都能顺利执行 Foundry 和 Hardhat,就会需要 remapping 的设定(可以通过在 foundry.toml 里设定或新增一个 remappings.txt 档案)。

例如 OpenZeppelin 如果是安装在 node_modules 资料夹但要让 Foundry 顺利执行,那就需要在 remapping 设定里指定:@openzeppelin/=node_modules/@openzeppelin/。在合约内 import OpenZeppelin 时只要写 import "@openzeppelin/_.",它就会知道要去 node_modules/@openzeppelin 资料夹底下找档案。

另外 remapping 也可以用来让你自己设定 Solidity 合约里的 import path:

# 左边是合约内 import 路径,右边是实际路径 utils/=contracts/utils/ test-utils=contracts/test/utils/ mocks/=contracts/mocks/

注:如果反过来,套件是安装在 lib 资料夹但要让 Hardhat 顺利执行,请参考 Foundry book 的 Hardhat 页面

测试

在介绍今天的重点「如何写 Foundry 测试」之前,会先介绍 Foundry 测试档案的架构、Foundry 提供的测试种类,以及 Foundry 最重要的功能之一:cheatcodes。

Foundry 是用 Solidity 来写测试,所以在转换成 Foundry 之前,要记得放下以前写测试是「写一堆(在链下运作的)代码来戳你要测试的合约」这个习惯,然后接受你现在要「写一个合约来戳你要测试的合约」,也就是你一开始的进入点就是在合约内了!没有所谓链下这种概念!

左边是 Hardhat/Brownie,右边是 Foundry

在写测试时,你要想像你就是 MyTest 这个合约,用呼叫另一个合约的方式在测试 MyContract 合约。

测试档案架构

首先,测试档案是一个 Solidity 合约,所以一定会有 pragma、import 及主合约。 

pragma solidity 0.7.6; import "forge-std/Test.sol"; import "MyContract.sol"; contract MyTest is Test {     MyContract myContract;     _. }

注 1:forge-std 可以说是必要的套件,里面提供各种测试必备功能,例如:console.log(就像是 Hardhat 的 console.log)、assert(就像是 Mocha/Chai 的 assert)及待会会介绍的 cheatcodes。

注 2:MyContract 是我们要测试的合约,MyTest 是测试 MyContract 的合约。MyTest 里面会包含部署 MyContract、设置相关参数及设定,以及实际的测试函数。

setUp 函数:让你部署合约并做好测试前的准备

contract MyTest is Test {     MyContract myContract;     function setUp() public {         myContract = new MyContract(_.);         myContract.setParams(_.);     } }

注:setUp 函数如同 Hardhat 的 beforeEach 函数,会在每一个测试执行前都执行一次。

setUp 函数写完后,就可以开始写测试函数了。

测试种类

每一个测试都要用一个函数来写,要宣告成 public/external 而且开头要是 test 四个字,例如:

contract MyTest is Test {     _.     function testTransfer() public {         _.     }     function thisIsNotATest() internal {         _.     }     function testCannotApprove() public {         _.     } }

注:测试命名看每个人或团队喜好,可以是 Camel Case 或 Snake Case 等等。Foundry 文件的测试范例是使用符合 Solidity 命名规则的 Camel Case。

Fuzzing 测试

Foundry 另外还有支援 fuzzing 测试,让 Foundry 帮你随机生成 input 让你去执行你要测试的函数,像 Pytest 的 Prometheus 那样。Fuzzing 测试和一般测试的区别就在于测试函数有没有参数:没有参数的话就是一般测试,有的话就会变成 fuzzing 测试。

function testNormalTest() public {_.} function testFuzzingTest(uint256 x) public {     MyContract.setX(x);     // 在这个例子中要计算 x 平方,如果 fuzzing 产生的 x 值太大     // 就有可能导致算 x 平方时 overflow 而失败。     // 这时候 fuzzing 就会告诉你它找到这个 x 的值会导致你的执行失败,     // 你必须要修正 computeXSquare 函数让它能执行成功,否则这个测试     // 会一直失败。     MyContract.computeXSquare(); }

过滤 fuzzing 的 input

有时候未必是 computeXSquare 函数有问题,你可能会检查传进 computeXSquare的参数要符合特定条件(例如必须要大于或小于某个值)。这时候如果是用 Fuzzing 随意产生的值来呼叫就有可能因为这个条件检查而失败,但这不是你想要测试 computeXSquare的目的。这时候你就可以用 vm.assume() 来过滤掉预期外的 fuzzing input。

function testFuzzingTest(uint256 x) public {     // 如果 fuzzing 产生的 x 值会导致 vm.assume() 里的条件 return false     // 那 fuzzing 就会跳过这个值并重新产生新的 x 值。     vm.assume(1 * 10**18 < x && x < 10**9 * 10**18);     MyContract.setX(x);     MyContract.computeXSquare(); }

注 1:参数的型别是可以自已指定(只要是 Solidity 的型别都可以),Fuzzing 也会按照型别来产生乱数,例如指定 uint32 那它就会从 0 到 2^32–1 之间的数字来随机挑选。你会花不少时间在筛选 fuzzing input。

注 2:随机并不是真的随机,它会优先寻找边界的值例如 0, 1, 2, … 或是 2^32–1, 2^32–2, … 。

Fuzzing Runs

如果你指定的型别是 uint256,那表示一共会有 2^256 种可能的值,fuzzing 不可能帮你每一个值都测过一遍,所以你必须指定每次测试的 run 数。例如 500 run,那每一次你跑测试,fuzzing 就会从 2^256 个值中随机选出 500 个值。

  • Run 数可以通过 foundry.toml 档里的 fuzz_runs 参数来指定(或通过 FOUNDRY_FUZZ_RUNS 环境变数)

  • 另外还有 fuzz_max_local_rejects 参数(或 FOUNDRY_FUZZ_MAX_LOCAL_REJECTS 环境变数)及 fuzz_max_global_rejects 参数(或 FOUNDRY_FUZZ_MAX_GLOBAL_REJECTS 环境变数)

  • 上面这两个参数是指定当 fuzzing 产生的值被 vm.assume() 过滤掉一定次数后,就直接 abort,避免因为一直过滤而永远跑不完。详细请见 Fuzzing 参数页面

特别注意如果你的 fuzzing 测试有多个 input,代表会有更多种可能(两个 uint256 参数代表有 2* 2^256 种可能),如果你为每一个 input 都加了多个筛选条件,会导致 fuzzing 一直在过滤重算(因为更难算到一个组合是能通过所有 input 的筛选条件的)。你的测试将会因此跑得非常久,或是因为达到 fuzz_max_local_rejects 或 fuzz_max_global_rejects 上限而直接 abort。

Cheatcodes!

如果没有链下功能,都是用合约来测试,不就受制于 Solidity 本身的限制了吗?我碰不到 EVM、碰不到 state 的话,要怎么用像是 Hardhat 提供的 impersonateAccount 或是 getStorageAt/setStorageAt 的功能?这就是 cheatcodes 派上用场的地方。

你可以把 cheatecodes 想像成包装成 Solidity 函数的外挂指令,通过这些外挂指令你想要修改当前执行环境里的各种参数都行,像是 msg.sender、tx.origin、block timestamp、block gas limit、任意地址的 ETH 余额等等。常用的 cheatcodes 像是:

vm.warp(12300000) // Set block timestamp to 12300000 vm.roll(150000) // Set block number to 150000 vm.chainId(5) // Set chain ID to 5 vm.getNonce(0x123) // Get nonce of address 0x123 vm.setNonce(0x123, 99) // Set nonce of address 0x123 to 99 vm.deal(0x123, 1 ether) // Set balance of address 0x123 to 1 ether

注:deal 也可以修改 ERC20 的余额,它的底层是去捞 balanceOf 会读取到的 storage slot,再直接去修改这个 storage slot 的值。

修改 msg.sender 及 tx.origin

假设在测试 MyContract 的 transfer 函数时,因为是由 MyTest 这个合约去呼叫 MyContract.transfer(…),所以 MyContract transfer 函数在执行时的 msg.sender 会是 MyTest 合约。

如果你希望它是模拟成以另一个地址去呼叫 transfer 函数的话,你就会需要用 prank 这个 cheatcode 来修改 msg.sender。prank 可以吃一个或两个参数,第一个参数(address)会是你要指定的 msg.sender,如果有第二个参数(address)的话,那就是你要指定的 tx.origin。

contract MyTest {     function testTransfer() {         // msg.sender would be MyTest         myContract.transfer(_.);         // msg.sender would be 0x123 for the next call only         vm.prank(0x123);         myContract.transfer(_.);         // msg.sender would be 0x123 until stopPrank is called         // tx.origin would be 0x456 until stopPrank is called         vm.startPrank(0x123, 0x456);         myContract.transfer(_.);         myContract.blablabla(_.);         vm.stopPrank();     } }

执行环境参数的预设值

如果测试一开始执行环境就在合约(例如 MyTest 合约)里,那此时的 msg.sender、tx.origin、block.number 等等是怎么来的?其实这些值都会有一个预设值,你可以通过在 foundry.toml 档里去修改这些预设值

签名

利用 cheatcode 也可以签名,sign cheatcode 的第一个参数(uint256)是用来签名的私钥,第二个参数(bytes32)是要签名的内容。回传值分别是 (uint8 v, bytes32 r, bytes32 s)。可以参考这个 EIP-712 签章的范例

通过指定档案路径部署合约

如果你需要在测试合约内通过档案路径的方式去部署一个合约的话,可以参考 Uniswap V3 的测试

log, expect, assert, label

如果你要用像是 Hardhat console.log 的功能的话,可以用 console.sol/console2.sol 或是用 emit log 的方式

如果你预期某个函数执行一定会失败的话,可以用 vm.expectRevert(…),里面填执行失败会喷的 revert string(如果有的话):

function testCannotTransferMoreThanOneHas() {     uint256 balance = myContract.balanceOf(0x123);     vm.expectRevert("ERC20: not enough balance");     vm.prank(0x123);     myContract.transfer(0x456, balance + 1); }

如果你要 assert 某个结果的话,有很多 assertion 可以用,请参考 Asserting 页面

另外一个方便 debug 的功能是 label,被 label 起来的地址会在测试的 log 中显示你为这个地址 label 的名称。例如你 label 0x123 这个地址为 Alice(vm.label(0x123, “Alice”)),则测试的 log 中不管是参数、呼叫者或被呼叫者是 0x123 这个地址,它就会显示为 Alice,这在你通过测试 log 在 debug 的时候很好用。

被 label 的地址在 log 中会显示为 label 指定的名称,方便辨识

指令

  • 测试指令:forge test 

  • verbosity:-v, -vv, -vvv, -vvvv, -vvvvv,越多 v 越 verbose

  • 筛选测试:--match-contract 筛选测试合约名称、--match-test 筛选测试函数名称、--match-path 筛选测试档案路径及名称。以上都可以搭配 --no 的 prefix 来做反向的筛选。

  • --fork-url 及 --fork-block-number:用来指定 fork network 的参数(记得前面提到的,如果你把这资讯写在 foundry.toml 里,则你的测试全部都会跑在 fork network 里)。可以用资料夹和 --match-path 区分 fork network 及不是 fork network 的测试

更多指令参数请参考 test 指令页面

CI

在 CI 里跑 Foundry 测试会需要下载 foundry-toolchain 套件

Debug 功能

forge 还有一个 debug 功能,能深入看到每一个 opcode 执行时的 stack、memory 和 storage,请参考 debugger 页面。但这个功能有点 over kill 而且界面没有 Tenderly debug 功能友善,所以建议使用 Tenderly debug 功能。

WIP 的功能

  • Invariant Testing

  • Symbolic Execution

  • Coverage 功能

注意事项

1. foundry.toml 档设置 RPC URL 的话会让所有测试变成 fork network 测试

所以 fork network 要利用环境变数的方式让测试指令吃到 URL 和 Fork Block Number。这是目前比较麻烦的地方,未来 Foundry 会逐渐让这一个开发体验更好。

2. deal 设置 ERC20 totalSupply 时低机率失败

deal 设置 ERC20 的 balanceOf 或 totalSupply 时都是通过去覆写读取到的 storage slot 来达成,但如果遇到像是 WETH 的 totalSupply 不是用 storage 存的话就会导致 deal 失败。所以遇到像 WETH 这种代币要设置 totalSupply 的话,就必须要绕过 deal,例如先设置 balanceOf,接着再实际去 deposit。

3. 注意 vm.prank 只会在下一个 call 生效

这是在使用 vm.prank() 要特别注意的地方。call 就是一般合约呼叫另一个合约,所以如果在 vm.prank() 和你要 prank 的 call 之间多了另一个 call(即便是呼叫 ERC20 的 balanceOf 也算一个 call),prank 会生效在中间的那个 call。例如 safeERC20 的 safeApprove 里,它在 approve 前会先去问 allowance

4. EIP-712 签章内容组错会无法经由测试发现

EIP-712 签章在组签名内容时,如果少填或多填了参数,Foundry 的测试将不会发现有问题,因为测试里组签名内容的函数一定是拿原本合约写好的来用,不会在测试里再额外写一次组签名内容的函数。

假设你定义了一个 EIP-712 签章格式 tradeWithPermit,让使用者通过签章来同意合约把他的代币拿去 AMM 换成另一种代币:

/*     keccak256(         abi.encodePacked(             "tradeWithPermit(",             "address makerAddr,",             "address takerAssetAddr,",             "address makerAssetAddr,",             "uint256 takerAssetAmount,",             "uint256 makerAssetAmount,",             "address userAddr,",             "address receiverAddr,",             "uint256 salt,",             "uint256 deadline",             ")"         )     ); */ bytes32 public constant TRADE_WITH_PERMIT_TYPEHASH = 0x213bb100dae8406fe07494ce25c2bfdb417aafdf4a6df7355a70d2d48823c418; function _getOrderHash(Order memory _order) internal pure returns (bytes32) {     return keccak256(         abi.encode(             TRADE_WITH_PERMIT_TYPEHASH,             _order.makerAddr,             _order.takerAssetAddr,             _order.makerAssetAddr,             _order.takerAssetAmount,             _order.makerAssetAmount,             _order.userAddr,             _order.receiverAddr,             _order.salt,             _order.deadline         )     ); }

如果你今天因为需求再新增了一个 fee 参数到 tradeWithPermit 这个签章定义中,你改了 TRADE_WITH_PERMIT_TYPEHASH 但是在 _getOrderHash 里却忘记把 _order.fee 加进去。此时测试是会顺利通过的,也就是你没办法发现你在组签章内容实际上和签章定义的不符。

这是因为 Solidity 本身不会知道 EIP-712 签章这个概念,同样的场景在 Hardhat 测试里会报错是因为套件像是 ethers.js 会按照签章定义去检查传入的签章参数。需要特别留意。