スマートコントラクトのデザインパターンについて整理してみた

本投稿は TECOTEC Advent Calendar 2023 の22日目の記事です。

こんにちは、次世代デジタル基盤開発事業部の三島です。
普段は、スマートコントラクトの開発やバックエンドの開発に携わっています。

はじめに

2023の1月にテコテックに入社して以降、様々なプロジェクトでSolidityを使用したスマートコントラクトを書いてきました。
本記事では、この一年で実際にコントラクトを書いて気付いたこと、OSSのコントラクトを読んで学んだことをベースに、スマートコントラクトのデザインパターンについて整理してみました。

目次

コントラクトの設計

プロキシパターン

プロキシパターンは、データを格納するプロキシコントラクトとビジネスロジックを含む実装コントラクトを分ける手法です。
そうすることで、実装コントラクトのアップグレード後でも、プロキシコントラクトのデータを引き続き利用することができます。

プロキシパターンの仕組みについて詳しくみていきましょう。
プロキシパターンでは、プロキシコントラクトから実装コントラクトのメソッドをdelegatecall*します。
*delegatecall は、呼び出し元コントラクトのコンテキストで、呼び出し先コントラクトの関数を実行する特殊なメソッドです。
具体的には、実装コントラクトの関数を呼び出して、プロキシコントラクトのデータを参照・更新します。

図で表すと以下のようになります。

プロキシコントラクトのfallback*関数は、deletecallを実行して実装コントラクトの関数を実行します。
* fallback関数は、コントラクトにコール対象の関数が定義されていない場合に実行されます。

また、プロキシコントラクトで、実装コントラクトのアドレスと管理者のアドレスを保持します。
管理者のアドレスは、実装コントラクトを更新できるアカウントのアドレスです。

例えば、送信者がプロキシコントラクトから実装コントラクトの関数Aを実行するフローは以下になります。

  1. 送信者がプロキシコントラクトに対して、実装コントラクトの関数Aをコールする
  2. プロキシコントラクトには、関数Aがないため、fallbackがコールされる
  3. fallbackの中で、delegatecall がコールされ、プロキシコントラクトのコンテキストで、実装コントラクトのメソッドAをコールする

ポイントとしては、実装コントラクトのストレージにはデータが格納されません。

実装コントラクトのアップグレードについて

実装コントラクトのアップグレードは、プロキシコントラクトの実装コントラクトのアドレスを更新することで実現可能です。
プロキシパターンの実装方法として、アップグレードのメソッドをプロキシコントラクトで持つTransparent Parentプロキシと実装コントラクトで持つUUPS プロキシがあります。

今回は、Transparent ParentプロキシとUUPS プロキシについての詳しい説明は割愛します。

ファクトリーパターン

ファクトリーパターンは、コントラクトのデプロイの責務を担うファクトリーコントラクトを用意します。

通常コントラクトをデプロイするときは、外部アカウント(EOA)からデプロイします。
ファクトリーパターンでは、ファクトリーコントラクトの関数を実行して、コントラクトをデプロイします。

ファクトリーは、ウォレットアカウント(ERC4337)やトークンバウンドアカウント(ERC6551)でも利用されています。
利点として、以下が挙げられます。

  • コントラクトを新たにデプロイするときに、ファクトリーコントラクトの関数を実行するだけでよい (creation codeの指定が不要)
  • create2*と組み合わせることで、ファクトリーコントラクトからデプロイしたコントラクトの参照が可能になる
    → 同一のsalt、create code、コンストラクター引数の組み合わせでデプロイされたコントラクトが既に存在する場合は、そのコントラクトのアドレスを返す。まだデプロイされていない場合は、新規デプロイするという制御が可能になる
    * create2とは、デプロイ前にコントラクトのアドレスが取得可能なデプロイ関数です。

AccountとAccountFactoryのイメージ図

セキュリティ

アクセス・リストリクション

アクセス・リストリクションは、modifierを利用して関数の実行可否を制限する手法です。

例えば、etherの引き落とし機能を持つWalletコントラクトがあるとします。
その際に、引き落としが可能なアカウントをコントラクトオーナーのみに制限する必要があります。

以下が実装の例です。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract Wallet {
    address payable owner;

    constructor() {
        owner = payable(msg.sender);
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    receive() external payable {}

    function withdraw() public onlyOwner {
        owner.transfer(address(this).balance);
    }
}

まず、onlyOwner のmodifierを定義しています。
引き落としをおこなう withdraw 関数に onlyOwner を追加することで、コントラクトオーナーのみ withdraw の実行が可能になります。

実行制限のバリデーションをmodifierとして定義することで、再利用性や可読性が高まります。

リエントランシー・ガード

リエントランシー・ガードについて見る前に、まずリエントランシー攻撃についてみていきましょう。

例として、リエントランシー攻撃に対して脆弱性のあるSharedWalletコントラクトを用意しました。
以下の機能を持ちます。

  • 各アカウントがetherを預けることが可能
  • 各アカウントが自身の預けた量と同額のetherを引き落とすことが可能
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract SharedWallet {
    mapping(address => uint) public balances;

    fallback() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        uint balance = balances[msg.sender];

        require(balance >= 0);

        payable(msg.sender).call{value: balance}("");

        balances[msg.sender] = 0;
    }
}

対してリエントランシー攻撃をおこなうコントラクトです。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "./SharedWallet.sol"

contract Attacker {
    address payable wallet;

    constructor(address payable wallet_) {
        wallet = wallet_;
    }

    mapping(address => uint) public balances;

    fallback() external payable {
        if (address(wallet).balance >= 1 ether) {
            ShareWallet(wallet).withdraw();
        }
    }

    function attack() public payable {
        wallet.call{value: 1 ether}("");
        ShareWallet(wallet).withdraw();
    }
}

ステート変数walletで、SharedWalletコントラクトのアドレスを保持しています。
fallback関数は、Attackerコントラクトに、etherが送金されたときに実行されます。

実際に攻撃の手順を見ていきましょう

  1. Attackerコントラクトのattackをvalue: 1 etherを指定してコール
  2. Attackerコントラクトのwallet.call{value: 1 ether}("")がコールされ、AttackerコントラクトがSharedWalletコントラクトに1 ether を預け入れる
  3. AttackerコントラクトShareWallet(wallet).withdraw()がコールされる
  4. SharedWalletコントラクトのwithdrawがコールされる
  5. SharedWalletコントラクトのpayable(msg.sender).call{value: balance}("")がコールされ、Attackerコントラクトに1 etherが送金される
  6. Attackerコントラクトのfallbackがコールされ、再度SharedWalletコントラクトのwithdrawがコールされる
  7. 5 - 6 が繰り返し実行され、SharedWalletコントラクトのetherの残高が1 etherより少なったら処理が終了する

上記の手順により、Attackerコントラクトは、1 etherしか預けていないのにかかわらず、それ以上の額のetherを引き落とすことが可能となっています。

以下がリエントランシー・ガードの対応をしたコントラクトです。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract ShareWallet {
    mapping(address => uint) public balances;

    bool locked;

    modifier noReentrant() {
        require(!locked, "no reentrant");

        locked = true;
        _;
        locked = false;
    }

    fallback() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() public noReentrant {
        uint balance = balances[msg.sender];

        require(balance >= 0);

        payable(msg.sender).call{value: balance}("");

        balances[msg.sender] = 0;
    }
}

withdrawnoReentrantのmodifierを追加しました。
noReentrantでは、関数本体の呼び出し前に、lockedfalseであることをチェックし、lockedtrueに更新します。
関数本体実行後に、lockedfalseに更新して、処理を終了します。

Attackerコントラクトのattackが実行されて、繰り返しwithdrawが実行されたときに、今度はlockedtrueのため、require(!locked, "no reentrant")でエラーとなります。

リエントランシー・ガードをすることで、コントラクトのメソッドが外部コントラクトから繰り返し実行されることを防ぐことができます。

アップデート・インタラクション

アップデート・インタラクションとは、コントラクトのステート変数を更新してから、外部コントラクトの関数をコールする方法です。

以下が先ほどの脆弱性のあるSharedWalletコントラクトのwithdraw関数です。

    function withdraw() public {
        uint balance = balances[msg.sender];

        require(balance >= 0);

        payable(msg.sender).call{value: balance}("");

        balances[msg.sender] = 0;
    }

この関数では、etherの送金後に、balancesを更新しています。
そのため、Attackerコントラクトから再度withdrawがコールされたときに、balancesの残高が更新されていません。
それによって、require(balance >= 0);のチェックがエラーにならず、再度etherが送金されてしまいます。

なので、balancesを更新した後に、etherを送金するように順序を変更する必要があります。
以下が修正したwithdraw関数です。

    function withdraw() public {
        uint balance = balances[msg.sender];

        require(balance >= 0);

        balances[msg.sender] = 0;

        payable(msg.sender).call{value: balance}("");
    }

修正後は、Attackerコントラクトから再度withdrawが呼ばれたときに、balancesの残高が更新されているため、require(balance >= 0);のチェックでエラーとなります。
通常のWebサービスでは、外部処理が成功した後に、DBのテーブルを更新するのが一般的だと思いますが、スマートコントラクトでは先にステート変数を更新してから、外部コントラクトの関数をコールすることが大事です。

関数のデザイン

メモリ配列ビルディング

メモリ配列ビルディングは、関数の中で配列の生成・代入をおこなう方法です。

前提として、Solidityの配列は、配列の長さの変更の可否に応じて、動的配列と静的配列に分けることができます。
また、格納場所に応じて、ステート変数とローカル変数*に分けることができます。
* ステート変数は、ブロックチェーンに恒久的に保存される変数で、コントラクト内で参照可能なっています。対して、ローカル変数は実行中の関数内でのみ有効な変数です。

Solidityで配列を扱うときの注意点として、動的配列をローカル変数として使用できないという点があります。
図示すると、以下になります。

動的配列 静的配列
ステート変数
ローカル変数

では、実際にメモリ配列ビルディングが有効な例を見ていきましょう。
以下のStudentStorageコントラクトで、studentsByScore関数を実行して、scoreが一致するStudentの配列を返すとします。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract StudentStorage {
    struct Student {
        string name;
        uint score;
    }

   Student[] students;

    constructor() {
        students.push(Student("Sato", 70));
        students.push(Student("Yamada", 80));
        students.push(Student("Takahashi", 80));
        students.push(Student("Suzuki", 90));
    }

    function studentsByScore(uint score) public view returns (Student[] memory) {
        // `students`の中から、`score`が一致するStudentの配列を返す
        return ...;
    }
}

他のプログラミング言語の場合ですと、まず動的配列を生成してからstudentsをイテレートして、score一致する要素を追加していくと思います。 (もしくは、filter等の関数を使用する)

ただ、Solidityでは、動的配列のローカル変数を宣言することができません。また、配列を操作するためのfilterといった関数が提供されていません。

そのため、以下のように最初のfor文で配列の要素数を取得した後に、その要素数で配列を生成し、2度目のfor文で配列の各要素を代入する必要があります。

    function studentsByScore(uint score) public view returns (Student[] memory) {
        uint counter = 0;
        for (uint i = 0; i < students.length; i++) {
            if (students[i].score == score) counter++;
        }

        Student[] memory targetStudents = new Student[](counter);
        uint index = 0;
        for (uint i = 0; i < students.length; i++) {
            if (students[i].score == score) {
                targetStudents[index++] = students[i];
            }
        }

        return targetStudents;
    }

処理が冗長になりましたが、関数の実行前に返り値の配列の要素数が不定の場合は、要素数の取得後に、配列の生成をおこなう必要があります。

以上、スマートコントラクトのデザインパターンを6つ紹介しました。
今後もSolidityのアップデートに注目しつつ、スマートコントラクトの設計についての理解を深めていきたいたいと思います。

最後まで読んでいただきありがとうございました!

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp