😺

複数の Multi Sig Wallet の出金申請を管理するスマートコントラクトを Solidity で作る

2022/05/11に公開

はじめに

こんにちは、Anman です。Scalably 株式会社、 JPYC 株式会社、Everscale Japan 共催の日本円ハッカソンをきっかけに Solidity 等のブロックチェーン関連技術に触れ、@Yuuuu_ くんと一緒に JPYC を使ったライブ配信連動型投げ銭サービス「推しサポ」を開発しました。
光栄にも賞をいただけましたので、「推しサポ」の技術的な部分に関する記事を Zenn にまとめています。
前回の記事はこちら
ソースコードはこちら

今回は「推しサポ」の出金承認部分に関して解説していきたいと思います。

前提

「推しサポ」のプールから持ち主の Wallet への出金には、以下の流れをとります。

  1. プールの持ち主が出金額を決めて、出金の申請を行います
  2. 一部の管理者権限をもった承認者が、出金の申請を手動で承認もしくは拒否をし、許可した場合には申請された出金を手動で実行します

「どうしてこんなに面倒くさい方式を取ったのか」、と疑問に思われる方が少なくないかと思いますが、これは主に AML (アンチ マネー ロンダリング) の観点にて実装した機能で、あくまでも最低限の出金制限を設けたものと位置付けています。
従って、正式なリリースを行う場合には、よりしっかりとした管理システムや体制を敷く必要性があるかと思います。(本稿の主題と逸れるので、あまり深掘りはしませんが、[暗号資産 AML] などと検索すれば色々と情報にヒットするかと思います)

実装

Multi Sig Wallet

さて、早速どういった実装なのかを解説していきたいとは思うのですが、この実装自体は一般的に Multi Sig Wallet と呼ばれるものの実装を参考にしています (といいますか、ほぼそのものです)。
Multi Sig Wallet とは、Wallet からの送金の際に複数の秘密鍵 (承認者) が必要となる Wallet です。
例えば、自分の子供に貯金用の Wallet を与えたいけど、両方の親の承認がないと出金が行えないようにしたい、みたいな機能が必要な際に Multi Sig Wallet の仕組みを活用することが考えられます。

Multi Sig Wallet との違い

今回、一般的な Multi Sig Wallet の実装と違う点があるとすれば、一般的な Multi Sig Wallet は Wallet のスマートコントラスト (「推しサポ」でいう Pool のスマートコントラスト) そのものに出金に関わる処理が実装されているのですが、今回はこれを出金申請管理用スマートコントラストとして切り出しているところだと思います。
なぜ切り出したのかと言いますと、承認を行う側 (スマートコントラスト内では owner と表記しています) のインターフェースを簡単したかったからです。
Pool のスマートコントラスト側に出金の実装を寄せてしまうと、承認を行う人 (owner) はユーザーの各 Pool に出金の申請が新規で入ったか監視する必要性があります。

これはちょっとサービスとしてスケーラビリティが無いと判断して、以下のように出金の申請が集まる出金申請管理用スマートコントラストとして切り出しました。

こうすることによって、承認を行う側 (owner) は 1 つのスマートコントラストにある情報を参照するだけで、サービス全体から集まっている出金申請を管理できるので、申請の管理が便利になるというのがメリットです。
また、こうやってユーザー側で直接操作しない機能を削ることで、(現状はユーザーが負担予定の) Pool 作成用のガス代を削減できる、といったメリットもあるかと思います。

Solidity のコード解説

プール側の出金申請関数

それでは、機能のイメージがついたところで、実際に Solidity のコードをみてどのように実装しているのか見てみましょう。
以下の Pool スマートコントラストに実装した関数では、送金額を引数として、指定した送金額を Pool の持ち主に送金する新規の申請を、出金申請管理用スマートコントラストへ登録しています。

ThrowMoneyPool.sol
    function submitWithdrawRequest(uint _jpycAmount) public onlyOwner {
        require(jpyc.balanceOf(address(this)) >= _jpycAmount, "Insuffucient balance on contract");

        uint __txId = withdrawConfirmationAuthority.submitTransaction(address(this), owner, _jpycAmount);
        jpyc.approve(address(this), _jpycAmount);
        jpyc.approve(address(withdrawConfirmationAuthority), _jpycAmount);

        emit withdrawRequestId(__txId);
    }

出金申請管理用スマートコントラスト側の出金申請関数

出金申請管理用スマートコントラストでは、申請に関連するデータを格納する構造体 (transaction) と、その構造体と申請番号 (txIndex) と紐づけるマッピング (transactions) を定義し、マッピングに新規の申請を追加しています。
また、ここで各アドレスが申請した送金のリクエストの内、未処理のものを参照できるように requesterTransactionQue 配列に追加しています。
これは、ユーザーが自らの申請済みの送金リクエストを確認して、例えばサービス側に「この送金申請は実行されていないのですが、どのようになっていますか?」といった問い合わせがしやすいように配慮しています。

WithdrawConfirmationAuthority.sol
    function submitTransaction(
        address _from,
        address _to,
        uint _jpycAmount
    ) public returns (uint) {
        uint txIndex = transactions.length;

        transactions.push(
            Transaction({
                from: _from,
                to: _to,
                jpycAmount: _jpycAmount,
                executed: false,
                numConfirmations: 0
            })
        );

        requesterTransactionQue[_to].push(txIndex);

        emit SubmitTransaction(msg.sender, txIndex, _to, _jpycAmount);

        return txIndex;
    }

申請に関連するデータを格納する構造体 (transaction) には、以下の情報含んでいます。

  • from: 送金元の Pool アドレス
  • to: 送金先の Wallet アドレス
  • jpycAmount: 送金額
  • executed: 送金の実行の有無 (デフォルトでは false)
  • numConfirmations: 送金申請を承認した秘密鍵の数 (デフォルトでは 0)

上記の構造体のデータを参照して、既に実行されている送金申請に対しては追加で承認を行えないように executed の bool 値から defer する、必要な numConfirmations が満たされていなかったら出金処理が行えないようにするといった制約をかけることができます。

出金承認関数

そして、出金申請管理用スマートコントラストでは承認関数では、出金申請を既に行っていないアドレスか確認をして、問題がなければ numConfirmations をインクリメントします。
また、ここで申請番号ごとにどの owner アドレスが承認済みかを isConfirmed 配列で記録します。

WithdrawConfirmationAuthority.sol
    function confirmTransaction(uint _txIndex)
        public
        onlyOwner
        txExists(_txIndex)
        notExecuted(_txIndex)
        notConfirmed(_txIndex)
    {
        Transaction storage transaction = transactions[_txIndex];
        transaction.numConfirmations += 1;
        isConfirmed[_txIndex][msg.sender] = true;

        emit ConfirmTransaction(msg.sender, _txIndex);
    }

出金実行関数

最後に、executeTransaction で出金を実際に実行します。
ここで、transaction.executed を true にして、実行済みにして、requesterTransactionQue から申請番号の記録を削除します。

WithdrawConfirmationAuthority.sol
    function executeTransaction(uint _txIndex)
        public
        onlyOwner
        txExists(_txIndex)
        notExecuted(_txIndex)
    {
        Transaction storage transaction = transactions[_txIndex];

        require(
            transaction.numConfirmations >= numConfirmationsRequired,
            "cannot execute tx"
        );

        transaction.executed = true;

        bool success = jpyc.transferFrom(transaction.from, transaction.to, transaction.jpycAmount);

        require(success, "tx failed");

        // Delete tx index from que
        for (uint i = _txIndex; i < requesterTransactionQue[transaction.to].length - 1; i++) {
            requesterTransactionQue[transaction.to][i] = requesterTransactionQue[transaction.to][i + 1];
        }
        requesterTransactionQue[transaction.to].pop();

        emit ExecuteTransaction(msg.sender, _txIndex);
    }

以上を ethers.js で呼び出せるように UI を作成すれば完成です!(出金申請は Chrome Extension 側にて実装したものの、承認者用のページは未作成なのでこちらでは割愛します)

次回は OBS Studio 側で表示させる配信者用ページの解説です!

Discussion