💹

ガス代削減のためマークルツリーで賢くエアドロップする

2022/06/17に公開

こんにちは。JPYC研究開発チーム所属のretocroomanです。

今回はJIP(JPYC incentive program)というJPYCの流動性に貢献した方へエアドロップする際に用いたコントラクトを紹介します。なお、マークルプルーフをご存知でない方は先にマークルプルーフの解説記事をご確認ください。タイトルにもあるとおり、マークルプルーフのライブラリを使用してエアドロップをしていきます。なお、今回紹介するコントラクトはJPYC開発コミュニティに所属するterrier-loverさんに作成していただいたものです!

前提知識

Solidity(前提知識集)
AccessControl.solのコード解説
MerkleProofについて

SimpleMerkleDistributer.solの目的

普通にエアドロップしようと思ったとき、まずエアドロップの配布先のアドレスリストを全てコントラクトに保存して、claimする時にそれらと照らし合わせる、といった方法が思いつくのではないでしょうか。しかし、対象のアドレスが何千とかになる場合、ガス代がもの凄くかかりそうです(ストレージへの書き込みは非常に高価です)…しかしSimpleMerkleDistributer.solではエアドロップの対象のアドレスを全て持つ必要はありません、それどころか基本的にはbytes32のマークルルートさえ持っていれば大丈夫です。

これはマークルツリーのアルゴリズムのおかげなのですが、マークルツリーについて軽く説明すると、検証したいデータ群のハッシュ値を一列に並べて、隣同士を結合させてハッシュ化、さらに隣同士を結合してハッシュ化としていき最後の一つになるまで繰り返します。そして最後に残ったハッシュ値がマークルルート、といった具合です。ちなみに最初の一列に並べられているハッシュ値をリーフノードと呼びます。重要なのはこのリーフノードの一つでも違えばマークルルートも変わってくるということです。これによりデータの検証ができます。データが正しければマークルルートが一致するはずです。また、隣同士を結合していくので列の並べ方も一意になります。

実際にデータを検証する際はマークルプルーフが必要になってきます。マークルプルーフは全てのリーフノードとリーフノードの順番さえ分かれば計算できます。マークルプルーフは検証するデータがマークルルートに到達するまでに結合されることになるハッシュ値のリストだからです。ハッシュ値のリストは検証するデータと結合される順番になっています。

マークルツリーに関して簡単に各用語をまとめますとこのようになります。

マークルツリー : データの要約を格納するツリー状のデータ構造。
マークルルート : 隣同士で結合させハッシュ化を繰り返して最後に残ったハッシュ値。
リーフノード:検証するデータをハッシュ化したもの。マークルルートの計算には全てのリーフノードが必要。
マークルプルーフ : マークルルートを再度計算する際に必要なハッシュ値のリスト。検証するデータのハッシュ値と結合されていく。

SimpleMerkleDistributer.solのコード解説

Reward_distributerの全体像

解説するコードのリンク
SimpleMerkleDistributer.sol(terrier-loverさん)
https://github.com/terrier-lover/rewards_distributer/blob/main/hardhat/contracts/SimpleMerkleDistributer.sol
継承しているコントラクトのコードのリンク
AbstractMerkleDistributer.sol(terrier-loverさん)
https://github.com/terrier-lover/rewards_distributer/blob/main/hardhat/contracts/AbstractMerkleDistributer.sol
OpenZepplinのライブラリのリンク
https://github.com/OpenZeppelin/openzeppelin-contracts

SimpleMerkleDistributerが継承しているコントラクトの解説

contract SimpleMerkleDistributer is AbstractMerkleDistributer {

継承しているコントラクトはabstractコントラクトのAbstractMerkleDistributerコントラクトだけのようです。このabstractコントラクトとは継承されることが前提のモジュールのようなコントラクトです。

abstract contract AbstractMerkleDistributer is
    AccessControlEnumerableUpgradeable,
    ReentrancyGuardUpgradeable
{

AbstractMerkleDistributerコントラクトAccessControlEnumerableUpgradeableコントラクトReentrancyGuardUpgradeableコントラクトを継承していますね。AccessControlEnumerableUpgradeableコントラクトの方は前提知識の方で解説記事のリンクを貼っているのでそちらを参考にしてください。複数の権限を管理できるライブラリです。ReentrancyGuardUpgradeableコントラクトの方は、Reentrancy Attackを防ぐライブラリです。Reentrancy Attackについて詳しく知りたい方はこちらのOpenZeppelinの記事が役に立つと思います。Reentrancy Attackとは端的にいうとfallback関数で状態変数の不一致を誘発させてコントラクトをハッキングする方法です。

AccessControlEnumerableUpgradeableコントラクトAbstractMerkleDistributerコントラクトでonlyAdminOrModeratorRoles修飾子(22行目)を宣言するときに使われています。DEFAULT_ADMIN_ROLEかMODERATOR_ROLEの権限を持っているアカウントからのアクセスのみ許可する修飾子ですね。DEFAULT_ADMIN_ROLEはAbstractMerkleDistributerコントラクトのinitialize関数内でinitialize関数の実行者に設定していますが、MODERATOR_ROLEのアカウントはinitialize関数では設定されていないので後からDEFAULT_ADMIN_ROLEの権限で設定する必要がありそうです。

    modifier onlyAdminOrModeratorRoles() {
        require(
            hasRole(DEFAULT_ADMIN_ROLE, _msgSender()) ||
                hasRole(MODERATOR_ROLE, _msgSender()),
            "Not admin or moderator"
        );
        _;
    }

ReentrancyGuardUpgradeableコントラクトSimpleMerkleDistributerコントラクトのclaim関数(35行目)でライブラリの修飾子がそのまま使われています。こちらはこれをつけることでハッキングから守っているという認識で大丈夫でしょう。

    function claim(
        address recipient,
        uint256 amount,
        string memory uniqueKey,
        bytes32[] calldata proof
    ) external override nonReentrant {

SimpleMerkleDistributerの状態変数と修飾子の解説

    bytes32 merkleRoot; // マークルルート
    // ユーザーがclaimするとclaimしたuniquKeyに対してtrueになる
    // uniquKeyは日付の文字列
    // エアドロップが複数回行われることを想定している
    mapping(address => mapping(string => bool)) hasClaimed;

この他にもSimpleMerkleDistributerコントラクトが継承しているコントラクトの状態変数と修飾子もあるので注意する。

initialize関数(31行目)の解説

一番最初はinitialize関数です。これはUpgradeableなコントラクトにとっては特別な意味があり、Upgradeableなコントラクトにおけるconstructor関数に当たるものと思ってもいいでしょう。constructo関数同様にpublicで宣言して大丈夫です。

    function initialize(address initialToken, bytes32 initialMerkleRoot)
        public
        initializer // 関数を一度しか呼べなくする修飾子
    {
        // DEFAULT_ADMIN_ROLEのアカウントの設定をしている
        AbstractMerkleDistributer.initialize();

        // トークンの設定
        // tokenの状態変数はAbstractMerkleDistibuterコントラクトで宣言されている
        token = IERC20Metadata(initialToken);
        // マークルルートの設定
        merkleRoot = initialMerkleRoot;
    }

initializer修飾子がでてきましたが、これはUpgradeabilityでよく使われる修飾子でinitialize関数を一度しか呼べなくするものです。Initializableコントラクトで定義されていますが、その他にもinitialize関数実行中にしか呼べなくするというonlyInitializing修飾子も用意されています。このコントラクトはAccessControlEnumerableUpgradeableコントラクトやReentrancyGuardUpgradeableコントラクトなどUpgradeableなOpneZeppelinのコントラクトには全て継承されています。

setMerkleRoot関数(31行目)、setHasClaimedPerRecipientAndUniqueKey関数(28行目)の解説

    function setMerkleRoot(bytes32 newMerkleRoot)
        public
        // DEFAULT_ADMIN_ROLEかMODERATOR_ROLEの権限のアカウントのみ呼べる
        onlyAdminOrModeratorRoles
    {
        // 新しいマークルルートの設定、つまりエアドロップの情報を更新させた時など
        merkleRoot = newMerkleRoot;
    }
    function setHasClaimedPerRecipientAndUniqueKey(
        address recipient, 
        string memory uniqueKey,
        bool newHasClaimed
    )
        public
        // DEFAULT_ADMIN_ROLEかMODERATOR_ROLEの権限のアカウントのみ呼べる
        onlyAdminOrModeratorRoles
    {
        // ユーザーがclaimしたかどうかの情報を変える
        hasClaimed[recipient][uniqueKey] = newHasClaimed;
    }

どちらも管理者権限でマークルルートを更新したり、ユーザーの状態変数を変えたりと管理者がエアドロップの調整をする関数ですね。

setMerkleRoot関数(31行目)の解説

    function claim(
        address recipient, // トークンを受け取るアドレス、この関数を呼び出すアドレスと同一
        uint256 amount, // エアドロップされるトークン量
        string memory uniqueKey, // 日付の文字列
        bytes32[] calldata proof // マークルプルーフ
    // Abstractで型だけ宣言していたのでそれをoverrideしている
    // nonReentrantはReentrancy Attackを防ぐ修飾子
    ) external override nonReentrant {
        // この関数を呼び出すアドレスがトークンを受け取るアドレスと同一か、もしくは管理者か確認する
        require(
            (
                _msgSender() == recipient
                ||  hasRole(DEFAULT_ADMIN_ROLE, _msgSender())
                ||  hasRole(MODERATOR_ROLE, _msgSender())
            ),
            "Cannot claim reward of others."
        );

        // getIsClaimable関数を呼び出し、claim情報が正しいか調べる
        (bool isClaimable, string memory message) = getIsClaimable(
            recipient,
            amount,
            uniqueKey,
            proof
        );
        // claim情報が正しくないならエラー文とともにrevertされる
        require(isClaimable, message);

        // ユーザーがclaimしたことを記録する
        // 実際にエアドロップする前に状態変数を更新することでReentrancy Attackの予防にもなる
        // revertされればこの更新もなかったことになるので問題ない
        hasClaimed[recipient][uniqueKey] = true;
        // エアドロップする量のトークンを送信して、正しくトークンを送信できたか確認する
        require(token.transfer(recipient, amount), "Transfer failed");

        emit Claim(recipient, amount, uniqueKey);
    }

こうしてみてみるとこの関数では特に複雑なことはしていませんね。getIsClaimable関数がこのコントラクトのキーとなります。それではgetIsClaimable関数をみてみましょう。

    function getIsClaimable(
        address recipient, // トークンを受け取るアドレス
        uint256 amount, // エアドロップされるトークン量
        string memory uniqueKey, // 日付の文字列
        bytes32[] calldata proof // マークルプルーフ
    ) public view returns (bool, string memory) {
        // まず初めに、ユーザーが既にclaim済でないか確認する
        if (hasClaimed[recipient][uniqueKey]) {
            return (false, "Recipient already claimed");
        }

        // 検証するリーフノードをclaim情報から生成する
        bytes32 leaf = keccak256(abi.encodePacked(recipient, amount, uniqueKey));
        
        // OpenZeppelinのマークルプルーフのライブラリを使用して検証する
         // マークルプルーフとマークルルートと検証するリーフノードを渡すとbool値が返される
        bool isValidLeaf = MerkleProofUpgradeable.verify(
            proof, // マークルプルーフ
            merkleRoot, // マークルルート
            leaf // 検証するリーフノード
        ); 
        
        // もしclaim情報が正しくなければメッセージとともにfalseを返す
        // claim関数から呼ばれた場合はこのメッセージはrevertのエラー文になる。
        if (!isValidLeaf) {
            return (false, "Not a valid leaf");
        }

        // もしclaim情報が正しければメッセージとともにtrueを返す
        return (true, "Reward is claimable");
    }

ちなみにこの関数はpublic関数となっておりフロントから確認できるようにもなっています。OpenZeppelinのマークルプルーフのライブラリであるMerkleProofUpgradeableコントラクトにはverify関数があり、マークルプルーフとマークルルートとリーフノードを渡すとマークルツリーのアルゴリズムを使って引数が正しいか検証してくれます。マークルツリーとマークルプルーフとリーフノードはどれもどれか二つが決まればもう一つが決まるという関係になっています。

claimAllDiposits関数(91行目)の解説

     // DEFAULT_ADMIN_ROLEかMODERATOR_ROLEの権限のアカウントのみ呼べる
    function claimAllDiposits() public onlyAdminOrModeratorRoles {
        // このコントラクトが現時点で持っている全てのトークン量
        uint256 currentBalance = token.balanceOf(address(this));
        // トークンを保有していなければrevertする
        if (currentBalance <= 0) {
            revert('No available balance');
        }

        // トークンを送信して、正しく送信できたか確認する
        require(
            token.transfer(_msgSender(), currentBalance), 
            "Transfer failed"
        );
    }

これはエアドロップ用にこのコントラクトに預けられていたトークンを管理者権限で全て引き出す関数ですね。

まとめ

実際にエアドロップする際にデプロイするコントラクトをみてきました。いかがだったでしょうか。個人的にはフロント側や複数回エアドロップが行われること、管理者権限等を意識されてコントラクトが作成されていて、とても勉強になりました。

ちなみにこちらにはVersioningMerkleDistributerコントラクトも用意されていて、SimpleMerkleDistributerコントラクトとほとんど仕様は同じですがマークルルートを設定するごとにバージョンが更新されていて過去のマークルルートも保存されていくという点が異なります。引き出しはできませんが過去のマークルルートの検証もできます。よりユーザー目線でしょうか。

ここまでお読みいただきありがとうございます。
それでは、ぜひエアドロップしたいと思った際はこのようにマークルプルーフのライブラリを使ってエアドロップしてみましょう!

ちなみに今回紹介したコントラクトはMITライセンスです!

日本初のブロックチェーン技術(ERC20)を活用した日本円ステーブルコインJPYCはこちらから購入できます!
JPYC社はブロックチェーンエンジニアを募集中です!こちらからご応募お願いします!(タイミングにより募集を行なっていない場合があります)
また、ラボ形式でブロックチェーンに関する講義をしているJPYC開発コミュニティにも是非ご参加ください!

Discussion