合约开发框架Foundry 介绍及使用心得
imToken 最近将合约开发框架从Hardhat 换成Foundry,此篇文章将分享Foundry 使用心得并顺便推广Foundry。
Foundry 是什么
Foundry是由Rust 语言所写,为Solidity 开发者构建的合约开发框架。
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就好,非常简单直觉。
注:安装Foundry 会安装包含用来测试的forge功能及其他操作合约、读链资料、送交易的辅助功能例如cast,本文只会聚焦在用来测试的forge功能。
安装套件
如果你需要用到像是Openzeppelin 或Solmate 的library,用forge install,后面接的参数是该library 的Github repo 名称(可包含tag 或commit)。
注:forge install是用安装git submodule 的方式安装,目前会固定安装在lib 资料夹底下。
合约library 会以submodule 形式,固定安装在lib 资料夹底下
设定档
Foundry 的设定档是foundry.toml档,不一定要有这个设定档,Foundry 会自动带入预设值。里面一些比较常用到的值例如:
待会在测试章节里还会看到其他设定值的使用。
注:其他参数或支持多个设定档并存的功能可以参考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:
注:如果反过来,套件是安装在lib 资料夹但要让Hardhat 顺利执行,请参考Foundry book 的Hardhat 页面。
测试
在介绍今天的重点:「如何写Foundry 测试」之前,会先介绍Foundry 测试档案的架构、Foundry 提供的测试种类,以及Foundry 最重要的功能之一:cheatcodes。
Foundry 是用Solidity 来写测试,所以在转换成Foundry 之前,要记得放下以前写测试是「写一堆(在链下运作的)代码来戳你要测试的合约」这个习惯,然后接受你现在要「写一个合约来戳你要测试的合约」,也就是你一开始的进入点就是在合约内了!没有所谓链下这种概念!
左边是Hardhat/Brownie,右边是Foundry
在写测试时,你要想像你就是MyTest 这个合约,用呼叫另一个合约的方式在测试MyContract 合约。
测试档案架构
首先,测试档案是一个Solidity 合约,所以一定会有pragma、import及主合约。
注1:forge-std可以说是必要的套件,里面提供各种测试必备功能,例如:console.log(就像是Hardhat 的console.log)、assert(就像是Mocha/Chai 的assert)及待会会介绍的cheatcodes。
注2:MyContract 是我们要测试的合约,MyTest 是测试MyContract 的合约。MyTest 里面会包含部署MyContract、设置相关参数及设定,以及实际的测试函式。
setUp 函式:让你部署合约并做好测试前的准备
注:setUp函式如同Hardhat 的beforeEach函式,会在每一个测试执行前都执行一次。
setUp 函式写完后,就可以开始写测试函式了。
测试种类
每一个测试都要用一个函式来写,要宣告成public/external而且开头要是test四个字,例如:
注:测试命名看每个人或团队喜好,可以是Camel Case 或Snake Case 等等。Foundry 文件的测试范例是使用符合Solidity 命名规则的Camel Case。
Fuzzing 测试
Foundry 另外还有支持fuzzing 测试,让Foundry 帮你随机生成input 让你去执行你要测试的函式,像Pytest 的Prometheus 那样。Fuzzing 测试和一般测试的区别就在于测试函式有没有参数:没有参数的话就是一般测试,有的话就会变成fuzzing 测试。
过滤fuzzing 的input
有时候未必是computeXSquare函式有问题,你可能会检查传进computeXSquare的参数要符合特定条件(例如必须要大于或小于某个值)。这时候如果是用Fuzzing 随意产生的值来呼叫就有可能因为这个条件检查而失败,但这不是你想要测试computeXSquare的目的。这时候你就可以用vm.assume()来过滤掉预期外的fuzzing input。
注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 像是:
注:deal也可以修改ERC20 的余额,它的底层是去捞balanceOf会读取到的storage slot,再直接去修改这个storage slot 的值。
修改msg.sender 及tx.origin
在测试MyContract 的transfer函式时,因为是由MyTest 这个合约去呼叫MyContract.transfer(…),所以MyContracttransfer函式在执行时的msg.sender会是MyTest 合约。
如果你希望它是模拟成以另一个地址去呼叫transfer函式的话,你就会需要用prank这个cheatcode 来修改msg.sender。prank 可以吃一个或两个参数,第一个参数(address)会是你要指定的msg.sender,如果有第二个参数(address)的话,那就是你要指定的tx.origin。
执行环境参数的预设值
如果测试一开始执行环境就在合约(例如MyTest 合约)里,那此时的msg.sender、tx.origin、block.number等等是怎么来的其实这些值都会有一个预设值,你可以透过在foundry.toml档里去修改这些预设值。
签名
利用cheatcode 也可以签名,signcheatcode 的第一个参数(uint256)是用来签名的私钥,第二个参数(bytes32)是要签名的内容。回传值分别是(uint8 v, bytes32 r, bytes32 s)。
透过指定档案路径部署合约
如果你会需要在测试合约内透过档案路径的方式去部署一个合约的话,可以参考Uniswap V3 的测试。
log, expect, assert, label
如果你要用像是Hardhatconsole.log的功能的话,可以用console.sol/console2.sol或是用emit log的方式。
如果你预期某个函式执行一定会失败的话,可以用vm.expectRevert(…),里面填执行失败会喷的revert string(如果有的话):
如果你要assert 某个结果的话,有很多assertion 可以用,请参考Asserting 页面。
另外一个方便debug 的功能是label,被label起来的地址会在测试的log 中显示你为这个地址label的名称。例如你label0x123 这个地址为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 的测试。
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,所以实际上prank 会作用在问allowance那个call 而不是approve。
4. EIP712 签名内容组错会无法经由测试发现
EIP712 签名在组签名内容时,如果少填或多填了参数,Foundry 的测试将不会发现有问题,因为测试里组签名内容的函式一定是拿原本合约写好的来用,不会在测试里再额外写一次组签名内容的函式。
假设你定义了一个EIP712 签名格式tradeWithPermit,让使用者透过签名来同意合约把他的代币拿去AMM 换成另一种代币:
如果你今天因为需求再新增了一个fee参数到tradeWithPermit这个签名定义中,你改了TRADE_WITH_PERMIT_TYPEHASH但是在_getOrderHash里却忘记把_order.fee加进去。此时测试是会顺利通过的,也就是你没办法发现你在组签名内容实际上和签名定义的不符。
这是因为Solidity 本身不会知道EIP712 签名这个概念,同样的场景在Hardhat 测试里会喷错是因为套件像是ethers.js 会按照签名定义去检查传入的签名参数。需要特别留意。
5. 测试名称以testFail 开头,Foundry 会预期要执行失败
而且这个效力会盖过vm.expectRevert(),所以当你测试里用vm.expectRevert()时,记得测试名称就不要用testFail 开头,否则expectRevert里的revert string 检查是不会生效的,例如:
声明:本站所提供的资讯信息不代表任何投资暗示, 本站所发布文章仅代表个人观点,仅供参考。