foundry

【hardhat移行の決め手】foundryのDifferentialTestの凄さ

前回、DifferentialTestの概要を解説させていただきました。

今回は、MerkelTreeを題材に、hardhatからの移行の決め手となったDifferentialTestの凄い部分にフォーカスを当てて解説していきたいと思います。

前回同様ここで使用するテストコードなどは全て下記に保存しています。

https://github.com/eggdragons/how-to-use-foundry

今回のテストでは、foundryの公式ホームページの例にもなっているdmfxyz/murkyを一部利用します。

合わせて下記のページもご確認ください。

https://book.getfoundry.sh/forge/differential-ffi-testing

引数がランダムに選出されるのを体感するテスト(testCreateRandomLeaves)

まずは、Fuzz機能がどんなものかを目視確認が出来るテストからやっていきたいと思います。

引数がランダムに選出されるのを体感するテストを行います。

対象のテストは、test/merkleDifferentialTests.t.solで、テストの内容はこちら

    function testCreateRandomLeaves(address[128] memory addresses, uint8[128] memory quantities) public {
        bytes memory packed1 = abi.encodePacked(addresses);
        bytes memory packed2 = abi.encodePacked(quantities);

        string[] memory runJsInputs = new string[](4);
        runJsInputs[0] = "node";
        runJsInputs[1] = "ts-src/createRandomLeaves.js";
        runJsInputs[2] = packed1.toHexString();
        runJsInputs[3] = packed2.toHexString();

        bytes memory result = vm.ffi(runJsInputs);
        uint256 number = abi.decode(result, (uint256));
        assertEq(number, 1);
    }

foundryのFuzz機能を用いてランダムにアドレスと数量の配列を取得し、nodejsに出力します。

nodejs(ts-src/createRandomLeaves.ts)では、取得した配列をデコードしてjson形式で出力します。

json形式での出力が終わると、1を返す仕組みになっています。

早速、テストを行ってみましょう!

npm run compile && forge test --ffi \
--match-contract DifferentialTests \
--match-test testCreateRandomLeaves -vvvvv

テストが開始されると、ルートディレクトリにdata/createRandomLeaves.jsonが作成されるので、開いてみてください。

すると、どんどんランダムな値が上書きされていくのがわかります。

Fuzz機能は、ランダムに引数を選ぶだけでなく、トライを重ねてくれることがわかります。

全てのテストに合格すると、テスト合格になります。

nodejsのロジックをコントラクトで検証する(testLogicMerkleProofBadCase1)

早速、foundryのDifferentialTestの凄さを体感していきましょう!

今回は、コントラクト内で、OpenzeppelinのMerkleProofを活用したいケースを考えていきます。

MerkleProofには、様々種類があり、コントラクト側に実装したいMerkleProofとnodejs側で作ったMerkleProofが一致するのかを確認したいことって往々にしてあると思います。

対象のテストは、test/merkleDifferentialTests.t.solで、テストの内容はこちら

    function testLogicMerkleProofBadCase1(address[128] memory addresses, uint8[128] memory quantities, uint8 i)
        public
    {
        vm.assume(i < 128);
        bytes memory packed1 = abi.encodePacked(addresses);
        bytes memory packed2 = abi.encodePacked(quantities);

        string[] memory runJsInputs = new string[](4);
        runJsInputs[0] = "node";
        runJsInputs[1] = "ts-src/foundryMerkleBadCase1.js";
        runJsInputs[2] = packed1.toHexString();
        runJsInputs[3] = packed2.toHexString();

        bytes memory result = vm.ffi(runJsInputs);
        (bytes32 jsRoot, bytes32[][] memory jsProofs, bytes32[] memory jsLeaves) =
            abi.decode(result, (bytes32, bytes32[][], bytes32[]));

        bool verified = MerkleProof.verify(jsProofs[i], jsRoot, jsLeaves[i]);
        assertEq(verified, true);
    }

nodejs(ts-src/foundryMerkleBadCase.ts)で作ったRoot、Proof、Leafを持ってきて、OpenzeppelinのMerkleProofを行っています。

nodejs(ts-src/foundryMerkleBadCase.ts)側の主要なロジックは、下記の通りとなっています。

const hashleaves = leaves.map((x) =>
  ethers.utils.solidityKeccak256(
    ["address", "uint256"],
    [x.address, x.quantity]
  )
);

const tree = new MerkleTree(hashleaves, keccak256);
const proofs = hashleaves.map((leave) => tree.getHexProof(leave));

const root = tree.getHexRoot();

特筆すべきところはなく、至って普通の実装ですね。

では、この状態でテストしてみましょう!

npm run compile && forge test --ffi --match-contract merkleDifferentialTests --match-test testLogicMerkleProofBadCase1 -vvvvv  

エラーが返ってきたかと思います。

nodejsで実装したMerkleTreeに問題があることがわかりますね!

以前こちらでも述べたのですが、OpenZeppelinのMerkle Proof (v4.7.0) では、MerkleTreeを作る際に、必ずsortが必要になります。

そこで、このようにnodejs側の実装を修正します

// [bad] const tree = new MerkleTree(hashleaves, keccak256);
const tree = new MerkleTree(hashleaves, keccak256, { sort: true });

この問題点を修正したテストがtestLogicMerkleProofBadCaseFixed1(nodejs:foundryMerkleBadCaseFixed1)になります。

では、この状態でテストしてみましょう!

npm run compile && forge test --ffi --match-contract merkleDifferentialTests --match-test testLogicMerkleProofBadCaseFixed1 -vvvvv

次はテスト合格したと思います。

しかし、実はこのnodejs側の実装には問題があります。nodejs単体テストだとわからないんです。

次のテストに進みましょう!

DifferentialTestの良さがわかるテスト(testLogicMerkleProofBadCase2)

先ほどまでは、nodejsで作ったMerkleTreeの引数を使って検証してました。

今度は、実際のコントラクトを想定して、MerkleLeafをコントラクト側で用意します。

先ほどのテストから追記変更したのは下記の部分になります。

bytes32 leaf = keccak256(abi.encodePacked(addresses[i], quantities[i]));
bool verified = MerkleProof.verify(jsProofs[i], jsRoot, leaf);

では、早速テストしてみましょう!

npm run compile && forge test --ffi --match-contract merkleDifferentialTests --match-test testLogicMerkleProofBadCase2 -vvvvv 

すると、エラーが発生することがわかります。再度、nodejsをチェックしてみましょう!

すると、コントラクト側でuint8で実装していた"quantity"がuint256にて計算されています。

// bad point: The type of "quantity" has been changed from uint256 to uint8.
// [bad]const hashleaves = leaves.map((x) =>
//  ethers.utils.solidityKeccak256(["address", "uint256"], [x.address, x.quantity])
//);

const hashleaves = leaves.map((x) =>
  ethers.utils.solidityKeccak256(["address", "uint8"], [x.address, x.quantity])
);

この問題点を修正したテストがtestLogicMerkleProofBadCaseFixed2(nodejs:foundryMerkleBadCaseFixed2)になります。

では、この状態でテストしてみましょう!

npm run compile && forge test --ffi --match-contract merkleDifferentialTests --match-test testLogicMerkleProofBadCaseFiexd2 -vvvvv

今度は合格できたかと思います。そしてさらに素晴らしいのがテストのスピード

この検証のテスト時間は1分を切っています。

DifferentialTestすごいと思いませんか?hardhatもいずれこのようなテストが実装されると思いますが、スピードでは叶わないのではないでしょうか。ぜひFoundryを導入してみてください。そして、色々教えてくださいね!

-foundry