🧑‍🎓

delegatecall とストレージ管理から理解する ERC-7546とMeta Contract

2024/12/19に公開

はじめに

スマートコントラクトのアップグレードや柔軟なロジック管理を行う上で、delegatecallという EVM の機能やストレージ管理(ERC-7201 など)に関する理解は不可欠です。これらの基礎知識を踏まえた上で、ERC-7546 という新しいアップグレードパターンと、その実装フレームワークである Meta Contract について解説します。

この記事では

  1. delegatecallの基本的な仕組み
  2. ストレージ管理の課題と ERC-7201 による解決策
  3. ERC-7546 によるアップグレードパターン
  4. Meta Contract フレームワークの実装方法

について、順を追って説明していきます。

delegatecall とは?

delegatecall は、あるコントラクト(呼び出し元)が別のコントラクト(呼び出し先)の「コード」を借りて実行する機能です。これは「他人の料理レシピを借りて、自分の台所(ストレージ)を使って料理する」ようなイメージです。

ポイント

  • コードは借りるが、データは呼び出し元のストレージを利用
  • msg.sendermsg.valueも呼び出し元が維持される

1. 通常の call と delegatecall の違い

通常の call

  • コントラクトBのコードを実行
  • データはコントラクトBのストレージに保存

delegatecall

  • コントラクトBのコードを借用
  • データはコントラクトAのストレージに保存

2. 具体例で delegatecall を理解する

コード例

以下のような簡単な例で見てみましょう

// 実装コントラクト
contract Called {
    uint public number;

    function increment() public {
        number++;
    }
}

// 呼び出し元コントラクト
contract Caller {
    uint public number;

    function callIncrement(address _calledAddress) public {
        _calledAddress.delegatecall(
            abi.encodeWithSignature("increment()")
        );
    }
}

CallerCalledincrement()を借用しますが、実際に増えるのはCaller内部のnumberです。

注意点:

  • ストレージレイアウトが同じであることが望ましい(異なると不具合発生)
  • msg.sendermsg.valueは呼び出し元のまま

ストレージスロットの基本

1. ストレージとは

スマートコントラクトには、データを保存するための「ストレージ」という領域があります。これは以下のような特徴を持ちます

  • 256 ビット(32 バイト)のスロットに分かれている
  • スロット 0 から順番に使用される
  • データは永続的に保存される
  • 変更にはガス代がかかる

2. 変数の保存方法

contract StorageExample {
    uint256 x;    // スロット0に保存
    uint256 y;    // スロット1に保存
    address owner;  // スロット2に保存
}
  • 変数は宣言順に、スロット 0 から順番に配置される
  • uint256 のような 32 バイトの変数は 1 つのスロット全体を使用
  • より小さい変数(uint8、bool 等)は 1 つのスロットに複数詰め込むことができる(パッキング)

3. アップグレード時の問題

具体例

// バージョン1
contract TokenV1 {
    string name;          // スロット0
    uint256 totalSupply;  // スロット1
}

// バージョン2
contract TokenV2 {
    string name;          // スロット0
    uint256 newVariable;  // スロット1に新しい変数
    uint256 totalSupply;  // スロット2に移動!
}

問題点

  • totalSupplyの位置がスロット 1 からスロット 2 に移動
  • 古いデータが失われる、または間違ったデータを参照してしまう

4. 継承による問題

contract Base {
    uint256 variable1;    // スロット0
    uint256 variable2;    // スロット1
}

contract Child is Base {
    uint256 childVar;     // スロット2
}

// 新しい親コントラクトを追加すると...
contract NewParent {
    uint256 newParentVar; // すべてのスロットがずれる
}

contract Child is NewParent, Base { // 問題発生!
    uint256 childVar;
}

問題点

  • 継承関係を変更するとストレージレイアウトが崩れる
  • 新しい親コントラクトの追加が困難
  • 複雑な継承関係での変数管理が難しい

5. OpenZeppelin の対処法(ギャップアプローチ)

contract BaseContract {
    uint256 variable1;
    uint256 variable2;

    // 将来の拡張のための空きスロット
    uint256[50] private __gap;
}

制限事項

  • 事前に十分な空きスロットを確保する必要がある
  • ストレージの無駄遣いになる可能性がある
  • 継承順序の変更には対応できない
  • 50 以上の変数を追加する場合に対応できない

なぜ ERC-7201 が必要なのか?

ストレージ管理において、変数の追加や継承関係の変更によってデータの保存位置が変わってしまい、既存のデータが失われたり間違った参照が発生したりする問題がありました。
ERC-7201は、ストレージ領域をネームスペースで分離することで、この問題を解決します。

1. ネームスペース方式

/// @custom:storage-location erc7201:token.base
struct TokenStorage {
    uint256 value;
    mapping(address => uint256) balances;
}

メリット

  • 関連する変数をグループ化できる
  • 各グループが独立したストレージ領域を持つ
  • アップグレードや継承の影響を受けない

2. ストレージ位置の計算方法

ERC-7201 では、各ネームスペースのストレージ位置を以下の計算式で決定します

bytes32 position = keccak256(abi.encode(
    uint256(keccak256(bytes("token.base"))) - 1
)) & ~bytes32(uint256(0xff));

ただし、この計算を毎回コントラクト内で行うのはガス代の無駄遣いになります。代わりに、Foundry のcast index-erc7201コマンドを使用して事前に計算し、その結果をコントラクトの定数として使用することができます

$ cast index-erc7201 "token.base"
0xd77362988766d0a761f68697611e1b7d081b4c6b86b0ca898302dfc37799f600  # 計算されたストレージ位置

3. 実装例

contract StorageExample {
    /// @custom:storage-location erc7201:token.base
    struct TokenStorage {
        uint256 value;
        mapping(address => uint256) balances;
    }

    // 事前に計算したストレージ位置を定数として使用
    bytes32 private constant STORAGE_POSITION = 0xd77362988766d0a761f68697611e1b7d081b4c6b86b0ca898302dfc37799f600;  // cast index-erc7201の結果

    function _getTokenStorage() internal pure returns (TokenStorage storage ts) {
        assembly {
            ts.slot := STORAGE_POSITION
        }
    }

    function transfer(address to, uint256 amount) external {
        TokenStorage storage ts = _getTokenStorage();
        require(ts.balances[msg.sender] >= amount, "Insufficient balance");
        ts.balances[msg.sender] -= amount;
        ts.balances[to] += amount;
    }
}

利点

  • 型安全なアクセス方法
  • コードの可読性向上
  • アップグレードの柔軟性
  • ストレージの衝突防止

この方式により、アップグレード性、安全性が大きく向上し、より柔軟な開発が可能になります。

https://eips.ethereum.org/EIPS/eip-7201


ここから本題:ERC-7546 の解説

delegatecallを使ってコントラクトの機能を柔軟に再利用し、ERC-7201 でストレージを安全に管理できれば、より高度なアップグレード戦略が可能になります。

そこで注目されるのが ERC-7546 です。

1. なぜ ERC-7546 が必要なのか?

既存の標準にはそれぞれ長所と短所があります。

  • ERC-1967 (UUPS): コントラクト全体を 1 対 1 でアップグレード。簡潔だが柔軟性が低い
  • ERC-2535 (Diamond Standard): ファンクション単位でアップグレード可能。しかし多数のクローン(インスタンス)を同時にアップグレードする仕組みが得意でない

ERC-7546 は、ファンクションレベルのアップグレード可能性 と、多数のクローンを同時アップグレード という両方の要素を組み合わせたアーキテクチャを提供します。

2. ERC-7546 の 3 要素

  1. Proxy Contract(プロキシコントラクト)
    ユーザーはまずこのコントラクトにアクセスし、delegatecallを使って必要な機能(コード)を借ります。
    ステートはすべて Proxy に集約されています。

  2. Dictionary Contract(ディクショナリコントラクト)
    ファンクションセレクター(関数識別子)と対応する Function Contract のアドレスをマッピングします。
    この Dictionary を更新すれば、全てのクローン(Proxy)が一斉に新関数を参照できるため、大量展開したコントラクトを同時にアップグレードできます。

  3. Function Contracts(ファンクションコントラクト群)
    個々の関数がここに定義されます。
    delegatecallによって Proxy のストレージを操作するため、Function Contracts 自身はステートを持たず、コードロジックのみを保持します。

3. 図解

以下は、ERC-7546 アーキテクチャのおおまかな流れを示した図です。

ERC-7546 アーキテクチャの図解

ユーザーは常に同じ Proxy Contract に対してトランザクションを送りますが、Dictionary を通じて実行される関数は自在に差し替えることができます。

4. ERC-7546 の強み

  1. 関数単位でのアップグレード
    バグ修正や関数追加を関数ごとに行える

  2. 大量クローンの同時アップグレード
    Dictionary を 1 箇所更新すれば、多数の Proxy インスタンスが新関数を即時利用可能

  3. コードサイズ上限の回避
    ロジックを複数の Function Contracts に分散できるため、24kB 上限を超えた大規模ロジックを実装可能

5. ユースケース例

  • トークンコントラクト
    多数のトークンを同じロジックで運用し、新関数追加時に Dictionary を変更すれば、全トークンに一斉適用可能

  • ウォレット・DAO
    膨大なユーザーウォレットや DAO インスタンスを 1 つの Dictionary で管理し、セキュリティパッチや機能拡張を一括で適用可能

https://eips.ethereum.org/EIPS/eip-7546
https://github.com/ecdysisxyz/ucs-contracts

Meta Contract の解説

Meta Contractは、ERC-7546 のアーキテクチャを実装するための Foundry ベースのスマートコントラクトフレームワークです。

1. Meta Contract の特徴

  • アップグレード可能性: アドレスを変更せずに Meta Contracts をアップグレード可能
  • モジュール性: コントラクトロジックを独立した管理可能なコンポーネントに分離
  • スケーラビリティ: 大規模アプリケーションに適した設計
  • 柔軟性: プロジェクトの特定のニーズに合わせて簡単に拡張・カスタマイズ可能
  • テスト可能性: モジュール構造により、個々の関数やシステム全体の統合テストが容易

2. Meta Contract の始め方

Meta Contract を使用したプロジェクトを開始するには、以下のコマンドで、カウンターのサンプル実装を含むプロジェクトを作成できます。

# プロジェクトの作成
forge init contract -t metacontract/template

# カウンターサンプルのテスト実行
cd contract
forge test

作成されたプロジェクトには、Meta Contract 実装の基本となるサンプルコードが含まれています。以下で、プロジェクトの構造と各ファイルの役割を詳しく見ていきましょう。

3. ディレクトリ構造

contract/
├── script/                   # デプロイ・アップグレード用スクリプト
│   ├── deploy/              # デプロイ関連
│   │   ├── CounterDeployer.sol       # デプロイ用ライブラリ
│   │   └── DeployCounter.s.sol       # デプロイ実行スクリプト
│   └── upgrade/             # アップグレード関連
│       ├── CounterUpgrader.sol       # アップグレード用ライブラリ
│       └── 01_UpgradeSetNumber.s.sol # アップグレード実行スクリプト
├── src/                      # スマートコントラクト本体
│   └── counter/             # カウンターのサンプル実装
│       ├── functions/       # 各関数のコントラクト
│       │   ├── initializer/
│       │   │   └── Initialize.sol    # 初期化関数
│       │   ├── GetNumber.sol         # 数値取得関数
│       │   ├── Increment.sol         # 数値増加関数
│       │   └── SetNumber.sol         # 数値設定関数
│       ├── interfaces/      # インターフェース定義
│       │   ├── CounterFacade.sol     # Etherscanでの関数操作用
│       │   └── ICounter.sol          # 関数・イベント・エラーの定義
│       └── storage/         # データ保存領域の定義
│           ├── Schema.sol            # データ構造定義
│           └── Storage.sol           # データアクセス用ライブラリ
└── test/                    # テストコード

4. ファイル構造と役割

デプロイ用スクリプト (script/deploy/)

  • CounterDeployer.sol: 各関数コントラクトをデプロイし、紐付けを行うライブラリ
  • DeployCounter.s.sol: 実際のデプロイを実行し、アドレスを環境変数に保存するスクリプト

アップグレード用スクリプト (script/upgrade/)

  • CounterUpgrader.sol: 既存の関数を新しいバージョンに更新するためのライブラリ
  • 01_UpgradeSetNumber.s.sol: SetNumber関数のアップグレードを実行するスクリプト

関数コントラクト (src/counter/functions/)

  • Initialize.sol: コントラクトの初期設定を行う関数
  • GetNumber.sol: 保存されている数値を読み取る関数
  • Increment.sol: 数値を 1 増やす関数
  • SetNumber.sol: 数値を指定の値に設定する関数

インターフェース (src/counter/interfaces/)

  • CounterFacade.sol: Etherscan 上で関数を直接操作するための空関数を定義
  • ICounter.sol: 全ての関数、イベント、エラーの定義をまとめたもの

ストレージ (src/counter/storage/)

  • Schema.sol: ERC-7201 に準拠したデータ構造の定義
  • Storage.sol: 定義されたデータ構造へのアクセス方法を提供

5. デプロイ処理

Meta Contract では、テストとデプロイで同じ処理を使用できるように、デプロイ処理をライブラリとして切り出しています。

CounterDeployer.sol の主な役割

library CounterDeployer {
    string internal constant BUNDLE_NAME = "Counter";

    /**
     * @dev Deploys the Counter contract
     * @param mc MCDevKit storage reference
     * @return counter Address of the deployed Counter proxy contract
     */
    function deployCounter(MCDevKit storage mc, uint256 initialNumber) internal returns(address) {
        // 1. バンドルのセットアップ開始
        // - バンドル名を設定してDictionaryコントラクトの初期化を準備
        // - 複数のコントラクト(Factory/LPなど)を組み合わせる場合に管理しやすくなる
        mc.init(BUNDLE_NAME);

        // 2. バンドルに関数を紐付け
        // - 各関数をDictionaryコントラクトに登録するための準備
        // - 設定内容:関数名、関数セレクタ(4バイトの識別子)、実装コントラクトのアドレス
        mc.use("Initialize", Initialize.initialize.selector, address(new Initialize()));
        mc.use("GetNumber", GetNumber.getNumber.selector, address(new GetNumber()));
        mc.use("Increment", Increment.increment.selector, address(new Increment()));
        mc.use("SetNumber", SetNumber.setNumber.selector, address(new SetNumber()));

        // 3. バンドルにFacadeを紐付け
        // - Etherscanでの関数呼び出しに必要なインターフェースを設定
        mc.useFacade(address(new CounterFacade()));

        // 4. 実際のデプロイを実行
        // - Dictionaryのデプロイと関数・Facadeの登録
        // - Dictionaryと紐付いたProxyのデプロイと初期化
        // - デプロイしたProxyコントラクトのアドレスを返却
        return mc.deploy(abi.encodeCall(Initialize.initialize, initialNumber)).toProxyAddress();
    }
}

デプロイライブラリ利用

デプロイスクリプト

function run() public startBroadcastWith("DEPLOYER_PRIV_KEY") {
    // ライブラリを使用してデプロイ
    address _counter = CounterDeployer.deployCounter(mc, 1);

    // デプロイしたアドレスを環境変数に保存
    _saveAddrToEnv(_counter, "COUNTER_PROXY_ADDR_");
}

テスト

contract CounterScenarioTest is MCTest {
    using CounterDeployer for MCDevKit;
    ICounter public counter;

    function setUp() public {
      // デプロイスクリプトと同じライブラリを使用してデプロイ
        counter = ICounter(CounterDeployer.deployCounter(mc, 100));
    }
}

このように共通化することで

  • テストとデプロイで同じ処理を使用可能
  • デプロイ手順のミスを防止
  • コードの重複を回避

が実現できます。

6. ERC-7201 に基づくストレージアクセス

Meta Contract では、ERC-7201 に基づき、個々の関数コントラクトからストレージへのアクセスを Storage ライブラリを介して行います。

まず、ストレージの構造を Schema として定義します

library Schema {
    /// @custom:storage-location erc7201:Counter.CounterState
    struct CounterState {
        uint256 number;
    }
}

このスキーマに対して、Foundry のcast index-erc7201コマンドで算出した固定のストレージスロットを使用してアクセスします

library Storage {
    function CounterState() internal pure returns(Schema.CounterState storage ref) {
        assembly { ref.slot := 0x3b60124079255925a9b0c57f1ed870358d719ce06c4ae9645b74322357c0b400 }
    }
}

各関数コントラクトは、この Storage ライブラリを使用してストレージにアクセスします

// 読み取り
function getNumber() external view returns(uint256 number) {
    return Storage.CounterState().number;
}

// 書き込み
function setNumber(uint256 newNumber) external {
    Storage.CounterState().number = newNumber;
    emit ICounter.NumberSet(newNumber);
}

この方式により、各関数コントラクトは安全かつ効率的にデータの読み書きを行うことができ、アップグレード時のストレージの整合性も保証されます。

7. Facade パターンによるコントラクト検証

Meta Contractでは、EtherscanでProxyコントラクトを直コン操作できるようにするために、Facadeコントラクト(空関数を定義したコントラクト)を登録する必要があります。

Facadeコントラクトの必要性

Facadeコントラクトを登録しないと、Etherscanのプロキシコントラクトで以下の操作ができません

  • "Read as Proxy": プロキシを通じた読み取り関数の呼び出し
  • "Write as Proxy": プロキシを通じた書き込み関数の呼び出し

Etherscan proxyの直コン

実装方法

// インターフェースを継承したFacadeコントラクト
contract CounterFacade is ICounter {
    function initialize(uint256 initialNumber) external {}
    function getNumber() external view returns(uint256) {}
    function increment() external {}
    function setNumber(uint256 newNumber) external {}
}

// デプロイ時にFacadeを登録
mc.useFacade(address(new CounterFacade()));

このように、インターフェースで定義された全ての関数を含むFacadeコントラクトを作成し、デプロイ時に登録することで、Etherscanでのコントラクト操作が可能になります。


まとめ

本記事では、ERC-7546 のスマートコントラクト開発に必要な要素を解説しました

  1. delegatecall

    • コードの再利用メカニズム
    • ストレージの分離と活用
  2. ストレージ管理(ERC-7201)

    • 安全なストレージレイアウト
    • アップグレード時の衝突回避
  3. ERC-7546

    • 関数単位のアップグレード
    • 大量展開時の同時更新
    • ストレージとロジックの分離
  4. Meta Contract

    • ERC-7546 の実装フレームワーク
    • モジュラー設計のサポート
    • テストとデプロイの効率化

これらの技術を組み合わせることで、DeFi、NFT、DAO、ウォレットなど、複雑で拡張が求められるスマートコントラクトを、より安全かつ効率的に開発・運用できます。Meta Contract フレームワークを使用することで、ERC-7546 を簡単に実装でき、保守性の高いスマートコントラクト開発が可能になります。

@naizo_eth

Discussion