はじめに

初めまして。 CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です! スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

代表的なゲームはクリプトスペルズというブロックチェーンゲームです。

今回は、異なるERC4337で提案されているコントラクトアカウントのエンコードロジックを標準化し、コントラクトアカウントの実装に依存しないUserOpビルダーを使用する仕組みを提案しているERC7679についてまとめていきます!

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

他にも様々なEIPについてまとめています。

概要

ERC4337コントラクトアカウントの実装では、署名、nonce、calldata(呼び出しデータ)のエンコード方法が異なります。 この違いにより、DApps、ウォレット、コントラクトアカウントツールが、特定のアカウントのSDK(ソフトウェア開発キット)を使用せずにコントラクトアカウントと連携することが難しくなります。 これにより、特定のベンダーに依存することになり、コントラクトアカウントの普及に悪影響を及ぼします。

この問題を解決するために、コントラクトアカウントの実装がアカウント固有のエンコードロジックをオンチェーンで実装できる標準的な方法を提案します。 これは、生の署名、nonce、calldataを入力として受け取り、適切にフォーマットされた形式で出力するメソッドの実装を行います。

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

動機

ERC4337のUserOperationをコントラクトアカウント用に構築するには、各コントラクトアカウント実装の詳細な理解が必要です。 これは、各実装がnonce、calldata、署名を異なる方法でエンコードすることが許容されているためです。

具体例

  • あるコントラクトアカウントではexecuteFooという関数を実行する可能性があります。
  • 別のコントラクトアカウントはexecuteBarという関数を実行する可能性があります。

このように、同じ操作を実行していても、使用する関数が異なるためcalldataも異なります。

UserOp送信時の課題

UserOperationを特定のコントラクトアカウントに送信するためには、以下のステップが必要です。

  1. どのコントラクトアカウント実装を使用しているかを特定
    • 各アカウントの実装方法を理解する必要があります。
  2. 正しくエンコード
    • 署名、nonce、calldataを正しくエンコードする。
    • もしくは、エンコード方法を知っている特定のアカウント専用のSDKを使用する。

問題点

多くのDApps、ウォレット、アカウントアブストラクション(AA)ツールは特定のコントラクトアカウント実装に依存しているため、断片化が発生します。 これにより、特定のベンダーに依存してしまい、異なる実装同士での相互運用性が損なわれます。

仕様

UserOperation Builder

この規格に準拠するコントラクトアカウントの実装は、以下で定義されているIUserOperationBuilderインターフェースを実装する必要があります。

IUserOperationBuilderインターフェース

js
struct Execution {
    address target;
    uint256 value;
    bytes callData;
}

interface IUserOperationBuilder {
    /**
     * @dev Returns the ERC-4337 EntryPoint that the account implementation
     * supports.
     */
    function entryPoint() external view returns (address);
    
    /**
     * @dev Returns the nonce to use for the UserOp, given the context.
     * @param smartAccount is the address of the UserOp sender.
     * @param context is the data required for the UserOp builder to
     * properly compute the requested field for the UserOp.
     */
    function getNonce(
        address smartAccount,
        bytes calldata context
    ) external view returns (uint256);
	
    /**
     * @dev Returns the calldata for the UserOp, given the context and
     * the executions.
     * @param smartAccount is the address of the UserOp sender.
     * @param executions are (destination, value, callData) tuples that
     * the UserOp wants to execute.  It's an array so the UserOp can
     * batch executions.
     * @param context is the data required for the UserOp builder to
     * properly compute the requested field for the UserOp. 
     */
    function getCallData(
        address smartAccount,
        Execution[] calldata executions,
        bytes calldata context
    ) external view returns (bytes memory);
    
    /**
     * @dev Returns a correctly encoded signature, given a UserOp that
     * has been correctly filled out except for the signature field.
     * @param smartAccount is the address of the UserOp sender.
     * @param userOperation is the UserOp.  Every field of the UserOp should
     * be valid except for the signature field.  The "PackedUserOperation"
     * struct is as defined in ERC-4337.
     * @param context is the data required for the UserOp builder to
     * properly compute the requested field for the UserOp.
     */
    function formatSignature(
        address smartAccount,
        PackedUserOperation calldata userOperation,
        bytes calldata context
    ) external view returns (bytes memory signature);
}

Execution

js
struct Execution {
    address target;
    uint256 value;
    bytes callData;
}
  • target
    • 呼び出し先のアドレス。
  • value
    • 送金するEtherの量。
  • callData
    • 実行する関数の呼び出しデータ。

entryPoint

js
function entryPoint() external view returns (address);

コントラクトアカウント実装がサポートするERC4337のEntryPointのアドレスを返す関数。

getNonce

js
function getNonce(
    address smartAccount,
    bytes calldata context
) external view returns (uint256);

指定されたコントラクトアカウントアドレスとコンテキストに基づいて、UserOpに使用するnonceを返す関数。

  • 引数
    • smartAccount
      • UserOpの送信者のアドレス。
    • context
      • UserOpビルダーがUserOpのフィールドを正しく計算するために必要なデータ。

getCallData

js
function getCallData(
    address smartAccount,
    Execution[] calldata executions,
    bytes calldata context
) external view returns (bytes memory);

指定されたコントラクトアカウントアドレス、実行内容、コンテキストに基づいてUserOpのcalldataを返す関数。

  • 引数
    • smartAccount
      • UserOpの送信者のアドレス。
    • executions
      • 実行したい(ターゲット、送金額、呼び出しデータ)のタプルの配列。
      • バッチ実行が可能。
    • context
      • UserOpビルダーがUserOpのフィールドを正しく計算するために必要なデータ。

formatSignature

js
function formatSignature(
    address smartAccount,
    PackedUserOperation calldata userOperation,
    bytes calldata context
) external view returns (bytes memory signature);

署名フィールド以外の全てのフィールドが正しく入力されたUserOpに対して、正しい形式の署名を返す関数。

  • 引数
    • smartAccount
      • UserOpの送信者のアドレス。
    • userOperation
      • UserOp。
      • 署名フィールド以外の全てのフィールドが有効である必要があります。
    • context
      • UserOpビルダーがUserOpのフィールドを正しく計算するために必要なデータ。

UserOperation Builderの使用

以下の手順に従って、UserOp Builderを使用してUserOpを構築します。

1. UserOpBuilderのアドレスとコンテキストを取得

アカウントオーナーから UserOpBuilderのアドレスとコンテキストを取得します。 コンテキストは、構築する側から見ると内容がわからないバイト配列です。 UserOpBuilderの実装がUserOpの各値を正しく決定するためにコンテキストが必要になる場合があります。

2. マルチコール(バッチeth_call)の実行

getNonceとgetCallDataをコンテキストと実行内容(executions)を使ってマルチコール(バッチ化されたeth_call呼び出します。

  • getNonce
    • 指定されたコンテキストとアカウントアドレスに基づいてnonceを取得。
  • getCallData
    • 指定されたコンテキストと実行内容に基づいてcalldataを取得。

3. UserOpの作成

取得したデータ(nonceとcalldata)を使用してUserOpを作成します。 ガス値はランダムに設定しても良いですし、低く設定しても良いです。 このUserOpを使用してガス見積もり用のダミー署名を取得して、UserOpのハッシュに署名します。

4. formatSignatureの呼び出し

eth_callを使用して、formatSignatureを呼び出し、UserOpとコンテキストを使用して適切にフォーマットされたダミー署名を取得します。 このUserOpをガス見積もりに使用します。

5. ガス値の調整

UserOp内の既存のガス値を、適切なガス見積もりから得た値に変更します。 このUserOpは署名フィールド以外は有効である必要があります。 UserOpのハッシュに署名し、署名をUserOp.signatureフィールドに設定します。

6. formatSignatureの再呼び出し

eth_callを使用して、formatSignatureを再度呼び出し、完全に有効なUserOpを取得します。

7. 完全に有効なUserOpの取得

この時点で、完全に有効なUserOpが取得できています。 これをバンドラーに提出するために必要な操作を実行します。

注意点

UserOpにはnonce、calldata、署名以外にも多くのフィールドがありますが、これらはコントラクトアカウントの実装に大きく依存します。 コントラクトアカウントを構築する側がこれらの他のフィールドをどのように取得するかは実装次第です。。

アカウントがデプロイされていない時にUserOp Builderを使用

デプロイされていないアカウントに対してUserOp Builderを使用する方法

まだデプロイされていないアカウントに対してUserOpを構築する場合、UserOp Builderを利用する際の手順を以下のように変更します:

1. 追加情報の取得
  • UserOpBuilderのアドレスとコンテキストに加えて、ERC-4337で定義されているファクトリー(factory)とファクトリーデータ(factoryData)も取得します。
  • factory: アカウントをデプロイするためのコントラクト。
  • factoryData: ファクトリーがアカウントをデプロイするために必要なデータ。
2. CounterfactualCallコントラクトの使用
  • UserOpBuilderのビュー関数を呼び出す際に、eth_callを使用してCounterfactualCallコントラクトをデプロイします。このコントラクトは、アカウントをデプロイし、UserOpBuilderを呼び出します。
3. UserOpの構築
  • UserOpを構築する際に、factoryとfactoryDataを含めます。

CounterfactualCallコントラクトの動作

CounterfactualCallコントラクトは次のように動作します。

  1. アカウントのデプロイ

    • building party(UserOpを構築するDappsやウォレット)が提供したERC4337で定義されているfactory(新しいアカウントをデプロイするためのコントラクト)とfactoryData(アカウントをデプロイするために必要な追加データ)を使用してアカウントをデプロイします。
    • デプロイが成功しなかった場合は、revertします。
  2. UserOpBuilderの呼び出し

    • アカウントが正常にデプロイされた場合、UserOpBuilderを呼び出し、そのデータをbuilding partyに返します。

補足

Context(コンテキスト)

コンテキストは、UserOpビルダーがnonce、calldata、署名を正しく決定するために必要なデータをエンコードしたバイト配列です。 通常、このコンテキストはアカウントオーナーがウォレットソフトウェアを使って作成します。

委任

アカウントオーナーがトランザクションの実行をbuilding party(コントラクトアカウントを構築する側)に委任したい場合を考えます。 この場合、アカウントオーナーはbuilding partyの公開鍵に対する署名をコンテキスト内にエンコードします。 この署名を「authorization」と呼びます。

  1. 認可のエンコード
    • アカウントオーナーがbuilding partyの公開鍵に署名してコンテキストに含めます。
  2. UserOpの署名フィールド
    • building partyは自身の秘密鍵で署名を生成し、UserOpの署名フィールドに入力します。
  3. getSignatureの呼び出し
    • UserOpビルダーがコンテキストからauthorizationを抽出し、building partyの署名と連結します。
  4. 署名の検証
    • コントラクトアカウントは署名からbuilding partyの公開鍵を復元し、その公開鍵がauthorizationによって署名されていることを確認します。
    • 確認が成功すれば、UserOpを実行します。

Dummy Signature(ダミー署名)

ダミー署名は、bundler(バンドラー)がUserOpのガスを見積もるために使用される署名です(eth_estimateUserOperationGasを使用)。 この段階では有効な署名はまだ存在しません。 なぜなら、有効な署名自体がUserOpのガス値に依存しているため、循環依存が発生するからです。 この循環依存を解消するために、ダミー署名が使用されます。

ダミー署名の作成

ダミー署名は単なる固定値ではなく、コントラクトアカウントの署名検証ロジックに応じて構築されます。 ダミー署名は、実際の署名とほぼ同じガスを消費するように設計されます。 そのため、ダミー署名はコントラクトアカウントの実装に依存します。

互換性

この規格は、EntryPointのバージョン0.7の全てのERC4337コントラクトアカウントとの互換性があります。

EntryPointのバージョン0.6以下に対しては、PackedUserOperationが異なるため、バージョンに従ってIUserOperationBuilderインターフェースを修正する必要があります。

実装

Counterfactual Call Contract

Counterfactual Call Contractは、まだデプロイされていないコントラクトアカウントに対して処理の実行を行うメカニズムを提供します。 これは、ERC6492に影響されていて、ERC1271のisValidSignatureをプレデプロイされたコントラクトに対して実行する仕組みを利用しています。

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

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

コントラクト

js
contract CounterfactualCall {
    
    error CounterfactualDeployFailed(bytes error);

    constructor(
        address smartAccount,
        address create2Factory, 
        bytes memory factoryData,
        address userOpBuilder, 
        bytes memory userOpBuilderCalldata
    ) { 
        // スマートアカウントがデプロイされていない場合、デプロイを試みる
        if (address(smartAccount).code.length == 0) {
            (bool success, bytes memory ret) = create2Factory.call(factoryData);
            // デプロイに失敗した場合はエラーをスロー
            if (!success || address(smartAccount).code.length == 0) revert CounterfactualDeployFailed(ret);
        }

        // userOpBuilderを呼び出し、結果を返す
        assembly {
            let success := call(gas(), userOpBuilder, 0, add(userOpBuilderCalldata, 0x20), mload(userOpBuilderCalldata), 0, 0)
            let ptr := mload(0x40)
            returndatacopy(ptr, 0, returndatasize())
            if iszero(success) {
                revert(ptr, returndatasize())
            }
            return(ptr, returndatasize())
        }
    }
    
}

コントラクトの動作

  1. スマートアカウントのデプロイ確認
    • constructor内で、指定されたsmartAccountがまだデプロイされていない場合、create2Factoryを使用してデプロイを実行します。
    • デプロイが成功しなければ、CounterfactualDeployFailedエラーを投げます。
  2. userOpBuilderの呼び出し
    • アセンブリコードを使用して、userOpBuilderを呼び出し、指定されたuserOpBuilderCalldataを実行します。
    • 呼び出しが成功しなければ、エラーを投げて、成功すれば結果を返します。

ethersとviemライブラリを使用した呼び出し例

以下は、ethersとviemライブラリを使用してこのコントラクトを呼び出す例です。

ethers

js
const nonce = await provider.call({
  data: ethers.utils.concat([
    counterfactualCallBytecode,
    (
      new ethers.utils.AbiCoder()).encode(['address','address', 'bytes', 'address','bytes'], 
      [smartAccount, userOpBuilder, getNonceCallData, factory, factoryData]
    )
  ])
});
  • counterfactualCallBytecode
    • CounterfactualCallコントラクトのバイトコード。
  • smartAccount
    • スマートアカウントのアドレス。
  • userOpBuilder
    • UserOpBuilderのアドレス。
  • getNonceCallData
    • getNonce関数を呼び出すためのデータ。
  • factory
    • ファクトリーコントラクトのアドレス。
  • factoryData
    • ファクトリーコントラクトに渡すデータ。

viem

js
const nonce = await client.call({
  data: encodeDeployData({
    abi: parseAbi(['constructor(address, address, bytes, address, bytes)']),
    args: [smartAccount, userOpBuilder, getNonceCalldata, factory, factoryData],
    bytecode: counterfactualCallBytecode,
  })
});
  • encodeDeployData
    • デプロイデータをエンコードする関数。
  • parseAbi
    • ABIを解析する関数。
  • counterfactualCallBytecode
    • CounterfactualCallコントラクトのバイトコード。
  • smartAccount
    • コントラクトアカウントのアドレス。
  • userOpBuilder
    • UserOpBuilderのアドレス。
  • getNonceCalldata
    • getNonce関数を呼び出すためのデータ。
  • factory
    • ファクトリーコントラクトのアドレス。
  • factoryData
    • ファクトリーコントラクトに渡すデータ。

セキュリティ

ダミー署名のセキュリティ

ダミー署名は、ガス見積もりに使用されますが、中間者攻撃によって通信内容が盗みられる可能性があります。 しかし、ダミー署名は最終的なUserOpが提出された後には使えなくなるため、リスクは非常に低いです(両方のUserOpが同じnonceを使用するため)。

この小さなリスクをさらに軽減するために、ダミー署名を得るために署名されるUserOpには非常に低いガス値を設定しておくことが推奨されます。 これにより、ダミー署名が公開されたとしても、そのUserOpには低いガス値が設定された状態でトランザクションが実行されるようになります。

ガス代が低いと実行に失敗したり、トランザクションが長時間pendingの状態になります。

引用

Derek Chiang (@derekchiang), Garvit Khatri (@plusminushalf), Fil Makarov (@filmakarov), Kristof Gazso (@kristofgazso), Derek Rein (@arein), Tomas Rocchi (@tomiir), bumblefudge (@bumblefudge), "ERC-7679: UserOperation Builder [DRAFT]," Ethereum Improvement Proposals, no. 7679, April 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7679.

最後に

今回は「異なるERC4337で提案されているコントラクトアカウントのエンコードロジックを標準化し、コントラクトアカウントの実装に依存しないUserOpビルダーを使用する仕組みを提案しているERC7679」についてまとめてきました! いかがだったでしょうか?

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

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