Quick Look DeFi Contract Testing With Foundry
最近開始研究 Foundry 和 DeFi,乾脆摻在一起做成一篇文章。
Table of Contents
- Intro.
- Cast an eye over the “Testing with Foundry”
- “Brewing” Time — Basic Defi Project
- “Tasting” Time — Unit Testing
- Tipsy — Coclusion & Reference
Synchronization Link Tree
Intro.
前兩個月使用過的 Foundry 變的越來越潮了,所以想來跟大家分享一下這個新穎的測試工具!
這次的主要流程為:先寫一個簡單的 DeFi Project,有 Staking 的功能,之後用 Foundry 對其進行測試。本來想要挑現行的有名 Project 來測試的,但找到的 DeFi Project 都有一點點巨應該值得更長的篇幅特別分享!
Foundry 更新的速度遠比我想像中快,不過是一個月沒看而已,很多東西都完全長得不一樣了,所以大家如果遇到任何問題可以先參考一下 Reference 中的官方文件們。
Cast an eye over the “Testing with Foundry”
Testing Type
Unit Testing
- Unit Testing 通常是指完整、獨立地測試每一個部件,在給定各種輸入的情況下和預期的輸出要相符。在測試的過程中通常不會考慮其他部件的影響,在 Solidity 撰寫的 Smat Contract 中,部件通常指的是每一個 Function。需要注意的輸入有:
- 邊際測資:空字串、bound(e.g. 0, min, max, 2²⁵⁶, -2¹²⁸…)
- 極端測資:超長的輸入
- 特殊測資:含有特殊字元的輸入
Integration Testing
- 將許多個 Unit 組合之後一起進行測試,確保這些部件無論是:
- 在一起隨機運作、有特定目的運作,或甚至模擬特定情況的運作,都是正確無誤的。
- 除了隨機交互作用之外,模擬的情況可能有:Owner Operation、WhiteList Operation、User Operation 等。
Regression Testing
- 以迴歸的方式來對版本重測,確定舊版本 Bug 於修正後不會在新版本中出現。
Stress Testing
- 會嘗試去模擬現實世界的運行流,通常會有多個使用者隨機操作產品,也會有不同地方的 Provider,這樣可以去測試系統中是否有 Deadlock 的發生,或者不正常甚至不斷重複呼叫某一個功能的情況。
Security Testing
- 針對特定攻擊或者目的進行預防測試,例如重送攻擊、閃電貸等。
What features can we use in Foundry
Foundry 由以下兩者組成:
- Forge: 和我們平常使用的其他開發工具一樣,是一個 Ethereum 的測試框架。
- Cast:支援多種客戶端功能,像是與 EVM 智能合約互動、傳遞交易、取得鏈上資訊等,就如同一把瑞士刀(官方文件寫的)。
來自官方的 Foundry 特性:
- 快速且彈性的編譯 Pipeline
- 自動偵測並下載 Solidity 不同版本的編譯器(under ~/.svm)
- 增量編譯和緩存: 只有被修改的檔案會被重新編譯
- 並行編譯
- 支援非特定的目錄結構(e.g. Hardhat repos)
2. 以 Solidity 撰寫測試
3. 快速的 Fuzz testing,能夠收斂到最小的輸入,並輸出其反例
4. 快速的遠端 RPC 分岔模式, 利用類似 tokio 的 Rust 異步運行架構
5. 彈性的 debug 紀錄輸出(logging),例如:Dapptools-style 的 DsTest’s emitted logs 和 Hardhat-style 的 console.sol contract
6. 非常輕量(5–10MB),不需要 Nix 之類的套件管理器
7. 能利用 foundry-toolchain 使用 Foundry GitHub Action 快速的 CI(持續性整合)
Preparation
如果作業系統是 Linux 或 macOS 最簡單的方法就是使用以下方法下載 Foundry:
curl -L https://foundry.paradigm.xyz | bash
foundryup
下載完成之後再執行一次 foundryup
會將 Foundry 更新至最新版本,如果想要返回到指定版本,則使用指令 foundryup -v $VERSION
。
然而我自己是使用 Windows,下載的方式如下。
在下載 Foundry 之前得先擁有 Rust 和 Cargo,首先到 rustup.rs 下載 rust,然後執行:
rustup-init
這樣就能同時準備好 Rust 和 Cargo,最後打開 CMD 使用以下指令安裝 Foundry。
cargo install --git https://github.com/foundry-rs/foundry foundry-cli anvil --bins --locked
下載成功以後在電腦的某個地方使用 init
初始化一個專案。
$ forge init defi-testing
forge CLI 將會創建兩個檔案目錄:lib
和 src
。
1. lib
利用了 git submodules 來管理 dependencies,包含:
ds-test
中的 testing contract (lib/ds-test/src/test.sol)- 各式各樣測試合約的實作 demo(lib/ds-test/demo/demo.sol)
- 和其他我們下載的 dependencies,例如:
forge-std
、weird-erc20
、solmate
2. src
放了我們寫的智能合約和測試的原始碼
.
├── foundry.toml
├── lib
│ ├─ds-test
│ │ ├─demo
│ │ └─src
│ └── forge-std
│ ├── lib
│ ├── LICENSE-APACHE
│ ├── LICENSE-MIT
│ ├── README.md
│ └── src
└── src
├── Contract.sol
└── test
└──Contract.t.sol
之後一樣在終端機的部分,輸入指令:
$ forge install OpenZeppelin/openzeppelin-contracts
>
Installing openzeppelin-contracts in "C:\\Users\\qazws\\Desktop\\Blockchain\\defi-testing\\lib\\openzeppelin-contracts", (url: https://github.com/OpenZeppelin/openzeppelin-contracts, tag: None)
便可以在 lib
中看見 OpenZeppelin 的合約們。
在 foundry.toml
裡面決定 Foundry 的運行設定,包含 Remap 我們 import 或執行命令的路徑,以下列出一些常用的參數:
[default]
src = 'src'
test = 'test'
out = 'out'
libs = ['lib']
remappings = ['ds-test/=lib/ds-test/src/',
"@openzeppelin/=lib/openzeppelin-contracts/"]
evm_version = 'london'
#solc_version = '0.8.10'
sender = '0xB42faBF7BCAE8bc5E368716B568a6f8Fdf3F84ec'
tx_origin = '0x00a329c0648769a73afac7f9381e08fb43dbea72'
initial_balance = '0xffffffffffffffffffffffff'
gas_limit = 9223372036854775807
gas_price = 0
block_timestamp = 0
gas_reports = ["*"]
更多詳細內容可查看以下連結。
“Brewing” Time — Basic Defi Project
全文的原始碼在此。
Implementation — Simple Staking Contract
三個平行合約輔以 ERC20
、Ownable
和 safeMath
等函式庫的陽春 DeFi Staking。基本上就是 User 可以透過抵押 stableCoin,換取抵押時間計算而得的 holdToken 收益。
Contract.sol:
StableCoin.sol:
LP.sol:
“Tasting” Time — Unit Testing
Initialization & First Testing
開始 Foundry 的測試時,setUp()
會是測試開始的切入點,每一個「測試函式開始前」都會特別為其「設置」一個初始狀態,也就是 setUp()
中的內容。
這邊主要測試 Owner 和兩個 ERC-20 合約的運作是否正常。
$ forge test
>
[⠆] Compiling...
[⠃] Compiling 4 files with 0.8.10
[⠊] Solc 0.8.10 finished in 2.79s
Compiler run successful (with warnings)[PASS] testMockContractInit() (gas: 21400)
[PASS] testMockContractTransferFrom(uint256) (runs: 256, μ: 95003, ~: 107435)
[PASS] testOwner() (gas: 9852)Test result: ok. 3 passed; 0 failed; finished in 0.64s
需要注意的點有:
FarmTest
裡面我們在setup()
以new
宣告合約之後,可取得 LP 和 StableCoin 的地址作為Farm
的Constructor
參數傳入address(this)
是測試合約本身,也就是FarmTest
- 在
setup()
佈署 LP、StableCoin 和 Farm 這些合約的 Deployer 是 FarmTest 這個測試合約本身:<contract_deployer> == address(this)
- 使用 Solidity 來寫測試時,我們並不是透過 EOA 來 sign 一個合約(這個情況下 signer 是 msg.sender),所以 msg.sender 會需要是一個預設的值:
msg.sender == <sender_in_foundry.toml>
<contract_deployer> != msg.sender
在 Foundry 中如果需要 Gas Report 可以使用以下指令:
$ forge test --gas-report
More features can use
Foundry 同樣也支持 Fuzzing 測試。因為當我們一個一個函式都進行測試時,即便全部都成功 PASS,但在邊際測資中其實也很有可能會出現一些問題,導致 Under/Overflow 或其他 RuntimeError/Memory Leak 之類的錯誤。
我們在測試函式中增加參數之後,Fuzzing 能夠讓 Solidity test runner 隨機選擇大量的參數輸入我們的函式。
在以上例子中 fuzzer 會自動地對 x
嘗試各種隨機數,如果他發現當前輸入會導致測試失敗,便會回傳錯誤,這時候就可以開始 debug 啦!
進行測試:
$ forge test
>
[⠆]Compiling...
[⠆]Compiling 1 files with 0.8.10
Compiler run successfulRunning 3 tests for FooTest.json:FooTest
[PASS] testDouble() (gas: 9384)
[FAIL. Reason: Arithmetic over/underflow. Counterexample: calldata=0xc80b36b68000000000000000000000000000000000000000000000000000000000000000, args=[57896044618658097711785492504343953926634992332820282019728792003956564819968]][0m testDoubleWithFuzzing(uint256) (runs: 4, μ: 2867, ~: 3823)
[PASS] testFailDouble() (gas: 9290)Failed tests:
[FAIL. Reason: Arithmetic over/underflow. Counterexample: calldata=0xc80b36b68000000000000000000000000000000000000000000000000000000000000000, args=[57896044618658097711785492504343953926634992332820282019728792003956564819968]][0m testDoubleWithFuzzing(uint256) (runs: 4, μ: 2867, ~: 3823)Encountered a total of [31m1[0m failing tests, [32m2[0m tests succeeded
從以上錯誤會發現當參數輸入為 57896044618658097711785492504343953926634992332820282019728792003956564819968
之後會出現錯誤,來到 wolframe 貼上這個數字會發現其為 5.789 * 10^76 ~= 2^255
。
聽起來十分合理因為 x
的型態是 uint256
,所以如果將其乘於 2 以後就會超過 uint256
的型態範圍!
未來 Foundry 除了 Fuzz Testing 之外,還會支援:
- Invariant Testing
- Symbolic Execution
- Mutation Testing
New Features 可以在這兩個 Repo 找到:forge package、CLI README.
Cheating with Standard Library
$ forge install foundry-rs/forge-std
下載了 Standard Library 之後在 Contract.t.sol
我們就改繼承 Test.sol
不用 ds-test
的 test.sol
。
以下節錄自 forge-std/Test.sol
的原始碼,可以發現已經實作了 ds-test
、Vm.sol
和 console.sol
這些我們需要的部分。
特別是:
Vm public constant vm = Vm(HEVM_ADDRESS);
在宣告以後便可以使用 vm
,他是 Foundry 中的 CheatingCode,可用於模擬「該 Test Function 中」的 EVM 和區塊鏈狀況,例如 :
vm.deal
可用於預設一個地址擁有一定數量的代幣(例如deal(address(dai), address(alice), 10000e18);
)vm.warp
可以指定block.timestamp
等。
msg.sender in Foundry
msg.sender
在 Foundry 中是一個特別重要的存在,是過往用其他語言寫測試比較少注意到的部分。
大家還記得之前的 foundry.toml
嗎!如果我們在裡面加上參數 sender
就可以指定在合約測試時預設的 msg.sender
:
[default]
src = 'src'
out = 'out'
libs = ['lib']
remappings = ['forge-std/=lib/forge-std/src/','ds-test/=lib/ds-test/src/', "@openzeppelin/=lib/openzeppelin-contracts/"]
sender = '0xB42faBF7BCAE8bc5E368716B568a6f8Fdf3F84ec'
block_timestamp = 0# See more config options https://github.com/gakonst/foundry/tree/master/config
從官方文件整理的函式比較表:
在 STD 中也可以使用 console.log
的模擬環境,需要注意的是 prank()
只適用於下一個 external call,而 console.log
並不是 external call 所以沒辦法印出我們想像中的地址。
舉例來說在測試開始前的 setup()
中,我們想要假裝由一個 EOA(address(deployer)
) 來 Deploy 合約,而不是和上面 FarmTest
一樣的使用 FarmTest
這個合約本身來 Deploy:
以上這個小例子的結果是:對 MyContract 這個合約來說,他的 Constructor 會認定 msg.sender
,也就是 address(deployer)
是他的 owner。
大致了解了 vm.prank()
的功能之後,我們可以回到 FarmTest
來看第一個 Prank 測試:
這個測試的目的在觀察合約 Owner 被轉換之後,用 prank 的方式觀察「最一開始的 Deployer」是否還是能成功送出 transferOwnership
。我們的預期是不行,因為 Ownership 已經被轉移給別人,所以我們使用 vm.expectRevert
。
再來看看另外一個測試:
msg.sender
在測試檔案中是一個會呼叫每個 test function 的 EOA。而 prank()
是一個 test function 中的函式,目的是去「改變 external call 的 caller」,並不會向外影響到整個測試檔的 msg.sender
,因此沒辦法影響到不是 external call 的對象,例如:assertEq
。
Unit Testing
超級陽春的把四個函式測試一次,基本上這裡沒有實作太花俏的內容,比較需要注意的點是關於兩種代幣的模型,在設計上包含供給量得特別注意,但由於是 Mock 來示範的所以沒有特別著墨。
testStaking
合約原始碼:
相對應的測試:
特別注意模擬使用者情境的時候該在 Dapp 實作的部分要補上,例如 approve()
。關於 Approve 和 Transfer 的用法與使用時機參考:
此外由於要符合我們代幣模型的設計,使用 vm.assume
來指定變數性質,例如以下測試中我們規定 testing 時輸入的參數大小不可以超過供應量(不然就沒有足夠的 balance 啦)。
testCalYield
合約原始碼:
相對應的測試:
testWithdrawYield
合約原始碼:
相對應的測試:
testUnStaking
合約原始碼:
相對應的測試:
大家也可以使用多個 mockAddress 來做交互測試,不一定要用 test Contract 本身當 receiver 或 sender,但本文並沒有實作這個測試方法。
Tipsy — Coclusion & Reference
Conclusion
因為最近要期末考(可憐大學生),所以 Integration Test、Stress Test、Security Test 等其他測試我留到下一篇再來分享!
這篇文章真的非常感謝 Nic 老師、Cyan 老師、智程老師、陳品老師、傳鈞老師、狸貓老師給予我許多十分有幫助的建議!感謝老師們願意花許多寶貴的時間細心看過後給予指導😥
如果對 Foundry 有一些小問題,或者是想了解一些我其他的補充內容可以看這裡!
Reference & Citation
- TESTING SMART CONTRACTS
- Maximilian Wohrer and Uwe Zdun, “DevOps for Ethereum Blockchain Smart Contracts,”
Foundry Official Doc./Github
Foundry Resources
- How to Foundry with Brock Elmore
- crisgarner/awesome-foundry
- Intro to Foundry | The FASTEST Smart Contract Framework
- Foundry Tutorial | How To Debug & Deploy Solidity Smart Contracts
- Tic-Tac-Token-Foundry
- Damn Vulnerable DeFi — Foundry Version ⚒️
DeFi Testing
最後歡迎大家拍打餵食大學生
0x2b83c71A59b926137D3E1f37EF20394d0495d72d