合約開發框架 Foundry 介紹及使用心得

NIC Lin
imToken
Published in
23 min readJul 18, 2022

imToken 最近將合約開發框架從 Hardhat 換成 Foundry,此篇文章將分享 Foundry 使用心得並順便推廣 Foundry。

source: https://odyslam.com/blog/foundry-release-ci-cd-walkthrough/

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 就好,非常簡單直覺。

// 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 合約,所以一定會有 pragmaimport 及主合約。

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 那它就會從 02**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**256)**2 種可能),如果你為每一個 input 都加了多個篩選條件,會導致 fuzzing 一直在過濾重算(因為更難算到一個組合是能通過所有 input 的篩選條件的)。你的測試將會因此跑得非常久,或是因為達到 fuzz_max_local_rejects fuzz_max_global_rejects 上限而直接 abort。

Cheatcodes!

如果沒有鏈下功能,都是用合約來測試,不就受制於 Solidity 本身的限制了嗎?我碰不到 EVM、碰不到 state 的話,要怎麼用像是 Hardhat 提供的 impersonateAccount 或是 getStorageAt/setStorageAt 的功能?這就是 cheatcodes 派上用場的地方。

你可以把 cheatecodes 想像成包裝成 Solidity 函式的外掛指令,透過這些外掛指令你想要修改當前執行環境裡的各種參數都行,像是 msg.sendertx.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.approve(...);
myContract.transfer(...);
vm.stopPrank();
}
}

執行環境參數的預設值

如果測試一開始執行環境就在合約(例如 MyTest 合約)裡,那此時的 msg.sendertx.originblock.number 等等是怎麼來的?其實這些值都會有一個預設值,你可以透過在 foundry.toml 檔裡去修改這些預設值

簽名

利用 cheatcode 也可以簽名,sign cheatcode 的第一個參數(uint256)是用來簽名的私鑰,第二個參數(bytes32)是要簽名的內容。回傳值分別是 (uint8 v, bytes32 r, bytes32 s)。可以參考這個 EIP712 簽章的範例

透過指定檔案路徑部署合約

如果你會需要在測試合約內透過檔案路徑的方式去部署一個合約的話,可以參考 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 的 balanceOftotalSupply 時都是透過去覆寫讀取到的 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。例如 safeERC20safeApprove 裡,它在 approve會先去問 allowance,所以實際上 prank 會作用在問 allowance 那個 call 而不是 approve

4. EIP712 簽章內容組錯會無法經由測試發現

EIP712 簽章在組簽名內容時,如果少填或多填了參數,Foundry 的測試將不會發現有問題,因為測試裡組簽名內容的函式一定是拿原本合約寫好的來用,不會在測試裡再額外寫一次組簽名內容的函式。

假設你定義了一個 EIP712 簽章格式 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 View in Tenderly ;

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 本身不會知道 EIP712 簽章這個概念,同樣的場景在 Hardhat 測試裡會噴錯是因為套件像是 ethers.js 會按照簽章定義去檢查傳入的簽章參數。需要特別留意。

5. 測試名稱以 testFail 開頭,Foundry 會預期要執行失敗

而且這個效力會蓋過 vm.expectRevert(),所以當你測試裡用 vm.expectRevert() 時,記得測試名稱就不要用 testFail 開頭,否則 expectRevert 裡的 revert string 檢查是不會生效的,例如:

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

// Not expected result: transfer failed due to no allowance but you
// are tricked into believing it's because of not enough balance
function testFailTransferMoreThanOneHas() {
uint256 balance = myContract.balanceOf(0x123);
vm.expectRevert("ERC20: not enough balance");
myContract.transferFrom(0x123, 0x456, balance + 1);
}

--

--