はじめに

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

今回は、L1のストレージ処理をL2やオフチェーンデータベースへ安全にルーティングし、ガスコストを抑えつつ検証可能性を保つ仕組みを提案しているERC7700についてまとめていきます!

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

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

概要

ERC7700は、スマートコントラクトが「データの保存先」を外部システムに振り分けられるようにするための標準です。 ここでいう外部システムとは、EthereumのL1以外のストレージを指しており、具体的にはL2チェーンや、ブロックチェーン外のデータベースなどが対象になります。

EthereumのL1では、ストレージにデータを書き込むガス代が非常に高額です。 ERC7700はこの問題に対して、以下のような考え方を取ります。

  • データの保存処理そのものは、L1ではなく外部のストレージに任せる
  • L1のスマートコントラクトは、その外部ストレージへの「ルーティング」と「検証」だけを担う

このとき、外部ストレージへ書き込みを行う役割を担うのが「ストレージルーター」です。 ストレージルーターは、L1コントラクトの外側にありながら、あたかもL1コントラクトの一部であるかのように振る舞います。 そのため、ERC7700では「ストレージルーターはL1コントラクトの拡張である」と考えます。

ERC7700が想定しているストレージルーターの種類は、以下の3つです。

ルーター種別 説明
L1 Ethereumメインネット上のストレージ
L2 Optimistic Rollup や ZK Rollup などのL2チェーン
データベース ブロックチェーン外の一般的なデータベース

ERC7700によって外部ストレージに書き込まれたデータは、ERC3668(CCIP-Read)に準拠したコントラクトから取得できます。 これにより、「クロスチェーンでデータを書き込み、別のチェーンやL1から安全に読み出す」という一連の流れが完成します。

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

このように、ERC7700はERC3668と組み合わせることで、以下のクロスチェーンデータのライフサイクル全体をカバーします。

  • データの保存(Store)
  • データの取得(Read)

この仕組みは、クロスチェーン環境における安全でコスト効率の良いストレージ基盤を作るための重要な一歩です。

動機

ERC3668(CCIP-Read)が果たしてきた役割

ERC3668 は「CCIP-Read」とも呼ばれ、EthereumのL1コントラクトがクロスチェーンのデータを読み取るための標準として広く使われてきました。

具体的な利用例としては、以下のようなものがあります。

利用例 説明
DeFiの価格フィード 他チェーンやオフチェーンの価格情報をL1で利用
ENSのレコード ENSの名前解決情報を外部ストレージに保存

特にENSでは、L1にすべてのデータを保存するとガス代が高くなりすぎるため、レコード情報をクロスチェーンやオフチェーンに保存する方式が採用されています。 このように、「L1以外にデータを置くことでガス代を抑える」というアプローチは、ENS以外にも非常に多くの応用可能性があります。

読み取りは簡単だが、書き込みは難しい問題

ERC3668によるクロスチェーンデータの読み取りは、比較的シンプルです。 理由は、以下の前提があるからです。

  • 外部ストレージのデータは、CCIP-Read対応のHTTPゲートウェイを通じて取得される
  • L2チェーンもデータベースも、最終的にはHTTPレスポンスとして扱われる

そのため、L1側のコントラクトは「HTTP経由で受け取ったデータを検証する」だけで済みます。

一方で、「データを書き込む」処理については、これまで標準化されていませんでした。 その結果、EERC3668を利用する各サービスは、以下のようなことを個別に実装する必要がありました。

  • 外部ストレージへの安全な書き込み方法
  • L1でデータの正当性を検証する仕組み

ストレージ種別ごとのセキュリティ差

L2チェーンの場合、ロールアップの仕組み自体にセキュリティモデルが組み込まれています。 そのため、データの正当性検証はL2の設計に依存できます。

しかし、データベース型のストレージでは事情が異なります。 データベースはブロックチェーンではないため、次のような追加対策が必要になります。

  • 誰が書き込んだデータかを証明できること
  • データが改ざんされていないことをL1で検証できること

これらの仕組みを各サービスが独自に実装するのは、設計・実装ともに大きな負担になります。

想定されているユースケース

ERC7700が解決しようとしている課題は、以下のようなサービスで特に重要になります。

ユースケース 説明
外部ストレージ上の名前管理 ENSのように、L2やデータベースに保存された名前空間をL1ネイティブのように扱う
外部ストレージ上のデジタルID デジタルIDをL1コントラクトにあるかのように操作する

これらのサービスでは、「どこにデータが保存されているか」を利用者やアプリケーションが意識しないことが重要です。

ERC7700がもたらす意義

ERC7700は、ストレージの書き込み先を「ルーター」として抽象化します。 これにより、アプリケーション側は以下の点を意識せずに済みます。

  • データがL2にあるのか
  • データベースにあるのか
  • 将来別のストレージに移行するのか

結果として、基盤となるストレージ技術に依存しないサービス設計が可能になります。 ERC7700(CCIP-Store)は、クロスチェーン時代における「書き込み」の標準を定義することで、ERC3668が担ってきた読み取りと対を成す存在です。

仕様

ERC7700 は、スマートコントラクトが「どこにデータを書き込むか」を自分自身で判断し、その保存先を外部ストレージへルーティングするための仕組みを定義しています。 この仕様の中心となる役割を担うのが「クロスチェーンストレージルーター」です。

クロスチェーンストレージルーターは、L1コントラクトの内部ロジックとして存在しますが、実際のストレージ操作は次のような外部システムに委譲されます。

保存先 説明
L1 Ethereumメインネット
L2 Rollupなどのレイヤー2チェーン
データベース ブロックチェーン外のストレージ

ERC7700では、代表的なルーターとして以下が定義されています。

  • StorageRoutedToL1()
  • StorageRoutedToL2()
  • StorageRoutedToDatabase(

これらは「どこに書き込みをルーティングするか」を示すための revert(例外)として設計されています。 この設計が重要で、スマートコントラクトは実際に保存処理を行わず、「この書き込みはどこで実行すべきか」を revert でクライアントに伝えます。

さらにERC7700では、将来的な拡張も前提としています。 新しいチェーンやストレージに対応する場合は、新たな StorageRoutedTo__() 系の revert を定義する提案を追加するという拡張モデルを採用しています。

想定されている例は以下のようなものです。

ルーター 保存先
StorageRoutedToSolana() Solana
StorageRoutedToFilecoin() Filecoin
StorageRoutedToIPFS() IPFS
StorageRoutedToIPNS() IPNS
StorageRoutedToArweave() Arweave
StorageRoutedToArNS() ArNS
StorageRoutedToSwarm() Swarm

これにより、ERC7700は特定のストレージ技術に依存しない、拡張可能な標準になっています。

L1ルーター(StorageRoutedToL1())

L1ルーターの考え方

StorageRoutedToL1()は、もっとも単純なストレージルーターです。 このルーターは「実際の保存処理は別のL1コントラクトで行ってください」という指示をクライアントに返します。

L1ルーターが成立するための前提条件は次の1点だけです。

  • ルーティング前後で calldata(関数呼び出しデータ)が完全に同一であること

つまり、クライアントは以下のように振る舞う必要があります。

  1. あるL1コントラクトにトランザクションを送る
  2. revert で StorageRoutedToL1 が返る
  3. 同じ calldata を使って、指定された別のL1コントラクトに再送信する

L1コールのライフサイクル

以下は、L1ルーターを使った場合の全体の流れです。

sequenceDiagram
    participant Client
    participant L1_A as L1コントラクト(ルーター)
    participant L1_B as L1コントラクト(実体)

    Client->>L1_A: setValue(calldata)
    L1_A-->>Client: revert StorageRoutedToL1(contract)
    Client->>L1_B: setValue(calldata)
    L1_B-->>Client: response

この流れでは、最初に呼ばれる L1_A は「保存を実行しないコントラクト」です。 L1_A は、保存先のL1コントラクト(L1_B)のアドレスを revert で返します。

実際の保存処理は L1_B で行われます。 この設計により、ルーティングロジックと保存ロジックを明確に分離できます。

js
// Define revert event
error StorageRoutedToL1(
    address contractL1
);

// Generic function in a contract
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    // Get metadata from on-chain sources
    (
        address contractL1, // Routed contract address on L1; may be globally constant
    ) = getMetadata(node); // Arbitrary code
    // contractL1 = 0x32f94e75cde5fa48b6469323742e6004d701409b
    // Route storage call to L1 router
    revert StorageRoutedToL1( 
        contractL1
    );
};

イベント

StorageRoutedToL1
javascript
error StorageRoutedToL1(
    address contractL1
);

L1へのストレージ書き込みをルーティングする時に発行されるエラーです。 このエラーは、現在実行中のコントラクトではストレージ操作を行わず、指定された別のL1コントラクトで同一の calldata を使って処理を再実行する必要があることをクライアントに通知するためのものです。 revert を使うことで、オンチェーンの状態は変更されず、クライアント側に制御が戻ります。

パラメータ

  • contractL1
    • 実際に処理を実行すべきL1コントラクトのアドレス。

関数

setValue
javascript
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    (
        address contractL1
    ) = getMetadata(node);

    revert StorageRoutedToL1( 
        contractL1
    );
};

ストレージ操作をL1ルーター経由で別のL1コントラクトに委譲する関数。 この関数は、直接データを保存することを目的としていません。 まず getMetadata を通じて、どのL1コントラクトに処理を委譲すべきかを取得します。 その後、StorageRoutedToL1 を revert することで、クライアントに対して保存先コントラクトを明示します。

この設計により、コントラクト自身は「ルーティング判断」だけを行い、保存ロジックを完全に分離できます。

引数

  • node
    • 管理対象となるノードや名前空間を表す識別子。
  • key
    • 保存対象となるキー。
  • value
    • 保存する値。

setValue(ルーティング先L1コントラクト)

javascript
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    // Some code storing data mapped by node & msg.sender
    ...
}

実際にデータをL1上に保存する関数。 この関数は、ルーティング元コントラクトから指定された「実体コントラクト」です。 クライアントは、ルーティング元と完全に同一の calldata を使ってこの関数を呼び出します。 内部では node や msg.sender をキーとして、実際のストレージ更新が行われます。

引数

  • node
    • 管理対象となるノードや名前空間。
  • key
    • 保存対象となるキー。
  • value
    • 保存される値。

L2ルーター(StorageRoutedToL2())

概要

ERC7700における L2ルーターは、ストレージ書き込み処理をEthereumのL1ではなく、L2チェーン上のコントラクトへ委譲するための仕組みです。 L1でのストレージ書き込みはガスコストが高いため、実際のデータ保存をL2に移すことでコスト削減を実現します。

StorageRoutedToL2()は、そのための最小構成のルーターとして定義されています。

L2ルーターの基本的な考え方

最小構成のL2ルーターが必要とする情報は、以下の2つだけです。

情報 説明
contractL2 実際に処理を行うL2コントラクトのアドレス
chainId 対象となるL2チェーンのChain ID

L1コントラクトは、これらの情報を revert によってクライアントへ返します。 重要な点として、クライアント側はL1とL2で calldata が完全に同一であることを保証する必要があります。

つまり、L1で呼び出した関数と、L2で再実行する関数は、以下のすべてが一致している必要があります。

  • 関数名
  • 引数の型と順序
  • calldata のバイト列

L2コールのライフサイクル

L2ルーターを利用した場合の処理の流れは、以下のようになります。

sequenceDiagram
    participant Client
    participant L1 as L1コントラクト(ルーター)
    participant L2 as L2コントラクト(保存先)

    Client->>L1: setValue(calldata)
    L1-->>Client: revert StorageRoutedToL2(contractL2, chainId)
    Client->>L2: setValue(calldata)
    L2-->>Client: response

最初に呼ばれるL1コントラクトは、ストレージ処理を実行しません。 代わりに、「この処理は chainId のL2にある contractL2 で実行してください」という情報を revert で返します。

クライアントはその情報をもとに、同じ calldata を使って L2コントラクトを呼び出します。 実際のデータ保存は、この L2コントラクトで行われます。

js
// Define revert event
error StorageRoutedToL2(
    address contractL2, 
    uint256 chainId
);

// Generic function in a contract
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    // Get metadata from on-chain sources
    (
        address contractL2, // Contract address on L2; may be globally constant
        uint256 chainId // L2 ChainID; may be globally constant
    ) = getMetadata(node); // Arbitrary code
    // contractL2 = 0x32f94e75cde5fa48b6469323742e6004d701409b
    // chainId = 21
    // Route storage call to L2 router
    revert StorageRoutedToL2( 
        contractL2,
        chainId
    );
};

イベント

StorageRoutedToL2
javascript
error StorageRoutedToL2(
    address contractL2, 
    uint256 chainId
);

ストレージ書き込み処理をL2へルーティングする時に発行されるエラー。 このエラーは、現在実行中のL1コントラクトでは処理を行わず、指定されたL2チェーン上のコントラクトで同一の calldata を使って処理を再実行する必要があることを、クライアントに伝えるためのものです。 revert を使用することで、L1の状態は変更されず、制御がクライアントへ戻ります。

パラメータ

  • contractL2
    • 実際に処理を実行するL2コントラクトのアドレス。
  • chainId
    • 対象となるL2チェーンのChain ID。

関数

setValue(L1コントラクト側)
javascript
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    (
        address contractL2,
        uint256 chainId
    ) = getMetadata(node);

    revert StorageRoutedToL2( 
        contractL2,
        chainId
    );
};

ストレージ操作をL2ルーター経由でL2コントラクトに委譲する関数。 この関数は、L1上でデータを保存するためのものではありません。 getMetadata を通じて、どのL2チェーンのどのコントラクトに処理を委譲するかを取得します。 その後、StorageRoutedToL2 を revert することで、クライアントに対して保存先のL2情報を明示します。

この設計により、L1コントラクトはルーティング判断のみを担い、ストレージの実体を持たずに済みます。

引数

  • node
    • 管理対象となるノードや名前空間を表す識別子。
  • key
    • 保存対象となるキー。
  • value
    • 保存する値。

setValue(L2コントラクト側)

javascript
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    // Some code storing data mapped by node & msg.sender
    ...
}

L2上で実際にデータを保存する関数。 この関数は、L1ルーターから指定された実体コントラクトです。 クライアントは、L1で使用したものと完全に同一の calldata を使ってこの関数を呼び出します。

内部では、node や msg.sender をキーとして、L2上のストレージにデータが保存されます。 L2を使うことで、L1と比べて大幅に低いコストでストレージ操作が可能になります。

引数

  • node
    • 管理対象となるノードや名前空間。
  • key
    • 保存対象となるキー。
  • value
    • 保存される値。

Database Router

データベースルーターの概要

ERC7694におけるDatabase Routerは、Ethereumスマートコントラクトからオフチェーンのデータベースに対して書き込み処理を委譲するための仕組みです。

このルーターは、Ethereum L2にストレージを委譲する場合と非常によく似た考え方を採用しています。 ただし、保存先がブロックチェーンではなく、HTTP経由でアクセスされるデータベースである点が異なります。

データベースルーターでは、Ethereumのコントラクトは一切のデータを書き込まず、「どのゲートウェイに処理を渡すか」という情報だけを返します。

Database Router が持つ最小構成

Database Routerは、以下の2点においてL2ルーターと同等の性質を持ちます。

項目 説明
gatewayUrl オフチェーンストレージ処理を担当する HTTP ゲートウェイの URL
ユーザー署名 eth_sign による署名を用いてデータの正当性を保証

重要な点として、ERC7694ではL1に保存される情報は gatewayUrl のみです。 実際のデータ、署名、鍵情報はすべてオフチェーンで管理されます。

そのため、ストレージルーターは revert 時に gatewayUrl だけを返す設計になっています。

StorageRoutedToDatabase エラー

Database Routerは、通常の revert を使ってクライアントに制御を返します。 このときに使用されるのが StorageRoutedToDatabase エラーです。

js
error StorageRoutedToDatabase(
    string gatewayUrl
);

このエラーは、「この書き込み処理はオンチェーンではなく、指定された gatewayUrl に送って処理してください」という合図です。

コントラクト側の処理例

以下は、Database Router を利用するコントラクトの最小例です。

js
function setValue(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    (
        string gatewayUrl // Gateway URL; may be globally constant
    ) = getMetadata(node);

    // Route storage call to database router
    revert StorageRoutedToDatabase( 
        gatewayUrl
    );
}

この関数では、以下の点が重要です。

  • 書き込み処理を行わない
  • revert を使って gatewayUrl を返す

Ethereumコントラクトは「ルーティングの判断」だけを行い、実際の保存処理は完全にクライアントとゲートウェイに委ねられます。

Database Call のライフサイクル

コントラクトが revert した後、クライアントは次の流れで処理を進めます。

  1. データ署名用の鍵(Data Signer)を生成
  2. オフチェーンに送信するデータへ署名
  3. Data Signerの承認署名を取得
  4. CCIP-Read互換のペイロードをゲートウェイへ POST

1. データ署名者(Data Signer)の生成

Data Signerとは何か

Data Signerは、オフチェーンデータに署名するためだけに使われる専用の鍵ペアです。 Ethereumウォレットの秘密鍵を直接使わず、決定論的に派生させる点が特徴です。

これにより、以下の利点があります。

  • ユーザーは毎回署名を求められない
  • 鍵の再生成が可能
  • スコープを限定できる
keygen

Data Signerの生成は、以下の疑似コードで表されます。

javascript
function keygen(
  username,
  sigKeygen,
  spice
) {
  let inputKey = sha256(sigKeygen);
  let salt = sha256(`${username}:${spice}:${sigKeygen}`);
  let hashKey = hkdf(sha256, inputKey, salt, username, 42);
  return secp256k1(hashKey);
}

ここでは、Ethereumの署名を材料としてsecp256k1の鍵ペアを生成しています。

username

username は、CAIP10形式で表されるアカウント識別子です。

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

javascript
const caip10 = `eip155:${chainId}:${wallet}`;

CAIP10は「どのチェーンのどのアドレスか」を一意に表す標準フォーマットです。

spice

spice は、ユーザーが入力する任意のパスワードを元にした秘密値です。 これにより、同じウォレットでも異なるData Signerを生成できます。

javascript
const password = 'key1';

この password は、そのまま使わずPBKDF2によってストレッチされます。

javascript
let pepper = keccak256(abi.encodePacked(username));
let iterations = 500000;

let spice = pbkdf2(
    password, 
    pepper, 
    iterations
);

PBKDF2は、総当たり攻撃を防ぐために意図的に計算コストを上げる仕組みです。

sigKeygen

sigKeygen は、Ethereumウォレットに署名させる鍵生成専用メッセージです。

署名メッセージの形式は以下の通りです。

text
Requesting Signature To Generate Keypair(s)

Origin: ${username}
Protocol: ${protocol}
Extradata: ${extradata}

extradata は、以下のように計算されます。

js
bytes32 extradata = keccak256(
    abi.encodePacked(
        spice,
        wallet
    )
);

protocol は、特定のコントラクトにスコープを限定するための識別子です。

javascript
const protocol = `eth:${chainId}:${contract}`;

この署名により、Data Signerは特定のプロトコル専用として生成されます。

オフチェーンデータへの署名

生成されたData Signerは、複数のデータをまとめて署名できます。

  • 複数キーの更新
  • 複数ノードの更新

いずれもユーザーの追加操作なしで実行できます。

署名メッセージは以下の形式です。

text
Requesting Signature To Update Off-Chain Data

Origin: ${username}
Data Type: ${dataType}
Data Value: ${dataValue}

dataType は階層構造を / 区切りで表現します。

実データ dataType
text > avatar text/avatar
address > 60 address/60

3. データ署名者の承認

Data SignerはL1に保存されません。 そのため、オーナーまたはマネージャーによる承認署名が必要です。

承認メッセージの形式は以下です。

text
Requesting Signature To Approve Data Signer

Origin: ${username}
Approved Signer: ${dataSigner}
Approved By: ${caip10}

この署名によって、誰がどのData Signerを明示的に許可したかをCCIP-Read対応コントラクトが検証できます。

4. CCIP-Read 互換ペイロードの生成

最終的に、オフチェーンデータはERC3668互換形式でエンコードされます。

js
bytes encodedData = abi.encode(['bytes'], [dataValue]);
bytes funcSelector = callback.signedData.selector; // 0x2b45eb2b

bytes data = abi.encode(
    ['bytes4', 'address', 'bytes32', 'bytes32', 'bytes'],
    [funcSelector, dataSigner, dataSig, approval, encodedData]
);

この形式により、Ethereumコントラクトは以下を検証できます。

  • Data Signerが承認されているか
  • dataSig が正しい署名か

その上で、encodedData を正式な結果として扱います。

POST リクエスト形式

クライアントは、最終的に次の形式で gatewayUrl にPOSTします。

typescript
type Post = {
  node: string
  preimage: string
  chainId: number
  approval: string
  payload: {
    ...
  }
}

payload 内には、フィールドごとに以下が含まれます。

  • 値
  • 署名
  • タイムスタンプ
  • CCIP-Read データ

提示されているENS更新の例は、複数レコードを一度に安全に更新できることを示しています。

補足

L2とデータベースを併用した ENS 実装の概要

ERC7700は、ENSのような名前解決システムにとても相性が良い設計です。 ENSレコードは種類が多く、頻繁に更新されるため、すべてをL1に保存するとガスコストが高くなります。

この実装例では、ENSのレコード種別ごとに保存先を分けています。

ENS操作 保存先 理由
Ethereumアドレス データベース 更新頻度が低く、柔軟な管理が必要
テキストレコード(avatar など) L2 更新頻度が高く、安価な書き込みが必要

このように、ERC7700を使うことで、「ENSとしては1つのResolverに見えるが、実際の保存先は分散している」という構成を自然に実現できます。

L1コントラクト(ENS Resolver)

L1コントラクトは、ENS Resolverとしての入口の役割のみを持ちます。 実際の保存処理は行わず、どこに保存すべきかを revert で指示します。

gatewayUrl
javascript
string public gatewayUrl = "https://post.namesys.xyz";

オフチェーンデータベースへの書き込みを受け付けるゲートウェイURLです。 このURLは、クライアントがPOSTリクエストを送信する先です。 ENSのアドレスレコードなど、データベース保存対象のデータはすべてこのゲートウェイ経由で管理されます。

chainId
javascript
uint256 public chainId = uint(21);

保存先となるL2チェーンのChainIDです。 EIP155に基づくチェーン識別子で、クライアントがどのL2にトランザクションを送るべきかを判断するために使われます。

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

contractL2
javascript
address public contractL2 = "0x839B3B540A9572448FD1B2335e0EB09Ac1A02885";

L2上で実際にデータを保存するコントラクトのアドレスです。 このコントラクトは、L1 Resolverからルーティングされた calldata をそのまま受け取り、ストレージ更新を行います。

StorageRoutedToL2
javascript
error StorageRoutedToL2(
    uint chainId,
    address contractL2
);

ストレージ操作をL2にルーティングする時に発行されるエラーです。 このエラーは、L1では処理を完結させず、指定されたL2コントラクトに同一の calldata を送る必要があることをクライアントに通知します。

パラメータ

  • chainId
    • 対象となるL2チェーンの識別子。
  • contractL2
    • 実際に処理を行うL2コントラクトのアドレス。
StorageRoutedToDatabase
javascript
error StorageRoutedToDatabase(
    string gatewayUrl
);

ストレージ操作をオフチェーンデータベースにルーティングする時に発行されるエラーです。 このエラーは、クライアントに対して「指定されたゲートウェイに署名付きデータをPOSTしてください」と指示する役割を持ちます。

パラメータ

  • gatewayUrl
    • データベース書き込み用のHTTPエンドポイント。
authorised
javascript
authorised(node)

ENSノードのオーナーまたはマネージャーのみが実行できるように制御を行う modifier。 この修飾子は、ENSレジストリを参照し、呼び出し元が対象ノードに対する権限を持つかを検証します。 不正なレコード更新を防ぐために必須です。

パラメータ

  • node
    • ENSドメインの namehash。
setAddr
javascript
function setAddr(
    bytes32 node,
    address addr
) authorised(node) {
    revert StorageRoutedToDatabase(
        gatewayUrl
    );
}

ENSノードに紐づくEthereumアドレスを設定する関数。 この関数はL1上では何も保存せず、データベースルーターへ処理を委譲します。 アドレスレコードは頻繁に変更されないため、柔軟な管理が可能なデータベース保存が選ばれています。

引数

  • node
    • 更新対象となるENSドメインの namehash。
  • addr
    • 設定するEthereumアドレス。
setText
javascript
function setText(
    bytes32 node,
    string key,
    string value
) external {
    require(authorised(node), "NOT_ALLOWED");

    revert StorageRoutedToL2(
        chainId, 
        contractL2
    );
}

ENSノードに紐づくテキストレコードを設定する関数。 テキストレコードは更新頻度が高いため、L2に保存されます。 L1では権限チェックのみを行い、実際の保存はL2コントラクトに委譲されます。

引数

  • node
    • 更新対象となるENSドメインの namehash。
  • key
    • テキストレコードのキー。
  • value
    • 設定する値。

L2コントラクト

setText
javascript
function setText(
    bytes32 node,
    bytes32 key,
    bytes32 value
) external {
    records[keccak256(abi.encodePacked(node, msg.sender))]["text"][key] = value;
}

L2上でENSのテキストレコードを実際に保存する関数。 この関数はL1からルーティングされた calldata をそのまま受け取り、ノードと送信者をキーにしてレコードを保存します。 L2の低コストなストレージを活かした設計です。

引数

  • node
    • ENSドメインの namehash。
  • key
    • テキストレコードのキー。
  • value
    • 保存される値。

クライアント側実装

クライアントの役割

クライアントはERC7700において非常に重要な役割を担います。 L1で revert を受け取り、その内容に応じて正しい保存先で処理を完結させます。

クライアント処理例
javascript
let signer = keygen(username, sigKeygen, spice);

let post: Post = signData(node, addr, signer.priv);

await fetch(gatewayUrl, {
  method: "POST",
  body: JSON.stringify(post)
});

オフチェーンデータベースへの書き込みを行うクライアント側処理。 ウォレット署名から決定論的に生成したデータ署名者を使い、calldata を署名してゲートウェイにPOSTします。 この署名情報は、後のERC3668による読み取り時に検証されます。

補足

L2とデータベースは技術的に何が似ているのか

ERC7700では、L2へのルーティングとデータベースへのルーティングは、技術的には非常に近いものとして扱われています。 両者の共通点は、「EthereumのL1では処理を完結させず、別の実行環境に処理を移す」という点です。

違いを整理すると、以下のようになります。

保存先 実際に行っていること
L2 eth_call を別の EVM(L2)にルーティングする
データベース eth_call から eth_sign を抽出し、署名付きデータを外部に送る

L2の場合は、保存先もEVMであり、Ethereumと同じ実行モデルを持っています。 そのため、クライアントは同じ calldata を使ってL2コントラクトを呼び出すだけで済みます。

一方、データベースはEVMではありません。 そのため ERC7700は以下のようなアプローチを取ります。

  • L1コントラクトの eth_call を「署名生成のトリガー」として使う
  • その署名(eth_sign)を、後で検証可能な形でデータと一緒に保存する

これにより、ブロックチェーン外のデータであっても、ERC3668(CCIP-Read)を通じてL1から安全に検証できるようになります。

ルーティング処理が果たしている役割

ERC7700に記載されている各メソッドは、単に「保存先を変える」ためのものではありません。

重要なのは、以下の2点を同時に満たしていることです。

  • ストレージ操作を外部に委譲できること
  • 後からL1で検証できること

L2ではチェーン自体のセキュリティモデルに任せ、データベースでは署名と承認による明示的な検証を行います。

この違いを吸収するのが、ERC7700のルーティング設計です。

データ署名者によるUX改善の意義

データベースルーターでは、「派生したデータ署名者でデータを署名する」という仕組みを採用しています。 これにより、ユーザー体験が大きく改善されます。

具体的には、ウォレットに表示される署名リクエストの回数が常に2回に固定されます。

署名の種類 目的
Keygen 用署名 データ署名者の鍵生成
承認用署名 データ署名者の使用許可

この2回の署名が終われば、以下気になる追加のユーザー操作なしで行えます。

  • 1つのノードに対する複数レコードの更新
  • 複数ノードに対する一括更新

これは、ENSのように複数のレコードをまとめて更新するユースケースにおいて非常に重要です。 しかも、このUX改善は「追加コストなし」で実現されています。

バッチ更新を可能にする設計

派生したデータ署名者は、次の特徴を持ちます。

  • ウォレット単位で一意
  • ノードやレコード数に依存しない
  • バックグラウンドで署名可能

そのため、クライアントは複数のデータをまとめて署名し、一度のPOSTリクエストでゲートウェイに送信できます。

この設計により、ERC7700は「スケーラブルなオフチェーン書き込み」を現実的なものにしています。

セキュリティ

派生署名者の秘密鍵の扱い

データベースルーターでは、派生したデータ署名者の秘密鍵が一時的にクライアント上で使われます。 この秘密鍵は署名が完了した直後に必ず破棄する必要があります。

ローカルストレージやメモリに残したままにすると、以下のようなリスクが発生します。

  • 意図しないデータ署名
  • なりすましによるデータ改ざん

そのため、署名処理が終わった時点で即座に削除することが必須です。

sigKeygen の取り扱い

sigKeygen は、データ署名者を決定論的に生成するための非常に重要な情報です。 この署名メッセージと、その結果得られる署名値は秘密情報として扱う必要があります。

理由は単純で、これが漏洩すると、同じデータ署名者を第三者が再生成できるという状態になるためです。

そのため、keygen() 関数の実行が終わったら、以下の両方を即座に破棄する必要があります。

  • 署名メッセージ
  • sigKeygen

password と spice の扱い

password と spice は、データ署名者の生成に使われる補助的な秘密情報です。 特に spice は、PBKDF2 によって高コストで生成されているため、安全性が高い一方で、漏洩時の影響も大きくなります。

クライアントは以下を厳守する必要があります。

  • password を永続化しない
  • spice を永続化しない
  • keygen 処理後に即時削除する

これらを守らないと、データ署名者の再現や不正利用につながります。

引用

Avneet Singh (@sshmatrix), 0xc0de4c0ffee (@0xc0de4c0ffee), Nick Johnson (@arachnid), Makoto Inoue (@makoto), "ERC-7700: Cross-chain Storage Router Protocol [DRAFT]," Ethereum Improvement Proposals, no. 7700, April 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7700.

最後に

今回は「L1のストレージ処理をL2やオフチェーンデータベースへ安全にルーティングし、ガスコストを抑えつつ検証可能性を保つ仕組みを提案しているERC7700」についてまとめてきました! いかがだったでしょうか?

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

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