はじめに

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

今回は、スマートコントラクトアカウントを他のウォレットへ安全かつ簡単に移行できる仕組みを提案しているERC7405についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

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

概要

Portable Smart Contract Accounts(PSCA)は、スマートコントラクトアカウント(SCA)が抱える「他のウォレットへ移行できない」という問題を解消する仕組みです。 SCAはウォレットごとに実装が異なるため、ユーザーが別のウォレットへ移りたくても簡単には移行できません。 これに対して、PSCAはERC1967のProxy構造を活用し、ユーザーが自分のSCAを別のウォレットに移せるようにします。

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

移行時には、新しくランダムで生成される「移行用の鍵(migration key)」を利用します。 これは、外部所有アカウント(EOA)で秘密鍵やニーモニックを使ってアカウントをエクスポートする体験に近いものです。

PSCAでは、署名とタイムロックを組み合わせた仕組みで安全性を確保します。 ユーザーは移行操作を承認したあとでも、タイムロック期間内であれば内容を確認し、問題があればキャンセルできます。 これにより、不正な移行要求が通ってしまうことを防ぎます。

PSCAはSCAの実装部分を変更する必要がないため、導入コストが低く、Wallet A・Wallet Bといった異なる実装間でも移行しやすくなります。 結果として、Account Abstraction(AA)領域の相互運用性と拡張性が大きく向上します。

動機

ERC4337によってアカウントアブストラクション(AA)が広く普及し、SCA を採用するウォレットは増えています。しかし、EOA と違い、SCA は「コードそのものがウォレット依存」という特徴があります。 そのため、SCA を使っているユーザーは次のような問題に直面します。

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

  • ウォレットごとにコントラクトの実装が違う
  • その結果、他社のウォレットへアカウントをそのまま移せない
  • 移行機能がないため、一度作ったSCAに長期間縛られる

一部では「SCAの実装をモジュール化しよう」という提案もありますが、これはすべてのウォレットに同じような設計を求めることになり、導入コストも高くなりがちです。 ウォレット提供者ごとに実装方針が異なる現状では、統一化されたモジュール方式は必ずしも適していません。

PSCAが採用するアプローチは、Proxy(ERC1967)層に機能を持たせるというものです。 これにより、以下のようなメリットが生まれます。

  • 既存のウォレット実装をほとんど変更せずに導入できる
  • SCA内部のロジックに依存せず、どのウォレット間でも移行できる
  • モジュール方式とも組み合わせ可能で、AAエコシステム全体の拡張性を高められる
  • ユーザー体験としては「EOAの秘密鍵エクスポート」に近いシンプルな操作になる

下図のように、Wallet AのSCAをWallet Bへ移行でき、SCA プロキシのアドレスはそのまま維持されます。

ウォレット実装による縛りをほどき、ユーザーが自由にウォレットを選べるようにすることが PSCA の中心的な目的です。 これにより、AAのエコシステム全体がよりオープンで、相互運用性の高いものになります。

仕様

用語

以下は、PSCAが扱う主要な用語です。

用語 説明
Wallet Provider ウォレットサービスを提供する事業者。各社の SCA 実装は互換性がなく、移行性がない。
Random Operator アカウント移行のために生成される新しいランダムな秘密鍵またはニーモニック。公開鍵に対応するアドレスが randomOperator として扱われる。
BIP44 ニーモニックから鍵を導出する一般的な規格。ここでは m/44'/60'/0'/0/0' が使用される経路。

インターフェイス

IERC7405.sol
interface IERC7405 {
    /**
     * @dev emitted when the account finishes the migration
     * @param oldImplementation old implementation address
     * @param newImplementation new implementation address
     */
    event AccountMigrated(
        address oldImplementation,
        address newImplementation
    );
    
    /**
     * @dev prepare the account for migration
     * @param randomOperator public key (in address format) of the random operator
     * @param signature signature signed by the random operator
     *
     * **MUST** check the authenticity of the account
     */
    function prepareAccountMigration(
        address randomOperator,
        bytes calldata signature
    ) external;

    /**
     * @dev cancel the account migration
     *
     * **MUST** check the authenticity of the account
     */
    function cancelAccountMigration() external;

    /**
     * @dev handle the account migration
     * @param newImplementation new implementation address
     * @param initData init data for the new implementation
     * @param signature signature signed by the random operator
     *
     * **MUST NOT** check the authenticity to make it accessible by the new implementation
     */
    function handleAccountMigration(
        address newImplementation,
        bytes calldata initData,
        bytes calldata signature
    ) external;
}

イベント

AccountMigrated
javascript
event AccountMigrated(
    address oldImplementation,
    address newImplementation
);

アカウントの移行が完了したときに発行されるイベント。 SCAがWallet Aの実装からWallet Bの実装へ切り替わった際に発行されます。 移行前と移行後の実装アドレスを記録し、外部から移行の成立を確認できるようにします。

パラメータ

  • oldImplementation
    • 移行前の実装コントラクトアドレス。
  • newImplementation
    • 移行後の実装コントラクトアドレス。

関数

prepareAccountMigration
javascript
function prepareAccountMigration(
    address randomOperator,
    bytes calldata signature
) external;

アカウントを移行可能なステートへ準備する関数。 Wallet Aがランダムに生成した鍵で署名し、その公開鍵アドレスを randomOperator として渡します。 この関数では、SCAの正当性チェック、署名確認、レジストリ登録、アカウントのロック処理を行います。 この処理が実行されると、アカウントは移行操作完了または取消しまでロックされます。

引数

  • randomOperator
    • 移行専用に生成された公開鍵のアドレス。
  • signature
    • migration private key による署名。
cancelAccountMigration
javascript
function cancelAccountMigration() external;

アカウント移行を取り消す関数。 SCAの正当性を確認した上でロックを解除し、レジストリに登録されていた移行情報を削除します。 Wallet Aが移行を中断する場合に使用されます。

handleAccountMigration
javascript
function handleAccountMigration(
    address newImplementation,
    bytes calldata initData,
    bytes calldata signature
) external;

移行作業を完了して新しい実装へ切り替える関数。 Wallet Bによって呼ばれ、アカウント認証チェックを行わずにアクセスできるよう設計されています。 署名確認、レジストリ確認、Wallet A 側の事前処理の実行、ERC1967Proxyの実装アドレスの切り替え、初期化処理の実行、レジストリ削除、イベント発行を行います。

引数

  • newImplementation
    • Wallet Bの新しい実装コントラクトのアドレス。
  • initData
    • Wallet Bの初期化に必要なデータ。
  • signature
    • migration private keyによる署名。

MigrationOp(移行操作)と署名

PSCAはすべての移行操作で、ランダムに生成されたmigration private keyによる署名を使用します。

構造体

MigrationOp
javascript
struct MigrationOp {
    uint256 chainID;
    bytes4 selector;
    bytes data;
}

移行操作の内容を表す構造体。 チェーン ID、呼び出す関数の識別子(selector)、その関数に渡されるデータが含まれます。 このデータに対してERC191形式の署名を行い、改ざんされていないことを保証します。

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

パラメータ

  • chainID
    • 署名対象となるチェーンの識別子。
  • selector
    • 呼び出す関数の識別子。
  • data
    • selector に応じてエンコードされた追加データ。

署名方式

MigrationOp はERC191署名方式で署名されます。

selector 対象関数 data 内容
0x50fe70bd prepareAccountMigration abi.encode(randomOperator)
0xae2828ba handleAccountMigration abi.encode(randomOperator, setupCalldata)

署名対象のハッシュは以下になります。

text
abi.encode(chainID, selector, data)

レジストリ(IERC7405Registry)

移行情報を管理するための共通レジストリです。 ランダム鍵だけで対象アカウントを参照できる仕組みを持ち、移行プロセスの整合性を保証します。

IERC7405Registry.sol
interface IERC7405Registry {
    struct MigrationData {
        address account;
        uint48 createTime;
        uint48 lockUntil;
    }

    /**
     * @dev check if the migration data for the random operator exists
     * @param randomOperator public key (in address format) of the random operator
     */
    function migrationDataExists(
        address randomOperator
    ) external returns (bool);

    /**
     * @dev get the migration data for the random operator
     * @param randomOperator public key (in address format) of the random operator
     */
    function getMigrationData(
        address randomOperator
    ) external returns (MigrationData memory);

    /**
     * @dev set the migration data for the random operator
     * @param randomOperator public key (in address format) of the random operator
     * @param lockUntil the timestamp until which the account is locked for migration
     *
     * **MUST** validate `migrationDataMap[randomOperator]` is empty
     */
    function setMigrationData(
        address randomOperator,
        uint48 lockUntil
    ) external;

    /**
     * @dev delete the migration data for the random operator
     * @param randomOperator public key (in address format) of the random operator
     *
     * **MUST** validate `migrationDataMap[randomOperator].account` is `msg.sender`
     */
    function deleteMigrationData(address randomOperator) external;
}

構造体

MigrationData
javascript
struct MigrationData {
    address account;
    uint48 createTime;
    uint48 lockUntil;
}

移行情報を保持する構造体。 どのアカウントがどの randomOperator と結びついているか、ロックがいつまで続くかを記録します。

パラメータ

  • account
    • 移行対象のアカウントアドレス。
  • createTime
    • 登録された時刻。
  • lockUntil
    • アカウントがロックされている終了時刻。

関数

migrationDataExists
javascript
function migrationDataExists(address randomOperator) external returns (bool);

指定した randomOperator の移行データが存在するか確認する関数。 移行準備時に、すでに同じ randomOperator が使用されていないか確認するために使用されます。

引数

  • randomOperator
    • 検索対象のアドレス。

戻り値

  • bool
    • データの存在有無。
getMigrationData
javascript
function getMigrationData(address randomOperator) external returns (MigrationData memory);

randomOperator に紐づく移行データを取得する関数。 Wallet Bが移行処理を行う際に呼び出し、対象アカウントとロック状態を確認します。

引数

  • randomOperator
    • 検索対象アドレス。

戻り値

  • MigrationData
    • 該当データ。
setMigrationData
javascript
function setMigrationData(address randomOperator, uint48 lockUntil) external;

新しい移行データを登録する関数。 migrationDataMap[randomOperator] が空であることを確認した上で登録します。 Wallet Aの prepare 操作で呼ばれます。

引数

  • randomOperator
    • 移行用の公開鍵アドレス。
  • lockUntil
    • ロックが解除される時刻。
deleteMigrationData
javascript
function deleteMigrationData(address randomOperator) external;

移行データを削除する関数。 migrationDataMap[randomOperator].account が msg.sender と一致する必要があります。 Wallet AのキャンセルまたはWallet Bの移行完了時に使用されます。

引数

  • randomOperator
    • 削除対象の公開鍵アドレス。

期待される挙動

SCAをWallet AからWallet Bへ移行する際の流れは以下のようになります。

1. Wallet Aによる移行準備

  1. ランダムな移行鍵(ニーモニック or 秘密鍵)を新規生成し、ユーザーへ渡す。
  2. MigrationOp を署名し、prepareAccountMigration を呼び出す。
  3. SCAの正当性確認(例:ERC4337なら EntryPoint からの呼び出しであること)。
  4. 署名検証と randomOperator の確認。
  5. レジストリに同じ randomOperator が存在しないことを確認。
  6. レジストリへ setMigrationData で登録し、アカウントをロックする。

ロック状態は、キャンセルまたは移行完了まで続きます。

2. Wallet Bによる移行完了

  1. 移行鍵をインポートし、MigrationOp に署名。
  2. handleAccountMigration を呼び出す。 この時、アカウント認証チェックは行わない。
  3. randomOperator の署名確認。
  4. レジストリから migrationData を取得し、ロック期間が終了していることを確認。
  5. Wallet Aの事前処理 _beforeWalletMigration() を実行。
  6. Proxyの実装をWallet Bの実装へ切り替える。
  7. initData を元にWallet Bの初期化処理を実行。
  8. レジストリからデータ削除。
  9. AccountMigrated を発行。

3. Wallet A による移行キャンセル

  1. SCAの認証を行う。
  2. ロック解除し、レジストリのデータを削除。

ストレージレイアウト

異なるウォレット実装間でストレージが衝突しないよう、PSCAでは以下のルールを設けています。

  • 実装コントラクトの直下に状態変数を置いてはならない
  • すべての状態変数は struct にまとめて特定のスロットへ保存する
  • スロット index は各ウォレットがユニークになるように決める

スロットID の設計

項目 ルール
namespace A–Z, a–z, 0–9, _ のみ使用可能
推奨形式 wallet名_メジャーバージョン(例:foo_wallet_v1)
slotID {namespace}.{customDomain}(例:foo_wallet_v1.config)
実際の slot index bytes32(uint256(keccak256(slotID) - 1))

補足

Smart Contract Account(SCA)はウォレットごとに実装が異なるため、ユーザーが別のウォレットに移行したいときに互換性がなく、スムーズに移れないという問題があります。 SCAの構造上、ウォレットがそれぞれ独自の実装を持つことは珍しくありませんが、その結果として「アカウントがウォレットに固定されてしまう」という状況が起きています。

この問題を解決するために、SCAをモジュール化するというアプローチもあります。 ただし、この方法はウォレット側に新たな構造やモジュール対応を求めることになり、導入の負担が大きくなります。 また、既存のウォレット実装との互換性確保や、運用コストの増加も課題になります。

そこで ERC1967のProxyを土台とし、EOAの秘密鍵やニーモニックを使ったエクスポートに似た「移行機構」を採用します。 すでに馴染みのあるワークフローに倣うことで、ユーザーにとって直感的で扱いやすくなります。

SCA特有のセキュリティ要件を満たすために、移行専用として毎回ランダムに生成される鍵(migration key)を利用します。 この鍵に対応するアドレスは randomOperator として扱われ、移行プロセス全体の署名確認に使用されます。 EOAの秘密鍵エクスポートに近い体験でありながら、SCAの移行に必要な安全性が確保されています。

もう一つの大きな要素が「共通レジストリの導入」です。 プロトコル層にレジストリを置くことで、移行時に必要な情報を簡単に検索でき、migration keyのみでSCAを特定できます。 これにより、移行手続きの扱いやすさが大きく向上します。

さらに、SCAのストレージ構造がウォレット実装によって異なることも移行時の衝突の原因になります。 そこで、状態変数はすべて struct にまとめ、固有スロットへ配置するという設計を推奨しています。 これにより、異なるウォレット間でもストレージが衝突することなく、安全かつ整合性のある移行が可能になります。

互換性

PSCAの仕組みはERC1967Proxyを基盤にしているため、すでにERC1967を使用しているすべてのSCAと互換性があります。 ERC4337に準拠していないSCAであっても問題ありません。

また、SCAの実装コントラクト側に特別な構造や追加機能を求めてはいません。 移行に必要な共通のインターフェイスを実装するだけで利用できるため、既存のSCAの多くが変更なしで適用できます。 これにより、ウォレットごとの実装に依存しない広い互換性が確保されています。

セキュリティ

PSCAの移行プロセスでは、セキュリティを保つためにいくつかの重要な仕組みを導入しています。

まず、毎回新しく生成されるmigration keyを使用します。 同じ鍵を使い回さないことで、署名の再利用(リプレイ攻撃)を防ぎます。 つまり、移行操作を行うたびに、ランダムで一度きりの鍵を使用することで、悪用の可能性を減らしています。

次に、ウォレットごとに異なるストレージ構造があるため、移行後のストレージ衝突を避けなければなりません。 そこで、ストレージを struct にまとめて独立したスロットに保存する設計を推奨しています。 このルールを守ることで、移行先の実装が異なっても、元のストレージと衝突することがなく安全性が保たれます。

さらに、悪意ある第三者が即座に移行を行おうとする場合の対策として「タイムロック」を導入しています。 移行が始まるとアカウントはロック状態になり、一定期間が経過するまで移行処理は完了しません。 このロック期間中に、ユーザーは本来の認証方式(例:SCA のオーナー署名)で移行キャンセルが可能です。 これにより、予期せぬ移行が起きてもユーザーは被害を防げます。

特にロック状態では以下の操作を禁止します。

禁止される操作 理由
資産の移動 悪意ある移行中に資産を抜かれるのを防ぐため。
外部コントラクトの呼び出し 資産移動や危険な処理につながる可能性があるため。
アカウントの認証要素の変更 移行中の乗っ取りを防ぐため。
上記に影響するあらゆる操作 ロック状態の安全性を保持するため。

最後に、ウォレットプロバイダは移行操作が始まったことをユーザーに通知することが推奨されています。 メールやアプリ内通知など、利用可能な手段を使ってユーザーに知らせることで、万が一の不正操作を迅速に察知できるようにします。

引用

Aaron Yee (@aaronyee-eth), "ERC-7405: Portable Smart Contract Accounts [DRAFT]," Ethereum Improvement Proposals, no. 7405, July 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7405.

最後に

今回は「スマートコントラクトアカウントを他のウォレットへ安全かつ簡単に移行できる仕組みを提案しているERC7405」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!