[Solidityのはじめ方] Part28: アップグレード可能なコントラクト(proxyパターン)

Solidity

🚀 スマートコントラクトをアップグレード? なぜ必要?

ブロックチェーンの大きな特徴の一つに「不変性(Immutability)」があります。一度デプロイされたスマートコントラクトのコードは、基本的には変更できません。これはセキュリティや信頼性の観点からは非常に重要ですが、一方で開発者にとっては大きな課題も生みます。

  • バグが見つかった場合、修正できない 🤔
  • 新しい機能を追加したい場合、一からデプロイし直す必要がある
  • ユーザーに新しいコントラクトアドレスへ移行してもらう手間がかかる

これらの問題を解決するために考えられたのが「アップグレード可能なコントラクト」の仕組みです。特に広く使われているのが「プロキシパターン」と呼ばれる設計です。✨

🤔 プロキシパターンとは? 基本的な仕組み

プロキシパターンは、ユーザーが直接やり取りするコントラクト(プロキシコントラクト)と、実際のロジック(機能)が書かれたコントラクト(実装コントラクト)を分離する考え方です。

ユーザーは常に同じアドレスのプロキシコントラクトを呼び出します。プロキシコントラクトは、受け取った指示(関数呼び出し)を、その時点で指定されている実装コントラクトに転送(delegatecall)します。

ポイント💡

  • プロキシコントラクト: ユーザーとの窓口。状態(データ)を保持し、実装コントラクトのアドレスを知っている。
  • 実装コントラクト: 実際の処理ロジックが書かれている。状態は持たない(プロキシコントラクトのストレージを利用)。
  • アップグレード: 新しいバージョンの実装コントラクトをデプロイし、プロキシコントラクトが参照する実装コントラクトのアドレスを新しいものに切り替えるだけ!ユーザーが使うアドレスは変わりません。

この仕組みにより、コントラクトのロジック部分だけを入れ替えることが可能になり、あたかもコントラクトがアップグレードされたかのように振る舞わせることができます。

📜 主なプロキシパターンの種類

プロキシパターンにはいくつか実装方法がありますが、主に以下の2つが有名です。特にOpenZeppelinなどのライブラリを使うことで、安全かつ比較的簡単に実装できます。

1. Transparent Proxy Pattern (トランスペアレント・プロキシ・パターン)

このパターンでは、プロキシコントラクトのアップグレードや管理機能(例: 実装コントラクトのアドレス変更)を呼び出せるのは、指定された管理者アドレス(Admin)のみです。一般ユーザーからの呼び出しと管理者からの呼び出しを区別するロジックがプロキシコントラクト内に含まれます。

  • メリット: ユーザーと管理者の権限分離が明確。
  • デメリット: プロキシコントラクトのデプロイコストが比較的高く、呼び出し時のガス代も少し余分にかかる可能性がある。管理ロジックがプロキシに組み込まれるため、やや複雑。

2. UUPS (Universal Upgradeable Proxy Standard) Proxy Pattern (EIP-1822)

UUPSパターンでは、アップグレードロジック自体をプロキシコントラクトではなく、実装コントラクト側に持たせます。プロキシコントラクトは非常にシンプルになり、単なる転送機能に特化します。アップグレードを実行する関数(通常 `upgradeTo` など)は実装コントラクト内にあり、それをプロキシ経由で呼び出します。

  • メリット: プロキシコントラクトがシンプルでデプロイコストが安い。Transparent Proxyに比べて呼び出し時のガス代もわずかに効率が良い場合がある。現在の主流になりつつある。
  • デメリット: アップグレードロジックを実装コントラクトに含める必要がある。もし新しい実装コントラクトでアップグレードロジックを書き忘れると、それ以降アップグレードできなくなるリスクがある(ライブラリを使えば通常は大丈夫)。

⚠️ アップグレード可能なコントラクトの注意点

プロキシパターンは強力ですが、いくつか注意すべき点があります。これらを理解しないまま使うと、深刻な問題を引き起こす可能性があります。

🚨 特に注意すべきこと

  • ストレージの衝突 (Storage Collision):

    プロキシコントラクトは `delegatecall` を使って実装コントラクトのロジックを実行します。これは、実装コントラクトがプロキシコントラクトのストレージ(状態変数)を直接読み書きすることを意味します。そのため、新しい実装コントラクトで状態変数の定義順序を変えたり、間に新しい変数を挿入したりすると、データが意図しない場所に書き込まれたり、読み出されたりしてしまいます(ストレージレイアウトの互換性を保つ必要があります)。

    対策: 既存の状態変数の順序や型を変更しない。新しい状態変数は必ず既存の変数の「後」に追加する。ライブラリが提供する継承ルールに従う。

  • コンストラクタの問題と初期化関数 (`initialize`):

    プロキシパターンでは、実装コントラクトの `constructor` はデプロイ時に一度しか呼ばれず、プロキシのストレージコンテキストでは実行されません。そのため、`constructor` で行っていた初期化処理は、代わりに `initialize` のような名前の公開関数(public)で行う必要があります。この `initialize` 関数は、プロキシを通じて一度だけ呼び出されるように制御する必要があります(さもないと誰でも再初期化できてしまう)。

    対策: OpenZeppelinの `Initializable` コントラクトを継承し、`initializer` 修飾子を使って `initialize` 関数が一度しか呼ばれないように保護する。

  • アップグレード権限の管理:

    誰がコントラクトをアップグレードできるのか? この権限管理は非常に重要です。もし秘密鍵が漏洩したり、悪意のある人物が権限を握ったりすると、コントラクトのロジックが勝手に変更され、資金が盗まれるなどの被害に繋がります。

    対策: マルチシグウォレットやDAO(分散型自律組織)による管理など、単一障害点を作らない方法で権限を管理する。

  • イベントの考慮:

    アップグレード前後でイベントの定義が変わると、オフチェーンのアプリケーションがコントラクトの状態を正しく追跡できなくなる可能性があります。

    対策: イベントの変更は慎重に行い、互換性を考慮する。

🛠️ 実装例 (OpenZeppelin UUPS Proxy) の雰囲気

実際にコードを書く際は、OpenZeppelinのライブラリを使うのが一般的です。以下はUUPSパターンを使ったアップグレード可能なコントラクトの非常に簡単な例の雰囲気です。(完全なコードではなく、構造を示すものです)

実装コントラクト (V1)

// contracts/MyContractV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; // 権限管理用

// Initializable: initialize関数用
// UUPSUpgradeable: UUPSアップグレードロジック用
// OwnableUpgradeable: アップグレード権限管理用
contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    // UUPSではコンストラクタは使わず、initializeを使うため空にする
    constructor() {
        _disableInitializers();
    }

    // コンストラクタの代わりに初期化処理を行う関数
    function initialize(uint256 _initialValue) public initializer {
        // 親コントラクトの初期化も呼び出す
        __Ownable_init(msg.sender); // deployerをownerに設定
        __UUPSUpgradeable_init();

        value = _initialValue;
    }

    // 値を設定する関数
    function setValue(uint256 _newValue) public onlyOwner {
        value = _newValue;
    }

    // --- UUPSに必要な関数 ---
    // アップグレード権限をownerに制限
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}

    // --- 将来のV2で追加される関数(例) ---
    // function getDoubleValue() public view returns (uint256) {
    //     revert("Not implemented in V1");
    // }
}

デプロイとアップグレードの流れ (イメージ)

  1. まず `MyContractV1` をデプロイします。
  2. 次に `UUPSUpgradeable` プロキシコントラクトをデプロイし、`MyContractV1` のアドレスを参照するように設定します。このプロキシコントラクトのアドレスが、ユーザーが実際に使うアドレスになります。
  3. プロキシコントラクトを通じて `initialize` 関数を呼び出し、初期値を設定します。
  4. 将来、機能を追加した `MyContractV2` を作成し、デプロイします。
  5. プロキシコントラクトの所有者(Owner)が、プロキシコントラクトを通じて `upgradeTo` 関数(`MyContractV1` 内の `_authorizeUpgrade` で権限チェックされる)を呼び出し、実装コントラクトのアドレスを `MyContractV2` のものに切り替えます。

このプロセスは、HardhatやTruffleのような開発フレームワークとOpenZeppelin Upgrades Pluginsを使うと、より簡単かつ安全に行うことができます。

Hardhat Truffle OpenZeppelin Upgrades Plugins

まとめ

アップグレード可能なコントラクト、特にプロキシパターンは、スマートコントラクト開発における柔軟性と保守性を高めるための重要な技術です。

  • 不変性の課題を克服し、バグ修正や機能追加を可能にする。
  • プロキシコントラクト実装コントラクトに分離する。
  • Transparent ProxyUUPS Proxy が代表的なパターン(現在はUUPSが主流)。
  • ストレージの衝突初期化関数権限管理などに細心の注意が必要 ⚠️。
  • OpenZeppelin などの信頼できるライブラリと開発ツールの活用が不可欠。

この技術を理解し、正しく利用することで、より安全で持続可能な分散型アプリケーション(DApp)を構築することができます。複雑な側面もありますが、実践を通じて理解を深めていきましょう! 💪

📚 参考情報

コメント

タイトルとURLをコピーしました