はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、スマートコントラクトアカウントに移行しないEOAでも、バッチ実行を実現できるOperatorコントラクトを定義しているERC8000についてまとめていきます!

以下にまとめられているものを解説しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

全体の構造

ERC8000は「Operator」と「Operated」という2つのコンポーネントで構成されています。

以下の図は、ERC8000の全体的なフロー(Transient Storageを使った送信者保持の仕組み)を示しています。

Operatorの内部フロー

詳細なシーケンスは以下の通りです。

sequenceDiagram
    participant EOA as EOA(ユーザー)
    participant Op as Operatorコントラクト
    participant C1 as OperatedコントラクトA
    participant C2 as OperatedコントラクトB

    EOA->>Op: execute([callA, callB])
    Note over Op: msg.senderをTransient Storageに保存
    Op->>C1: callA(callData)
    C1->>Op: onBehalfOf()
    Op-->>C1: EOAのアドレス
    Note over C1: _msgSender() = EOAアドレス として処理
    Op->>C2: callB(callData)
    C2->>Op: onBehalfOf()
    Op-->>C2: EOAのアドレス
    Note over C2: _msgSender() = EOAアドレス として処理
    Op-->>EOA: 各コールの戻り値

このシーケンスを整理すると次のような流れになります。

  1. EOAがexecuteを呼び出し、実行したい複数のコールをまとめてOperatorへ送る
  2. Operatorは送信者(EOA)のアドレスをTransient Storageに保存してから各コールを順番に実行する
  3. 各ターゲットコントラクトは_msgSender()でOperatorのonBehalfOf()を呼び出し、実際のEOAアドレスを取得する
  4. ターゲットコントラクトはEOAを送信者として正しく認識した上で処理を実行する

なぜTransient Storageを使うのか

Operatorが送信者のアドレスを保持する方法として、通常のストレージ変数を使う方法もあります。 しかし通常のストレージはトランザクション終了後もデータが残るため、後続のトランザクションで誤ったアドレスが参照されるリスクがあります。

EIP1153で導入されたTransient Storage(一時ストレージ) はトランザクション終了時に自動的にクリアされます。 そのため、コールのコンテキスト外でアドレスが残存するリスクがなく、正確な送信者情報を安全に保持できます。

EIP1153については以下の記事を参考にしてください。

動機

EOAとSCAの違いとUXの問題

以下の図は、従来のEOAとERC8000を使ったバッチ実行の違いを示しています。

比較図

従来のEOAはapproveとtransferFromを別々のトランザクションで実行するため2回の確認が必要ですが、ERC8000ではOperatorを経由して1トランザクションにまとめられます。

Ethereumのウォレットには大きく2種類があります。

EOA(Externally Owned Account) は秘密鍵で管理される一般的なウォレットで、MetaMaskなどで使われています。 SCA(Smart Contract Account) はコントラクトとして動作するアカウントで、バッチ実行やガスレストランザクションなど高度な機能を持ちます。

従来のEOAが抱える典型的なUXの問題として、approve → transferFrom の2ステップ操作があります。 例えばDEXでトークンをスワップする時、まずトークン支出の承認トランザクションを送り、その後にスワップのトランザクションを別途送る必要があります。 2つのトランザクションを逐次実行するため、確認の手間とガス代が2倍かかります。

EIP7702が解決したこととまだ残る課題

EIP7702はEOAに一時的にコードを委任(デリゲート)できる仕組みを導入し、EOAをSCAのように動作させることを可能にしました。 これにより、EIP7702対応ウォレットを使えばバッチ実行が可能になります。

しかし現実には、EIP7702を使わずに従来のEOAのまま運用し続けるユーザーも多くいます。 EIP7702はまだ広く普及しておらず、対応ウォレットへの移行を望まないユーザーや、コードの委任そのものに抵抗感を持つユーザーが存在するためです。

ERC8000はそういったユーザー向けに、EIP7702を使わずにバッチ実行のUXを提供する軽量な仕組みを提案しています。

仕様

この仕様で登場する2つのコンポーネントの定義を整理しておきます。

Operator EOAからバッチ実行リクエストを受け取り、複数のコントラクト呼び出しを順番に実行する仲介コントラクト。

Operated Operatorからの呼び出しに対応するために_msgSender()をオーバーライドした抽象コントラクト。継承することでターゲットコントラクトが実際のEOAアドレスを正しく取得できるようになります。

IOperatorインターフェース

OperatorコントラクトはIOperatorインターフェースを実装します。 Operatorはシングルトン(単一のデプロイ済みインスタンス)として運用することが強く推奨されています。 単一のOperatorを複数のOperatedコントラクトで共有することで、セキュリティ監査のコストを集約し、信頼性を高められます。

以下はIOperatorインターフェースの定義です。

javascript
pragma solidity ^0.8.29;

interface IOperator {
    struct Call {
        address target;
        uint256 value;
        bytes callData;
    }

    function execute(Call[] calldata calls) external payable returns (bytes[] memory returnData);
    function onBehalfOf() external view returns (address sender);
}

Call構造体は実行するコールの情報を表します。

フィールド 型 説明
target address 呼び出し先のコントラクトアドレス
value uint256 送付するETHの量
callData bytes 呼び出すデータ(関数セレクタ + 引数)

execute

EOAからの複数コールをまとめて実行する関数です。

  • 引数

    • calls — 実行するコールの配列
  • 戻り値

    • returnData — 各コールの戻り値を格納したバイト配列

いずれかのコールが失敗した場合は全体をrevertします。 すべてのコールの戻り値をまとめて返します。

onBehalfOf

ターゲットコントラクトが実際の呼び出し元(EOA)のアドレスを取得するための関数です。

  • 戻り値
    • sender — コールのコンテキスト内での実際の送信者アドレス

コールのコンテキスト内(executeの実行中)にのみ有効なアドレスを返します。 コールのコンテキスト外で呼び出された場合はrevertします。

Operatedコントラクト

ターゲットとなるコントラクトがOperatorからの呼び出しに対応するための抽象コントラクトです。

javascript
pragma solidity ^0.8.29;

import { Context } from "@openzeppelin/contracts/utils/Context.sol";
import { IOperator } from "./interfaces/IOperator.sol";

abstract contract Operated is Context {
    IOperator public immutable operator;

    constructor(address operator_) {
        operator = IOperator(operator_);
    }

    function _msgSender() internal view virtual override returns (address) {
        if (msg.sender == address(operator)) {
            return operator.onBehalfOf();
        }

        return msg.sender;
    }
}

_msgSender()の挙動を整理すると以下の通りです。

呼び出し元 戻り値
Operatorコントラクト operator.onBehalfOf()が返す実際のEOAアドレス
それ以外 通常のmsg.sender

この仕組みにより、Operatorを通じた呼び出しでも直接呼び出しでも、ターゲットコントラクトは正しい送信者を取得できます。 ERC2771(メタトランザクション向けのトラステッドフォワーダーパターン) と同様の設計思想を持っています。

ERC2771はガス代の肩代わり(メタトランザクション)を実現するために、トラステッドフォワーダーがmsg.senderの代わりに署名者のアドレスを返す仕組みを定義した規格です。 ERC8000のOperatedコントラクトは、このパターンをOperatorからの一括コール実行に応用しています。

補足

なぜコールデータのハッシュやnonce管理を含まないのか

ERC8000のOperatorはコールデータの整合性検証(ハッシュチェック)やリプレイ防止のためのnonce管理を意図的に持ちません。 それらの責務は、Operatorを呼び出すウォレットやフロントエンドが担うべきと考えられているためです。 Operatorをシンプルな「実行の仲介者」に留めることで、コンパクトで検証しやすい実装を実現しています。

なぜTransient Storageを選んだのか

送信者アドレスの保持方法としては通常のストレージ変数も使えますが、トランザクション終了後もデータが残留するためリスクがあります。 EIP1153のTransient Storageはトランザクション終了時に自動消去されるため、残留リスクがなく、コンテキスト外でのonBehalfOf()呼び出しをrequireで確実にrevertできます。 ガスコストの観点でも通常のストレージより安価です。

なぜシングルトンを推奨するのか

Operatedコントラクトのコンストラクタには任意のOperatorアドレスを渡せますが、Operatorがシングルトンとして広く利用される想定で設計されています。 複数のOperatedコントラクトが同一のOperatorを共有することで、監査コストが集約され、コミュニティによる検証が進みやすくなります。

後方互換性

ERC8000は既存のスマートコントラクトには何も変更を要求しません。

ただし、Operatorを通じたバッチ実行の恩恵を受けられるのは、Operatedコントラクトを継承して_msgSender()をオーバーライドしたコントラクトに限られます。 既存コントラクトがOperatedに対応していない場合、msg.senderがOperatorアドレスになってしまい、送信者の識別が正しく行われません。

対応済みのコントラクトとそうでないコントラクトが混在するユースケースでは、Operatedに対応したコントラクトを呼び出す時のみバッチ実行が有効で、それ以外は従来通りの個別トランザクションが必要になります。

セキュリティ

再入攻撃の防止

OperatorのexecuteはReentrancyGuardTransient(再入防止ガード)を実装しています。 もし再入攻撃が成功すると、攻撃者がコールバックからOperatorを再度呼び出し、Transient Storageの送信者情報を別のアドレスで上書きできてしまいます。 nonReentrantモディファイアはこの攻撃経路を遮断します。

信頼できるOperatorの使用

Operatedコントラクトのコンストラクタで指定するOperatorアドレスは、信頼できるコントラクトでなければなりません。 悪意のあるOperatorを指定すると、onBehalfOf()が任意のアドレスを返すように細工でき、ターゲットコントラクトが誤った送信者を信頼してしまいます。 この提案ではOperatorをシングルトン(単一のデプロイ済みコントラクト)として運用することが推奨されており、広く監査されたコントラクトを使うことでリスクを低減できます。

参考実装

Operatorの実装

仕様で定義されたIOperatorインターフェースの実装例です。

javascript
pragma solidity ^0.8.29;

import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {IOperator} from "./interfaces/IOperator.sol";

contract Operator is IOperator, ReentrancyGuardTransient {
    using TransientSlot for *;
    using Address for address;

    // keccak256(abi.encode(uint256(keccak256("operator.actual.sender")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant MSG_SENDER_STORAGE = 0x0de195ebe01a7763c35bcc87968c4e65e5a5ea50f2d7c33bed46c98755a66000;

    modifier setMsgSender() {
        MSG_SENDER_STORAGE.asAddress().tstore(msg.sender);
        _;
        MSG_SENDER_STORAGE.asAddress().tstore(address(0));
    }

    function onBehalfOf() external view returns (address _actualMsgSender) {
        _actualMsgSender = MSG_SENDER_STORAGE.asAddress().tload();
        require(_actualMsgSender != address(0), "outside-call-context");
    }

    function execute(
        Call[] calldata calls_
    ) external payable override nonReentrant setMsgSender returns (bytes[] memory _returnData) {
        uint256 _length = calls_.length;
        _returnData = new bytes[](_length);

        uint256 _sumOfValues;
        Call calldata _call;
        for (uint256 i; i < _length; ) {
            _call = calls_[i];
            uint256 _value = _call.value;
            unchecked {
                _sumOfValues += _value;
            }
            _returnData[i] = _call.target.functionCallWithValue(_call.callData, _value);
            unchecked {
                ++i;
            }
        }

        require(msg.value == _sumOfValues, "value-mismatch");
    }
}

実装の重要なポイントを解説します。

MSG_SENDER_STORAGEはTransient Storageのスロットを指定するための定数です。 EIP1967のストレージスロット命名規則(EIPで使われるパターン)に基づき、衝突を避けるためにkeccak256ハッシュを用いて計算されています。

setMsgSenderモディファイアはexecuteの前後でTransient Storageへの書き込み・クリアを行います。 executeが開始する時にEOAのアドレスを書き込み、処理が終わるとaddress(0)で上書きしてクリアします。 これによりonBehalfOf()はコールのコンテキスト内でのみ有効なアドレスを返し、コンテキスト外ではrequireによってrevertします。

executeはnonReentrantモディファイアによって再入攻撃を防いでいます。 これは非常に重要な設計で、ターゲットコントラクト内から再びOperatorを呼び出すことでTransient Storageの送信者情報を上書きする攻撃を防ぎます。

ETHを送付するコールがある場合、各コールのvalueの合計がmsg.valueと一致することを確認します。 過不足があった場合はvalue-mismatchエラーでrevertします。

Operatedコントラクトの使用例

以下はDepositとWithdrawを提供するシンプルなコントラクトをOperator対応にした例です。

javascript
pragma solidity ^0.8.29;

import {Context} from "@openzeppelin/contracts/utils/Context.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IOperator} from "./interfaces/IOperator.sol";
import {Operated} from "./Operated.sol";

contract OperatorCompatible is Operated {
    error InvalidToken();
    error InsufficientBalance();

    mapping(address => mapping(address => uint256)) public balance;

    constructor(address operator_) Operated(operator_) {}

    function deposit(address token_, uint256 amount_) public payable {
        if (token_ == address(0)) revert InvalidToken();
        address _sender = _msgSender();
        IERC20(token_).transferFrom(_sender, address(this), amount_);
        balance[token_][_sender] += amount_;
    }

    function withdraw(address token_, uint256 amount_) public {
        if (token_ == address(0)) revert InvalidToken();
        address _sender = _msgSender();
        if (balance[token_][_sender] < amount_) revert InsufficientBalance();
        balance[token_][_sender] -= amount_;
        IERC20(token_).transfer(_sender, amount_);
    }
}

このコントラクトがOperatorを通じて呼び出される時、_msgSender()はEOAのアドレスを返します。 depositとwithdrawはEOAを送信者として正しく扱うため、BalanceのマッピングもEOAアドレスで管理されます。 EOAがOperatorを使ってapprove(Operator, amount) → deposit(token, amount)の2つのコールを1トランザクションで実行すれば、従来の2回の確認が不要になります。

最後に

今回は「ERC8000」についてまとめてきました。

ERC8000はEIP7702による委任を使わないEOAでも、Operatorコントラクトを仲介してバッチ実行を実現する仕組みを提案しています。 Operatorはトランザクションを通じてmsg.senderをTransient Storageで安全に保持し、ターゲットコントラクトはOperatedを継承するだけでEOAを正しく識別できます。 EIP7702の普及を待たずに今すぐUXを改善できる、実用的なアプローチです。

他でも色々記事を書いているのでぜひよろしければ読んでいってください!