Quick Look DeFi Contract Testing With Foundry

ChiHaoLu
Taipei Ethereum Meetup
19 min readJun 2, 2022

--

最近開始研究 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 特性:

  1. 快速且彈性的編譯 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 將會創建兩個檔案目錄:libsrc

1. lib 利用了 git submodules 來管理 dependencies,包含:

  • ds-test 中的 testing contract (lib/ds-test/src/test.sol)
  • 各式各樣測試合約的實作 demo(lib/ds-test/demo/demo.sol)
  • 和其他我們下載的 dependencies,例如:forge-stdweird-erc20solmate

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

三個平行合約輔以 ERC20OwnablesafeMath 等函式庫的陽春 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

需要注意的點有:

  1. FarmTest 裡面我們在 setup()new 宣告合約之後,可取得 LP 和 StableCoin 的地址作為 FarmConstructor 參數傳入
  2. address(this) 是測試合約本身,也就是 FarmTest
  3. setup() 佈署 LP、StableCoin 和 Farm 這些合約的 Deployer 是 FarmTest 這個測試合約本身:<contract_deployer> == address(this)
  4. 使用 Solidity 來寫測試時,我們並不是透過 EOA 來 sign 一個合約(這個情況下 signer 是 msg.sender),所以 msg.sender 會需要是一個預設的值:msg.sender == <sender_in_foundry.toml>
  5. <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 successful
Running 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 packageCLI README.

Cheating with Standard Library

$ forge install foundry-rs/forge-std

下載了 Standard Library 之後在 Contract.t.sol 我們就改繼承 Test.sol 不用 ds-testtest.sol

以下節錄自 forge-std/Test.sol 的原始碼,可以發現已經實作了 ds-testVm.solconsole.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

從官方文件整理的函式比較表:

Link: OriginalCheatcode, related Forge-STD

在 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

Foundry Official Doc./Github

Foundry Resources

DeFi Testing

最後歡迎大家拍打餵食大學生0x2b83c71A59b926137D3E1f37EF20394d0495d72d

--

--

ChiHaoLu
Taipei Ethereum Meetup

Multitasking Master & Mr.MurMur | Blockchain Dev. @ imToken Labs | chihaolu.me | Advisory Services - https://forms.gle/mVGKQwPQEUP37fLYA