はじめに

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

今回は、EIP712のドメインからchainIdを省略し、チェーンごとの操作を配列としてエンコードすることで、1つの署名で複数チェーンにまたがる操作を安全に検証できる仕組みを提案しているERC7964についてまとめていきます!

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

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

概要

ERC7964とは、EIP712の署名を使って複数のブロックチェーンにまたがる操作を1つの署名でまとめて承認できるようにする提案です。

Ethereumの署名にはどのチェーンで使うかを示すchainIdが含まれていますが、これがあると署名はそのチェーンでしか使えません。 ERC7964では、このchainIdをあえて署名のドメイン情報から省略します。 その代わり、チェーンごとの操作を配列(リスト)としてメッセージに含め、各チェーンが自分に関係する操作だけを検証する仕組みになっています。

この提案の大きな特徴は、EIP712の標準的なエンコーディングだけを使っている点です。 特殊なウォレット対応は不要で、EIP712をサポートする既存のウォレットであればそのままクロスチェーン署名を作成・表示できます。 ユーザーは署名する時に、どのチェーンでどんな操作が行われるかをウォレット上ですべて確認できます。

動機

チェーンごとに署名が必要な現状の課題

現在のアカウント抽象化(Account Abstraction)のソリューションでは、ブロックチェーンのネットワークごとに別々の署名が必要です。 例えば、EthereumとArbitrumの両方で操作を行いたい場合、ユーザーはそれぞれのチェーン向けに1つずつ、合計2つの署名を作成しなければなりません。 5つのチェーンで操作するなら5つの署名が必要です。

アカウント抽象化(Account Abstraction)

従来のEthereum(EOA)では秘密鍵で直接トランザクションに署名しますが、アカウント抽象化ではスマートコントラクトがアカウントとして機能し、署名検証のロジックをカスタマイズできます。

この制約が特にユーザー体験を悪化させるケースがあります。

  • クロスチェーンインテント ユーザーが複数チェーン間で資産を取引する時、例えば「EthereumでUSDCを売ってArbitrumでETHを受け取る」といった操作を、本来は1つの意思表示(インテント)で完結させたいにもかかわらず、チェーンの数だけ署名が必要になります。
  • マルチチェーンDAOガバナンス 複数チェーンにデプロイされたプロトコルの投票では、各チェーンのDAOコントラクトに対して個別に署名して投票する必要があります。
  • 統一アカウント管理 同じスマートアカウントが複数チェーンにデプロイされている場合、署名者の追加やしきい値の変更をチェーンごとに行わなければなりません。
  • クロスチェーンソーシャルリカバリ アカウントのリカバリを複数チェーンで一括して行いたい場合でも、チェーンの数だけ署名が必要になります。

既存アプローチの限界

クロスチェーン署名の問題を解決しようとする既存のアプローチには、大きく2つの手法があります。

Merkleツリー方式は、各チェーンの操作をMerkleツリーのリーフにして、そのルートに署名します。 しかし、標準的なEIP712ウォレットではMerkleツリーのリーフを表示できません。 ユーザーはMerkleルートという意味不明なハッシュ値に署名することになり、実際にどんな操作が含まれているかをウォレットで確認できません。 悪意のあるアプリケーションがリーフに不正な操作を仕込んでも、ユーザーは気づけないのです。

もう1つの手法として、独自のエンコーディング方式を定義するアプローチもありますが、ウォレット側で特別な対応が必要になるため普及が進んでいません。

ERC7964は、EIP712の標準的な配列型エンコーディングを使うことでこの問題を解決しています。 すべての操作がメッセージの一部として透明に表示されるため、ユーザーは署名する前にウォレット画面で全操作を確認できます。

仕様

クロスチェーンドメインの定義

EIP712の署名にはドメインセパレータ(EIP712Domain)という識別情報が含まれます。 通常はここにchainIdを設定して、署名が特定のチェーンでのみ有効になるようにします。

ERC7964では、このドメインセパレータからchainIdフィールドを省略します。 EIP712の仕様ではchainIdはもともとオプション項目なので、省略しても仕様に準拠しています。 chainIdを省略することで、「この署名は複数チェーンで使う意図がある」ということを示します。

通常、ドメインセパレータにはnameとversionだけを含めます。 verifyingContractフィールドは、同じアドレスに確定的にデプロイされたアプリケーション(すべてのチェーンで同一アドレス)に署名を紐づけたい場合に使用できます。 この場合、チェーン固有の構造体でコントラクトアドレスを指定する必要がなくなります。

以下は、クロスチェーン用のドメイン定義の例です。

javascript
EIP712Domain: [
  { name: "name", type: "string" },
  { name: "version", type: "string" }
  // chainId は省略 → クロスチェーン署名
]

chainIdが省略されているため、この署名はどのチェーンにも提出できるようになります。 ただし、各チェーンで実行される操作の内容はメッセージ本体の配列で指定されるため、無制限に任意の操作が実行されるわけではありません。

メッセージ配列のエンコーディング

クロスチェーン操作は、メッセージ構造体の配列としてエンコードされます。 配列の各要素が1つのチェーンでの操作を表し、その中にチェーンID、対象コントラクトアドレス、実行するデータなどを含みます。

以下は、チェーン固有の操作を定義する構造体の例です。

javascript
ChainOperation: [
  { name: "domain", type: "EIP712ChainDomain" },
  { name: "target", type: "address" },
  { name: "value", type: "uint256" },
  { name: "data", type: "bytes" }
],
EIP712ChainDomain: [
  { name: "chainId", type: "uint256" },
  { name: "verifyingContract", type: "address" }
]

各チェーン固有の構造体にはEIP712ChainDomainが含まれます。 この中にchainIdとverifyingContractを含めることで、各操作がどのチェーンのどのコントラクトを対象としているかを明示します。

ここでのポイントは、メインのドメイン(EIP712Domain)にはchainIdを入れず、各操作の構造体(EIP712ChainDomain)にchainIdを入れるという二段構えになっていることです。 これにより、署名全体はチェーンに依存しないが、個々の操作は特定のチェーンに紐づくという設計が実現されています。

EIP712の仕様では、配列は以下のようにエンコードされます。

text
keccak256(encodeData(element[0]) ‖ encodeData(element[1]) ‖ ... ‖ encodeData(element[n]))

各要素は構造体なので再帰的にハッシュ化されます。 実際の処理では、各チェーン操作の構造体ハッシュ(bytes32型、32バイト)を計算し、それらを連結してkeccak256でハッシュ化します。

以下のmermaid図は、メッセージ配列のエンコーディングフローを示しています。

graph TD
    A["ChainOperation[0]<br/>chainId: 1 (Ethereum)"] --> D["hashStruct(op[0])<br/>= bytes32 ハッシュ"]
    B["ChainOperation[1]<br/>chainId: 42161 (Arbitrum)"] --> E["hashStruct(op[1])<br/>= bytes32 ハッシュ"]
    C["ChainOperation[2]<br/>chainId: 137 (Polygon)"] --> F["hashStruct(op[2])<br/>= bytes32 ハッシュ"]
    D --> G["abi.encodePacked(hash0, hash1, hash2)"]
    E --> G
    F --> G
    G --> H["keccak256(...)<br/>= 配列ハッシュ"]

各チェーンの操作がそれぞれハッシュ化され、最終的に1つの配列ハッシュにまとめられます。

構造体ハッシュ

署名は操作の配列だけでなく、メイン構造体全体に対して計算されます。 メイン構造体には、操作の配列に加えてnonce(再利用防止番号)やdeadline(有効期限)などの共通フィールドを含めることができます。

以下は、クロスチェーンインテント用のメイン構造体の例です。

javascript
bytes32 constant CROSSCHAIN_INTENT_TYPEHASH = keccak256(
    "CrossChainIntent(ChainOperation[] operations,uint256 nonce,uint256 deadline)ChainOperation(EIP712ChainDomain domain,address target,uint256 value,bytes data)EIP712ChainDomain(uint256 chainId,address verifyingContract)"
);

fullStructHash = keccak256(abi.encode(
    CROSSCHAIN_INTENT_TYPEHASH,
    keccak256(abi.encodePacked(chainOperationsArray)),
    nonce,
    deadline
));

CROSSCHAIN_INTENT_TYPEHASHは、メイン構造体の型を表す固定のハッシュ値です。 この型ハッシュには、ネストされた構造体(ChainOperationやEIP712ChainDomain)の定義も含まれています。

メイン構造体のハッシュは、型ハッシュ、操作配列のハッシュ、nonce、deadlineをエンコードしてkeccak256で計算します。 アプリケーションは、デプロイされているすべてのチェーンで同じ型ハッシュを使用します。

メイン構造体のprimaryType(例えばCrossChainIntentやMultiChainVote)はアプリケーションが自由に定義できます。 この提案では特定のprimaryTypeを強制せず、柔軟な実装を可能にしています。

オンチェーン検証

アプリケーションがクロスチェーン署名を検証するためには、署名データにメタデータを付加する必要があります。 この提案では、標準的なECDSA検証やERC1271のisValidSignature呼び出しに対応するために、特別なバイナリエンコーディング形式を定義しています。

ERC1271

コントラクトウォレットが署名検証を行うための標準インターフェースです。 EOA(Externally Owned Account)ではなくスマートコントラクトが「この署名は正しい」と判断するための仕組みです。

署名エンコーディングの構造

署名データは以下のバイナリ構造でエンコードされます。

フィールド サイズ 説明
magic 9バイト クロスチェーン署名であることを示す固定値0x796479647964796479
fields 1バイト EIP712ドメインに含まれるフィールドの種類(ERC5267に準拠)
structIndex 2バイト 現在のチェーンに対応する操作配列のインデックス
application 20バイト ERC5267を実装したEIP712ドメイン情報を提供するコントラクトのアドレス
structsArrayOffset 32バイト structsArrayの開始位置へのオフセット(バイト)
structsArrayLength 32バイト structsArrayの要素数
structsArray N × 32バイト 各チェーン操作の構造体ハッシュの配列
crossChainSignatureOffset 32バイト crossChainSignatureの開始位置へのオフセット(バイト)
crossChainSignatureLength 32バイト crossChainSignatureのバイト長
crossChainSignature 可変長 実際の署名データ

ERC5267

EIP712のドメインセパレータ情報を取得するための標準インターフェースです。 eip712Domain()関数を呼び出すと、ドメインのname、version、chainId、verifyingContractなどの情報が返されます。

最初の32バイトにはheader情報(magic、fields、structIndex、application)がパッキングされ、その後にABIエンコーディングでstructsArrayとcrossChainSignatureが続きます。

javascript
bytes32 header = bytes32(abi.encodePacked(
    CrossChainSignatureChecker.ERC7964_MAGIC,
    fields,
    structIndex,
    address(application)
));
abi.encode(header, structsArray, crossChainSignature);
検証フロー

アプリケーションは、署名の先頭9バイトがmagic値0x796479647964796479と一致するかどうかで、クロスチェーン署名かどうかを判定します。 クロスチェーン署名であると判定された場合、以下のステップで検証を行います。

以下のmermaid図は、検証フローの全体像を示しています。

sequenceDiagram
    participant App as アプリケーション
    participant Sig as 署名データ
    participant ERC5267 as ERC5267コントラクト
    participant Verify as 署名検証

    App->>Sig: 1. メタデータをパース
    App->>App: 2. structsArray[structIndex]と<br/>現在のチェーンの操作ハッシュを照合
    App->>ERC5267: 3. eip712Domain()を呼び出し
    ERC5267-->>App: ドメイン情報を返す
    App->>App: 4. ドメイン+メイン構造体ハッシュ+<br/>配列ハッシュからEIP712ハッシュを再構築
    App->>Verify: 5. crossChainSignatureを検証
    Verify-->>App: 検証結果
  1. 署名データからメタデータ(fields、structIndex、application、structsArray、crossChainSignature)をパースします。
  2. structsArray[structIndex]が現在のチェーンの操作のハッシュと一致するか確認します。
  3. applicationで指定されたERC5267コントラクトからEIP712ドメイン情報を取得します。
  4. ドメイン情報、メイン構造体ハッシュ、配列ハッシュから完全なEIP712ハッシュを再構築します。
  5. crossChainSignatureを再構築したハッシュに対して検証します。

ウォレット表示

ERC7964は標準的なEIP712エンコーディングのみを使用しているため、EIP712に対応した既存のウォレットであれば、クロスチェーンメッセージをそのまま表示できます。

ウォレットの署名画面には、メッセージの一部として操作配列が表示されます。 ユーザーは各チェーンでどんな操作が実行されるかを確認してから署名できるため、不正な操作が隠されるリスクがありません。

ウォレット開発者は、操作をチェーンごとにグルーピングしたり、チェーンIDの代わりにチェーン名(Ethereum、Arbitrumなど)を表示したりすることで、ユーザー体験をさらに向上させることもできます。

補足

配列方式とMerkleツリー方式の比較

クロスチェーン署名を実現するアプローチとして、ERC7964が採用した配列方式と、既存のMerkleツリー方式があります。 両者にはトレードオフがあります。

以下の図は、2つの方式の違いを視覚的に示しています。

comparison.png

透明性

配列方式の最大の利点は透明性です。 Merkleツリー方式では、ユーザーがMerkleルートというハッシュ値に署名するため、実際にどんな操作が含まれているかをウォレットで確認できません。 ウォレットがすべてのリーフを表示するには、アプリケーションからリーフを取得し、Merkleツリーの構築を検証し、すべての操作を表示し、不正な操作がないか確認するという追加のロジックが必要です。 つまり、ユーザーはアプリケーションが正しくリーフを提供していることを信頼しなければなりません。

配列方式では、すべての操作がEIP712メッセージの一部として標準的に表示されます。 特別なウォレット対応は不要で、不正な操作を隠すことは構造上不可能です。

オンチェーンオーバーヘッド

データサイズの面では、Merkleツリー方式のほうが理論上効率的です。 ただし、一般的なクロスチェーン操作の規模(2〜5チェーン)では、その差はごくわずかです。

チェーン数 配列方式(N × 32バイト) Merkleツリー方式(⌈log₂(N)⌉ × 32バイト) 差分
2 64バイト(2 × 32) 32バイト(1 × 32) 32バイト(ハッシュ1個分)
3 96バイト(3 × 32) 64バイト(2 × 32) 32バイト(ハッシュ1個分)
4 128バイト(4 × 32) 64バイト(2 × 32) 64バイト(ハッシュ2個分)
5 160バイト(5 × 32) 96バイト(3 × 32) 64バイト(ハッシュ2個分)
8 256バイト(8 × 32) 96バイト(3 × 32) 160バイト(ハッシュ5個分)

2〜5チェーンでは、Merkleツリー方式がデータサイズで節約できるのは32〜64バイト(ハッシュ1〜2個分)にすぎません。 Merkleツリーが大きく有利になるのは8チェーン以上ですが、それだけのチェーンにまたがる操作は稀であり、ユーザーの理解やエラー発生時の問題切り分けの観点から、複数の署名に分割するほうが適切です。

chainIdを省略する理由

chainIdを0に設定する方法もありますが、ERC7964ではchainIdフィールドそのものを省略しています。 0に設定する場合、0が「クロスチェーン用」という特殊な意味を持つ値になってしまいます。 一方、フィールドを省略すれば「chainIdは指定されていない = チェーンに依存しない」という意味が明確になります。 EIP712ではドメインのフィールドはすべてオプションなので、省略は仕様上も正当です。

署名エンコーディングの設計

署名のエンコーディング形式は、calldata(コントラクトに送信されるデータ)とメモリのオーバーヘッドを最小化するように設計されています。

magic値(0x796479647964796479)は9バイトに設定されています。 これはfields(1バイト)、structIndex(2バイト)、application(20バイト)と合わせてちょうど32バイト(1ワード)に収まるためです。 9バイトのmagic値は32バイトのハッシュよりも短いですが、攻撃者がこの先頭9バイトと一致する有効なECDSA署名やERC1271署名を偶然生成する確率は2^-72(約4.7 × 10^21分の1)であり、実用上は衝突の心配は不要です。

applicationアドレスはERC5267のeip712Domain()関数を実装した任意のコントラクトを指します。 このコントラクトは操作を実行するコントラクトである必要はなく、EIP712ドメイン情報を提供するだけの専用コントラクトでもかまいません。 すべてのチェーンで一貫したドメイン情報を保証するために、イミュータブル(変更不可)なドメインセパレータコントラクトを別途デプロイすることもできます。

互換性

ERC7964は標準的なEIP712を拡張や変更なしで使用しているため、EIP712をサポートする既存のウォレットやアプリケーションはすぐにクロスチェーン署名を扱えます。 ただし、クロスチェーン固有のセマンティクス(意味)を理解するには、この提案で定義された検証パターンを実装する必要があります。

安全なデフォルト動作として、ドメインに特定のchainIdを含めて署名を検証しているアプリケーションは、chainIdが省略されたクロスチェーン署名を自動的に拒否します。 つまり、クロスチェーン署名に対応していないアプリケーションでは署名が通らないため、意図しない動作が起きるリスクはありません。 クロスチェーン署名をサポートしたいアプリケーションは、明示的にこの提案の検証パターンを実装する必要があります。

セキュリティ

クロスチェーンリプレイ

ERC7964は複数チェーンでの署名の再利用を意図的に許可する設計です。 verifyingContractフィールドでユーザーのアカウントアドレスに紐づけることで、他者のアカウントでの不正利用は防止されます。 ただし、同じチェーンでの再利用を防ぐためには、アプリケーション側でnonce(一度きりの番号)やdeadline(有効期限)をメッセージに含めて独自のリプレイ保護を実装する必要があります。

アカウント検証

署名を検証するアプリケーションは、署名者のアカウントが現在のチェーン上に存在するかを確認する必要があります。 例えば、Ethereum上に存在するアカウントの署名がPolygon上で受け付けられてしまうと、存在しないアカウントの操作が承認されることになります。 まだデプロイされていないカウンターファクチュアルアカウント(将来のアドレスが事前にわかっているがまだコントラクトがデプロイされていないアカウント)については、ERC6492を使用して署名を検証します。

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

コードと状態の差異

同じアドレスであっても、チェーンが異なればコントラクトのコードや状態が異なる場合があります。 あるチェーンでisValidSignature()(ERC1271の署名検証関数)が成功しても、別のチェーンでは状態の違いやチェーン固有のロジックによって失敗する可能性があります。 例えば、メッセージに含まれるnonceがすでにあるチェーンでは使用済みになっていることもあります。 アプリケーションは署名検証の失敗を適切に処理し、すべてのチェーンで均一な動作を前提としないようにする必要があります。

部分実行リスク

クロスチェーン署名はアトミック(全部成功か全部失敗)な実行を保証しません。 一部のチェーンでは署名が検証されて操作が実行されたが、他のチェーンでは失敗するという状況が発生し得ます。 例えば、クロスチェーントレードで一方のチェーンで資産を売却したのに、もう一方のチェーンで資産の取得に失敗するケースが考えられます。

さらに、ネットワークの混雑状況やブロック生成速度の違いにより、チェーンをまたいだ操作の実行順序が意図と異なる場合もあります。 アプリケーションは、操作が順序に依存しないように設計するか、適切な順序制御メカニズムを実装する必要があります。 また、部分的に失敗した場合のリファンド(返金)メカニズムを用意することも推奨されます。

署名の有効期限

明示的な有効期限がない署名は、理論上いつまでも有効です。 一部のチェーンで操作が実行されなかった場合、後から予期しないタイミングで実行される可能性があります。 これを防ぐために、メッセージにdeadlineやvalidUntilフィールドを含めることが推奨されます。

ユーザーはクロスチェーン署名を行う時、メッセージ配列のすべての操作を確認し、各操作が正しいチェーンIDを対象としているか、各チェーンでの操作の金額とアドレスが正しいかを確認する必要があります。 また、操作がチェーンごとに独立して実行される(アトミックではない)ことを理解しておく必要があります。

参考実装

CrossChainSignatureCheckerライブラリ

以下は、クロスチェーン署名を検証するためのライブラリの参考実装です。 OpenZeppelinのSignatureChecker、MessageHashUtils、IERC5267などを活用しています。

CrossChainSignatureChecker.sol
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {IERC5267} from "@openzeppelin/contracts/interfaces/IERC5267.sol";
import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol";

library CrossChainSignatureChecker {
    bytes9 internal constant ERC7964_MAGIC = 0x796479647964796479;

    function isValidCrossChainSignatureNow(
        address signer,
        bytes32 hash,
        bytes calldata erc7964Signature,
        function(bytes32) internal view returns (bytes32) structHash
    ) internal view returns (bool) {
        (
            bool success,
            bytes1 fields,
            uint16 structIndex,
            address application,
            bytes32[] calldata structsArray,
            bytes calldata crossChainSignature
        ) = parseCrossChainSignature(erc7964Signature);
        if (!success || structsArray[structIndex] != hash) return false;

        string memory name;
        string memory version;
        uint256 chainId;
        address verifyingContract;
        bytes32 domainSalt;
        (, name, version, chainId, verifyingContract, domainSalt, ) =
            IERC5267(application).eip712Domain();

        bytes32 typedHash = MessageHashUtils.toTypedDataHash(
            MessageHashUtils.toDomainSeparator(
                fields, name, version, chainId, verifyingContract, domainSalt
            ),
            structHash(keccak256(abi.encodePacked(structsArray)))
        );
        return SignatureChecker.isValidSignatureNowCalldata(
            signer, typedHash, crossChainSignature
        );
    }

    function parseCrossChainSignature(
        bytes calldata erc7964Signature
    )
        internal
        pure
        returns (
            bool success,
            bytes1 fields,
            uint16 structIndex,
            address application,
            bytes32[] calldata structsArray,
            bytes calldata crossChainSignature
        )
    {
        if (
            erc7964Signature.length < 0x9f ||
            bytes9(erc7964Signature[0:9]) != ERC7964_MAGIC
        ) {
            return (
                false, 0, 0, address(0),
                _empty32BytesArrayCalldata(), Calldata.emptyBytes()
            );
        }
        fields = erc7964Signature[9];
        structIndex = uint16(bytes2(erc7964Signature[10:]));
        application = address(bytes20(erc7964Signature[12:]));

        uint256 structsArrayOffset = uint256(bytes32(erc7964Signature[0x20:]));
        uint256 structsArrayDataOffset = structsArrayOffset + 32;
        if (
            structsArrayOffset < 0x60 ||
            structsArrayDataOffset > erc7964Signature.length
        ) {
            return (
                false, 0, 0, address(0),
                _empty32BytesArrayCalldata(), Calldata.emptyBytes()
            );
        }
        uint256 structsArrayLength = uint256(
            bytes32(erc7964Signature[structsArrayOffset:])
        );
        if (
            structsArrayDataOffset + structsArrayLength * 32 >
            erc7964Signature.length
        ) {
            return (
                false, 0, 0, address(0),
                _empty32BytesArrayCalldata(), Calldata.emptyBytes()
            );
        }

        uint256 crossChainSignatureOffset =
            structsArrayDataOffset + structsArrayLength * 32;
        uint256 crossChainSignatureDataOffset = crossChainSignatureOffset + 32;
        if (
            crossChainSignatureOffset < 0x60 ||
            crossChainSignatureDataOffset > erc7964Signature.length
        ) {
            return (
                false, 0, 0, address(0),
                _empty32BytesArrayCalldata(), Calldata.emptyBytes()
            );
        }
        uint256 crossChainSignatureLength = uint256(
            bytes32(erc7964Signature[crossChainSignatureOffset:])
        );
        if (
            crossChainSignatureDataOffset + crossChainSignatureLength >
            erc7964Signature.length
        ) {
            return (
                false, 0, 0, address(0),
                _empty32BytesArrayCalldata(), Calldata.emptyBytes()
            );
        }

        assembly ("memory-safe") {
            structsArray.offset := add(
                erc7964Signature.offset, structsArrayDataOffset
            )
            structsArray.length := structsArrayLength
        }
        crossChainSignature = erc7964Signature[
            crossChainSignatureDataOffset:
            crossChainSignatureDataOffset + crossChainSignatureLength
        ];
        return (
            true, fields, structIndex, application,
            structsArray, crossChainSignature
        );
    }

    function _empty32BytesArrayCalldata()
        private
        pure
        returns (bytes32[] calldata result)
    {
        assembly ("memory-safe") {
            result.offset := 0
            result.length := 0
        }
    }
}

isValidCrossChainSignatureNow関数は、署名者アドレス、現在のチェーンの操作ハッシュ、エンコードされた署名、そしてメイン構造体ハッシュを計算するコールバック関数を受け取ります。 内部では署名をパースし、structsArray[structIndex]が渡されたハッシュと一致するか確認し、ERC5267から取得したドメイン情報でEIP712ハッシュを再構築して検証を行います。

parseCrossChainSignature関数は、エンコードされた署名データをパースしてheader情報(fields、structIndex、application)と、動的データ(structsArray、crossChainSignature)を抽出します。 各ステップでデータの整合性(オフセットの妥当性、配列長のオーバーフローなど)をチェックし、不正なデータの場合はsuccess = falseを返します。

クロスチェーンインテントの例

以下は、ユーザーが「EthereumでUSDCを売却し、ArbitrumでETHを受け取る」というクロスチェーントレードを実行する例です。

署名メッセージ
javascript
{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
      // chainId は省略
    ],
    CrossChainIntent: [
      { name: "operations", type: "ChainOperation[]" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" }
    ],
    ChainOperation: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "target", type: "address" },
      { name: "value", type: "uint256" },
      { name: "data", type: "bytes" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "CrossChainIntent",
  domain: {
    name: "CrossChainDEX",
    version: "1"
  },
  message: {
    operations: [
      {
        domain: {
          chainId: 1,                    // Ethereum
          verifyingContract: "0x123321..." // Ethereum上のアカウント
        },
        target: "0xA0b86a33E6776885F5Db...", // USDCコントラクト
        value: 0,
        data: "0xa9059cbb..."               // transfer(settler, 1000 USDC)
      },
      {
        domain: {
          chainId: 42161,                // Arbitrum
          verifyingContract: "0x123321..." // Arbitrum上のアカウント
        },
        target: "0xArbitrumSettler...",
        value: 0,
        data: "0x3ccfd60b..."               // claim(最低0.5 ETH)
      }
    ],
    nonce: 42,
    deadline: 1704067200
  }
}

ウォレットにはこのメッセージが表示され、ユーザーはEthereumでUSDCを送信し、Arbitrumで最低0.5 ETHを受け取るという操作をすべて確認できます。

オンチェーン検証コントラクト
CrossChainDEX.sol
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
import {CrossChainSignatureChecker} from "./CrossChainSignatureChecker.sol";

contract CrossChainDEX is EIP712, Nonces {
    using CrossChainSignatureChecker for address;

    bytes32 public constant EIP712_CHAIN_DOMAIN_TYPEHASH =
        keccak256(
            "EIP712ChainDomain(uint256 chainId,address verifyingContract)"
        );
    bytes32 public constant CHAIN_OPERATION_TYPEHASH =
        keccak256(
            "ChainOperation(EIP712ChainDomain domain,address target,"
            "uint256 value,bytes data)"
            "EIP712ChainDomain(uint256 chainId,address verifyingContract)"
        );
    bytes32 public constant CROSSCHAIN_INTENT_TYPEHASH =
        keccak256(
            "CrossChainIntent(ChainOperation[] operations,"
            "uint256 nonce,uint256 deadline)"
            "ChainOperation(EIP712ChainDomain domain,address target,"
            "uint256 value,bytes data)"
            "EIP712ChainDomain(uint256 chainId,address verifyingContract)"
        );

    mapping(bytes32 operationHash => uint256) private _deadlines;

    constructor() EIP712("CrossChainDEX", "1") {}

    function executeIntent(
        address account,
        ChainOperation calldata currentOp,
        uint256 nonce,
        uint256 deadline,
        bytes calldata signature
    ) external {
        bytes32 currentOpHash = _structHash(currentOp);

        _useCheckedNonce(account, nonce);
        _deadlines[currentOpHash] = deadline;

        require(
            account.isValidCrossChainSignatureNowCalldata(
                currentOpHash,
                signature,
                _crossChainStructHash
            ),
            "Invalid signature"
        );

        (bool success, ) = currentOp.target.call{value: currentOp.value}(
            currentOp.data
        );
        require(success, "Execution failed");
    }

    function _crossChainStructHash(
        bytes32 operationsHash
    ) internal view returns (bytes32) {
        return keccak256(abi.encode(
            CROSSCHAIN_INTENT_TYPEHASH,
            operationsHash,
            nonces(account),
            _deadlines[operationsHash]
        ));
    }

    function _structHash(
        ChainOperation calldata op
    ) internal view returns (bytes32) {
        return keccak256(abi.encode(
            CHAIN_OPERATION_TYPEHASH,
            keccak256(abi.encode(
                EIP712_CHAIN_DOMAIN_TYPEHASH,
                block.chainid,
                address(this)
            )),
            op.target,
            op.value,
            keccak256(op.data)
        ));
    }
}

この参考実装は概念を示すための簡略化されたコードです。 _crossChainStructHash関数内のaccountの参照は、実際の実装ではストレージ変数やコンテキストから適切に取得する必要があります。

executeIntent関数では、まず現在のチェーンの操作のハッシュを計算し、nonceの消費とdeadlineの記録を行います。 その後、CrossChainSignatureCheckerを使ってクロスチェーン署名を検証します。

各チェーンには自分の操作のフルデータだけが送信されます。 他のチェーンの操作は32バイトのハッシュとしてstructsArrayに含まれるため、コントラクトに送信するデータ量が最小限に抑えられます。 例えばArbitrum側のsettlerコントラクトでも同じ署名を使い、structIndexを1に設定して検証を行います。

マルチチェーンガバナンスの例

DAOメンバーが複数チェーンのプロトコルインスタンスに対してまとめて投票する例です。

javascript
{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
    ],
    MultiChainVote: [
      { name: "votes", type: "ChainVote[]" },
      { name: "nonce", type: "uint256" }
    ],
    ChainVote: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "proposalId", type: "uint256" },
      { name: "support", type: "uint8" },
      { name: "reason", type: "string" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "MultiChainVote",
  domain: {
    name: "MultiChainDAO",
    version: "1"
  },
  message: {
    votes: [
      {
        domain: {
          chainId: 1,                    // Ethereum
          verifyingContract: "0x123321..." // EthereumのDAOコントラクト
        },
        proposalId: 42,
        support: 1,  // 賛成
        reason: "This upgrade improves security"
      },
      {
        domain: {
          chainId: 137,                  // Polygon
          verifyingContract: "0x321321..." // PolygonのDAOコントラクト
        },
        proposalId: 42,
        support: 1,  // 賛成
        reason: "This upgrade improves security"
      }
    ],
    nonce: 7
  }
}

1つの署名でEthereum上のDAOとPolygon上のDAOの両方に投票できます。 各チェーンのDAOコントラクトは同じ署名を受け取り、自分のチェーンに対応する投票データだけを検証・実行します。

統一アカウント管理の例

マルチシグウォレットに新しい署名者を追加する操作を、複数チェーンで一括して行う例です。

javascript
{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
    ],
    MultiChainAccountUpdate: [
      { name: "updates", type: "AccountUpdate[]" },
      { name: "nonce", type: "uint256" }
    ],
    AccountUpdate: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "operation", type: "uint8" },
      { name: "signerData", type: "bytes" },
      { name: "threshold", type: "uint256" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "MultiChainAccountUpdate",
  domain: {
    name: "MultiChainMultisig",
    version: "1"
  },
  message: {
    updates: [
      {
        domain: {
          chainId: 1,
          verifyingContract: "0x123321..."
        },
        operation: 0,  // addSigner
        signerData: "0x0102...1f20",
        threshold: 3
      },
      {
        domain: {
          chainId: 137,
          verifyingContract: "0x123321..."
        },
        operation: 0,  // addSigner
        signerData: "0x0102...1f20",
        threshold: 3
      },
      {
        domain: {
          chainId: 42161,
          verifyingContract: "0x123321..."
        },
        operation: 0,  // addSigner
        signerData: "0x0102...1f20",
        threshold: 3
      }
    ],
    nonce: 42
  }
}

Ethereum、Polygon、Arbitrumの3つのチェーンで同じアドレスにデプロイされたマルチシグウォレットに、1つの署名で同時に新しい署名者を追加し、しきい値を3に更新できます。

クロスチェーンソーシャルリカバリの例

ユーザーがアカウントへのアクセスを失い、ガーディアン(保護者)がリカバリを実行する例です。

javascript
{
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" }
    ],
    MultiChainRecovery: [
      { name: "recoveries", type: "ChainRecovery[]" },
      { name: "nonce", type: "uint256" }
    ],
    ChainRecovery: [
      { name: "domain", type: "EIP712ChainDomain" },
      { name: "newOwner", type: "address" }
    ],
    EIP712ChainDomain: [
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" }
    ]
  },
  primaryType: "MultiChainRecovery",
  domain: {
    name: "CrossChainSocialRecovery",
    version: "1"
  },
  message: {
    recoveries: [
      {
        domain: {
          chainId: 1,
          verifyingContract: "0x123321..."  // Ethereumのリカバリモジュール
        },
        newOwner: "0x9999999999999999999999999999999999999999"
      },
      {
        domain: {
          chainId: 137,
          verifyingContract: "0x321321..."  // Polygonのリカバリモジュール
        },
        newOwner: "0x9999999999999999999999999999999999999999"
      },
      {
        domain: {
          chainId: 42161,
          verifyingContract: "0x432432..."  // Arbitrumのリカバリモジュール
        },
        newOwner: "0x9999999999999999999999999999999999999999"
      }
    ],
    nonce: 1
  }
}

ガーディアンは1つの署名で3つのチェーンすべてのリカバリを承認できます。 3人のガーディアンがそれぞれ署名し(3-of-5のしきい値)、十分な数の署名が集まったら、各チェーンのリカバリモジュールで遅延付きリカバリがスケジュールされます。 遅延期間中は不正なリカバリを検知してキャンセルすることもできます。

引用

最後に

今回は「ERC7964による1つの署名で複数チェーンの操作をまとめて実行するクロスチェーンEIP712署名」についてまとめてきました。 いかがだったでしょうか?

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

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