作者:Nic Lin,imToken Labs 资深区块链工程师
本文受众:区块链开发者
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 的功能
注意事项
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 会按照签章定义去检查传入的签章参数。需要特别留意。
资源链接
GitHub - crisgarner/awesome-foundry: A curated list of awesome of the Foundry development…
GitHub - dabit3/foundry-cheatsheet
教程
风险提示:本文内容均不构成任何形式的投资意见或建议。 imToken 对本文所提及的第三方服务和产品不做任何保证和承诺,亦不承担任何责任。数字资产投资有风险,请谨慎评估该等投资风险,咨询相关专业人士后自行作出决定。