###Defiは金融のあるべき姿です
良いものならしっかり動くものを出さないと勿体無いです
こんにちは, InsureDAO の開発をしています Oishunです。
Defiプロトコルは日々大きく複雑になっていっています。外部にコードを読まれる機会が圧倒的に多いcryptoでは, テストコードはコントラクトと同等かそれ以上に重要な役割があると考えていますが, 現状そこに十分な注意やリソースを割けているプロトコルがまだ少ない印象です。
そこで今回はスマコンのテストについて, 自分が書いていく中で得たものを共有します。
目次
- テスト構造
- テスト項目
- テスト高速化
- 可読性・開発速度アップ
- デバッグ方法
- console.log命
- テストケース内から出力
- コントラクトから出力
- おわりに
- リンク集
環境
solidity 0.8.7
hardhat 2.6.1
#テスト構造
ここでは, Curve Financeの例を参考にします
unitaryとintegration二種類のテストを行うことが大事です。
- unitary: コントラクト・関数(役割)ごとにファイルを分け, その中で単一的なテストを行う
- integration: 複数の関数(deposit, withdraw)をランダムに, ランダムなインプットで任意の回数実行する。
integrationでやっていることはfuzzingとも呼ばれます。
想定通りに動くかをテストするunitaryとは対照的に, integrationでは想定外の状態があるかどうかのテストができるため, スマコンでは特に重要なテストです。
###forkテスト
Uniswapなど外部defiと連携して動作するコントラクトのテストは, Fork環境でテストをします。
外部コントラクトとの接続の仕方
converter.test.js
const QuoterArtifact = require('@uniswap/v3-periphery/artifacts/contracts/lens/Quoter.sol/Quoter.json');
const QuoterAddress = '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6';
quoter = await ethers.getContractAt(QuoterArtifact.abi, QuoterAddress);
hardhat.config.js
networks: {
hardhat: {
initialBaseFeePerGas: 0,
//forking: {url: "https://eth-mainnet.alchemyapi.io/v2/inputYourKey",} //remove comment when testing mainnet fork
},
rinkeby: {
url: `https://rinkeby.infura.io/v3/${infuraKey}`,
accounts: [`0x${key}`]
}
},
paths: {
sources: "./contracts",
tests: "./test/local",
//tests: "./test/mainnet_fork",
cache: "./cache",
artifacts: "./artifacts"
},
mainnet forkでテストしたいときは, forkingのコメントを外し, testsのパスをスイッチしてやっています。が, 面倒なのでこれをコンソールでうまくやる方法ありましたら教えてくださいw
#テスト項目
solidity-coverageという, テストがどれくらいコードをカバーできているかを可視化してくれるプラグインがあるのですが, これを埋めるためにも以下の項目は必ずテストしましょう
- success case: 想定通りに動かすテスト
- revert check: require()などが反応するか, 意図的にrevertさせるテスト
上記はマストです。
もしさらにテストを深めたい場合は, fuzzingテストをしましょう。
async function rule_withdraw(){
console.log("rule_withdraw");
// Withdraw tokens from the voting escrow.
//st_account
let rdm = Math.floor(Math.random()*10);//0~9 integer
st_account_n = rdm;
st_account = accounts[st_account_n];
let timestamp = BigNumber.from((await ethers.provider.getBlock('latest')).timestamp);
if(voting_balances[st_account_n]["unlock_time"].gt(timestamp)){
console.log("--reverted");
await expect(voting_escrow.connect(st_account).withdraw()).to.revertedWith("The lock didn't expire");
}else{
console.log("--success, account:", st_account_n);
await voting_escrow.connect(st_account).withdraw();
voting_balances[st_account_n]["value"] = BigNumber.from("0");
}
}
let func = ["rule_deposit", "rule_withdraw", "rule_checkpoint"];
describe("test_votingescrow_admin", function(){
//set arbitral number of repeats
for(let x=0; x<10; x++){
it("try "+eval("x+1"), async()=>{
for(let i=0;i<100;i++){
let n = await rdm_value(func.length);
await eval(func[n])();
}
});
}
});
実際のコードをそのまま抜粋して移植してきたので, 例として見せるものではないですが。。
ここではいろいろな処理を, ランダムにランダムな値で, ランダムなアドレスから実行させています。
これで想定外の挙動を探します。
実はethers.jsの方からシードを入力するタイプのランダムが提供されていました。Seedを元に生成するので, seedがあれば同じ処理を再現できます。これからfuzzing test実装するよって方はこっちで実装した方が良いです。
##高速化
コントラクトのデプロイをbeforeEachでやるより, beforeでやってafterEachで毎回巻き戻す方が早いです.
async function snapshot () {
return network.provider.send('evm_snapshot', [])
}
async function restore (snapshotId) {
return network.provider.send('evm_revert', [snapshotId])
}
before(async () => {
[creator] = await ethers.getSigners();
const Ownership = await ethers.getContractFactory("Ownership");
ownership = await Ownership.deploy();
});
beforeEach(async () => {
snapshotId = await snapshot();
});
afterEach(async () => {
await restore(snapshotId);
});
#可読性・開発速度アップ
テストケース毎に, 大量のexpect().to.equal... があると醜いし, テスト忘れの元凶になります。
Auditorはテストも毎行確認します。同じコードならまとめたほうが, 可読性も良いです。
自分は別ファイルを作り, その中で検証用の関数はまとめました。
test-utils.js
const verifyBalance = async ({ token, address, expectedBalance }) => {
const balance = await token.balanceOf(address)
assert.equal(balance.toString(), expectedBalance.toString(), `token balance incorrect for ${token.address} with ${address}`)
}
const verifyBalances = async ({ token, userBalances }) => {
const users = Object.keys(userBalances)
for (i = 0; i < users.length; i++) {
await verifyBalance({ token: token, address: users[i], expectedBalance: userBalances[users[i]]})
}
}
//export
Object.assign(exports, {
verifyBalances
})
テストファイルの方から, インポートして使います
const {
verifyBalances,
} = require("../test-utils")
await verifyBalances({
token: usdc,
userBalances: {
[alice.address]: initialMint,
[bob.address]: initialMint,
[chad.address]: initialMint
},
})
この方法で書くと, テストファイルが圧倒的に読みやすくなります。
expect()~系は基本上記の方法でまとめます。
自分の場合はついでに, approve()=>deposit()など, 何回も使われる処理系もまとめました。
#デバッグ方法
テストをやっていく中で当然壁にぶち当たりますが, その時いかに早く問題を特定するのにデバッグは命です。
デバッグのためにはconsoleに出力したいですが, 手法がいくつかあるので紹介します。
###テストコードから出力
テストケース内から変数を出力できます。
console.log("output:", output)
###コントラクト内から出力
hardhat/console.solという, スマコンからコンソールに出力できるモジュールがあります。
import "hardhat/console.sol";
contract Test{
function ppap(){
uint256 a = 10;
console.log(a); //コンソールに10が出力される
}
}
しかし, これはuint256しか出力できません。他の型を出力したい時は, 面倒ですがEventを出して, テストファイル側で拾って出力できます。=>できるそうです
console.logBytes();
or
console.log("a: %s", a);
Eventの方も一応残しておきます
event TEST(address owner, bytes32 metadata, string text, uint256 num);
function ppap(){
address _a = address(0);
bytes32 _b = 0x...;
string _c = "hi";
uint256 _d = 10;
emit TEST(_a, _b, _c, _d);
}
test.test.js
let tx = await test.ppap()
let receipt = await tx.wait()
let event = receipt.events[0]
console.log(event) //発行されたeventをコンソール出力
実際のテストでも, 実際のコードで発行されるEventを頼りにテストすることもあります
let tx = await pool.deposit(depositAmount)
let expectedMintAmount = depositAmount;
let actualMintAmount = (await tx.wait()).events[0].args['mintAmount'];
expect(actualMintAmount).to.equal(expectedMintAmount)
console.logは大事ですが, デバッグ手法は他にもやり方がいろいろあります。テストしたい関数が複雑な場合, それを複製してコードを一行づつ増やしながらテストしてみたり。テストコード側だけではなく, 柔軟にコントラクト側でも工夫すればデバッグ効率が圧倒的に向上します。
#おわりに
テストほどコントラクトの仕様を理解できるものはありません。
Auditやバグバウンティなど, 人に読まれる機会の多いスマコンこそ, テストは深く簡潔に書かれるべきだと思います。
これからもどんどんエンジニアが入ってくると思うので, この記事でより良いプロダクト作りに貢献できれば幸いです。
#リンク集
最後に, おすすめの記事を紹介します
- Curve開発者: An In-Depth Guide to Testing Ethereum Smart Contracts
- Molochテストコード: Moloch Testing Guide
- Paradigm記事: Introducing the Foundry Ethereum development toolbox
- Rari Capital: The Solcurity Standard