# [ERC8121] 保存した情報から別チェーンの関数を呼び出す仕組み

> アプリがあるチェーンのmetadataを読んだ時、必要な資格情報が別のチェーンに保存されていることがあります。関数名だけを保存しても、引数の型、渡す値、戻り値の読み方、呼び出すチェーン、コントラクトは分かりません。

- URL: https://cardene.dev/articles/281a6a09302527b540df
- 公開日: 2026-10-11
- 更新日: 2026-10-11
- トピック: Blockchain
- タグ: ethereum, erc, smartcontract, crosschain

## はじめに

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

https://amzn.asia/d/gxvJ0Pw

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください！

https://cardene.notion.site/EIP-2a03fa3ea33d43baa9ed82288f98d4a9

https://twitter.com/cardene777

https://zenn.dev/heku

https://cardene.substack.com/

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

ERC8121は、保存された値から別チェーンの関数呼び出しを組み立てるための提案です。
呼び出す関数、引数、戻り値の型、チェーン、コントラクトを1つの値へまとめます。

https://eips.ethereum.org/EIPS/eip-8121

## 概要

アプリがあるチェーンのmetadataを読んだ時、必要な資格情報が別のチェーンに保存されていることがあります。
関数名だけを保存しても、引数の型、渡す値、戻り値の読み方、呼び出すチェーン、コントラクトは分かりません。
20-byteのアドレスだけでは、同じアドレスを持つ別チェーンのコントラクトを取り違えるおそれもあります。

ERC8121では、一回の関数呼び出しに必要な情報をまとめた保存値を**hook**と呼びます。
呼び出し先のチェーンとコントラクトを1つにした値は**target**です。
hookを検証し、指定された関数を呼び、その結果を元の値として取り出す処理を**解決**と呼びます。

hookには、関数の型、実際の引数値、戻り値の型、targetを保存します。
`target`には、チェーンとコントラクトアドレスを1つにまとめたERC7930の値を保存します。
クライアントは`target`を復号し、どのチェーンのどのコントラクトを呼ぶかを確定します。

hookは呼び出しを再現する情報を持ちますが、targetが安全であることまでは証明しません。
クライアントは形式と型の整合性を検証し、targetを信頼できると判断してから関数を呼び出します。

![保存元のmetadataにあるhookを検証し、ERC7930のtargetから別チェーンのコントラクトを呼び出す](/images/qiita-e0f73d99-6107-4c0a-b60b-1d580281f8d8.webp)

保存元と実行先を直接つなぐのではなく、クライアントがhookを読み、検証を通過した場合だけtargetを呼び出します。

#### 別チェーンにある値を参照する

例えば、Optimism上のコントラクトが持つ`kyc`というmetadata keyから、Ethereum mainnet上の資格情報registryを参照できます。
保存元には資格情報そのものではなく、資格情報を返す関数とその宛先を表すhookを置きます。

クライアントは元のmetadataを読んだ後、hookが示すチェーンへ接続し、指定されたコントラクトの関数を呼び出します。
ERC8121はブリッジでstateを移す規格ではなく、別の場所にある値を関数呼び出しで解決するための記述形式です。

#### 一回の呼び出し情報を保存する

hookは、関数名だけでなく引数型を含む関数定義、実際に渡す値、戻り値の型、targetを保持します。
任意で4-byteの検査値も保存でき、関数定義から計算した値と一致するかを確認できます。

この情報が1つにまとまるため、クライアントは別のABIファイルを探さずにcall dataを組み立てられます。
戻り値をどの型としてdecodeするかもhookから判断できます。

#### 実行前に信頼を判断する

自己記述できることと、呼び出し先を信頼できることは別です。
悪意あるtargetを指すhookでも、形式だけなら正しく作れます。

クライアントはERC7930の値からチェーンとコントラクトアドレスを取り出し、allowlistや外部registryと照合します。
未確認のtargetなら関数を呼ばずに停止できます。

## 動機

別チェーンのmetadataや資格情報を参照する仕組みでは、呼び出す関数、引数、戻り値、チェーン、コントラクトを保存する必要があります。
これらを別々の設定や文書に置くと、1つが更新された時に対応関係が崩れます。
保存値だけを読んだクライアントは、どの型でcall dataを作り、どのチェーンへ送るかを復元できません。

ERC8121は、関数呼び出しを`bytes`または`string`として保存する共通形式を定めます。
人は文字列から呼び出し内容を確認でき、クライアントはselectorや型を使って機械的に検証できます。
資格情報registry、複数コントラクトで共有するmetadata、特定チェーンの単一registryなどを同じ解決手順で扱えます。

![チェーン、コントラクトアドレス、ABI、引数値が分散した状態とhookに集約した状態の比較](/images/qiita-5c080ffa-600d-415e-8e0e-92d34f791e20.webp)

左側では情報が存在していても対応関係が保存されていません。
右側ではhookが各要素を結び付けるため、保存値から呼び出しを復元できます。

#### 宛先とABIが分散する問題

関数名と引数値だけでは、引数を`bytes32`としてencodeするのか`bytes`としてencodeするのか決まりません。
コントラクトアドレスだけでは、そのアドレスをEthereum mainnetとOptimismのどちらで呼ぶのかも決まりません。

外部のABI文書とチェーン設定に依存すると、同じ保存値でも設定によって別の関数やコントラクトを指し得ます。
ERC8121は、解釈を変える型と宛先をhook自身へ含めます。

#### 同じaddressだけではchainを決められない

Ethereum形式の20-byteアドレスは、それだけでは対象チェーンを表しません。
ERC7930は、version、chain type、chain reference、addressを長さ付きの`bytes`へまとめます。
クライアントはその値から対象チェーンとコントラクトアドレスを同時に特定できます。

:::message
**ERC7930**については以下の記事を参考にしてください。
:::

[\[ERC7930\] 複数チェーンのアドレスを1つの形式で扱う仕組みを理解しよう！](/articles/e280966bf2619d25d4c8)

#### 人とclientの双方が読める保存形式

関数名と引数型を示す文字列と、関数名と実際に渡す値を示す文字列を分けて保存します。
例えば、前者は引数型を含む`getOwner`関数の定義、後者は同じ関数へ値`42`を渡す表現です。

人は2つの文字列から内容を読めます。
クライアントは型を示す文字列からselectorとABI encodingに使う型を計算し、保存された値を検証できます。
文字列形式はMarkdownのようなplain textにも置けます。

## 仕様

### Hookの構成

hookが保存するfieldは以下です。

| field | 型 | 必須 | 保存する内容 | 使用箇所 |
| --- | --- | --- | --- | --- |
| `functionSelector` | `bytes4` | 任意 | 関数の4-byte selector | signatureとの照合 |
| `functionSignature` | `string` | 必須 | 関数名と引数型 | selector計算とABI encoding |
| `functionCall` | `string` | 必須 | 関数名と実引数 | 関数名と値の取り出し |
| `returnType` | `string` | 必須 | 戻り値のSolidity tuple notation | ABI decoding |
| `target` | `bytes` | 必須 | ERC7930形式のchainとaddress | 宛先の特定 |

`functionSelector`がある場合、クライアントは`bytes4(keccak256(bytes(functionSignature)))`に相当する値を計算して照合します。
一致しないhookは呼び出しません。
省略した場合も、`functionSignature`から呼び出し用selectorを計算します。

### 2つのhook関数形式

selectorを含む形式は以下です。

```js
function hook(
    bytes4 functionSelector,
    string calldata functionSignature,
    string calldata functionCall,
    string calldata returnType,
    bytes calldata target
)
```

処理は、signatureからselectorを計算し、保存値と照合し、引数をencodeしてtargetを呼ぶ順です。
このschema自体に戻り値はありません。

selectorを省略する形式は以下です。

```js
function hook(
    string calldata functionSignature,
    string calldata functionCall,
    string calldata returnType,
    bytes calldata target
)
```

この形式では`functionSignature`が最初のparameterです。
二形式を判別する定数は以下です。

```js
bytes4 constant HOOK_SELECTOR_WITH_SELECTOR = 0x037f43ed;
bytes4 constant HOOK_SELECTOR_WITHOUT_SELECTOR = 0x6113bfa3;
```

これらはhook形式自体を示すselectorであり、hook内部の任意の`functionSelector`とは別です。

### functionCallの書式

| 値 | 書式 | 例 |
| --- | --- | --- |
| 文字列 | single quote | `'alice'` |
| `bytes`・hex | `0x` prefix | `0x1234abcd` |
| 数値 | 数値literal | `42` |
| 配列 | square bracket | `[1, 2, 3]` |
| tuple | parentheses | `('alice', 42, true)` |

```js
hook(
    0xc41a360a,
    "getOwner(uint256)",
    "getOwner(42)",
    "(address)",
    0x000100000101141234567890abcdef1234567890abcdef12345678
)
```

`42`は`uint256`としてABI encodeし、戻り値は`(address)`としてdecodeします。

### hookの符号化

保存先が`bytes`か`string`かによって表現が変わります。

![bytes形式とstring形式でselectorの有無を判定する位置とfieldの並び](/images/qiita-0365241b-4bac-4110-a725-62cc7bc18146.webp)

上段では形式selectorと任意の`functionSelector`を分けています。
下段では`hook(`の第一引数から`functionSelector`の有無を判定します。

#### bytes形式

```js
bytes memory withSelector = abi.encodeWithSelector(
    HOOK_SELECTOR_WITH_SELECTOR,
    functionSelector,
    functionSignature,
    functionCall,
    "(bytes)",
    target
);

bytes memory withoutSelector = abi.encodeWithSelector(
    HOOK_SELECTOR_WITHOUT_SELECTOR,
    functionSignature,
    functionCall,
    "(bytes)",
    target
);
```

先頭が`0x037f43ed`ならselectorあり、`0x6113bfa3`ならselectorなしのschemaを選びます。
それ以外をERC8121のbytes hookとしてdecodeしません。

#### string形式

```js
hook(0x9e574b14, "getContractMetadata(string)", "getContractMetadata('kyc')", "(bytes)", 0x000100000101141234567890abcdef1234567890abcdef12345678)

hook("getContractMetadata(string)", "getContractMetadata('kyc')", "(bytes)", 0x000100000101141234567890abcdef1234567890abcdef12345678)
```

第一引数が`0x`と8桁のhexならselectorあり、それ以外なら`functionSignature`から始まる形式です。

### hookの検出

クライアントは、どのmetadata keyがhookを含み得るかを事前に把握します。
`bytes`では2つの形式selector、`string`では`hook(`を確認します。
検出後もすぐにはcallせず、field数、型、selector、targetを検証します。

### 読み取りの解決

1. **形式を検出する**

    bytesの形式selector、またはstringの`hook(`を確認する。

2. **hookをparseする**

    5つのfieldを取り出す。

3. **selectorを照合する**

    保存値があればsignatureから計算した値と比較する。

4. **targetを復号する**

    ERC7930からchainとaddressを取り出する。

5. **targetを信頼できるか確認する**

    未確認targetなら停止する。

6. **functionCallをparseする**

    関数名と値を取り出し、signatureの型でencodeする。

7. **ERC3668を有効にする**

    target chainのproviderで有効化する。

8. **targetをcallする**

    selectorとABI encoded parametersを送る。

9. **結果を取得する**

    `returnType`に従ってdecodeする。

![hookの検出からtargetの信頼確認、ERC3668を有効にしたcall、結果取得までの9段階](/images/qiita-38653a3d-b8c0-4028-91aa-988c10d54b20.webp)

selector照合は関数型の取り違えを防ぎ、target確認は呼び出し先の信頼判断を担います。
一方が成功しても、もう一方を省略できません。

ERC3668は、コントラクトが`OffchainLookup`を返した時にgatewayからデータを取得し、元のコントラクトのcallbackで検証する手順です。
ERC8121の読み取りでは、この手順を有効にしたproviderでtargetを呼び出します。

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

[\[ERC3668\] オフチェーンデータ参照の仕組みを理解しよう！](/articles/6bd9a27a7353bd8147b2)

### 書き込みの実行

書き込みもtargetの検証までは読み取りと共通です。
違いは、結果を読むのではなくstate-changing transactionへ署名して対象チェーンへbroadcastする点です。

```js
const tx = await signer.sendTransaction({
    to: targetAddress,
    data: encodedFunctionCall,
});
const receipt = await tx.wait();
```

これは規範的なreference implementationではなく補足例です。
署名権限、gas、confirmationはERC8121が定めません。

### KYC資格情報を解決する例

公式仕様には、Optimism上の`kyc` metadataからEthereum mainnet上のKYC providerを呼ぶ2つのコード例があります。
独立したReference Implementationではなく、保存と解決の処理順を示す例です。

#### 保存元コントラクトにhookを保存する

```js
bytes4 constant HOOK_SELECTOR_WITHOUT_SELECTOR = 0x6113bfa3;

string memory functionSignature = "getCredential(string)";
string memory functionCall = "getCredential('kyc: 0x76F1Ff0186DDb9461890bdb3094AF74A5F24a162')";

// Ethereum mainnetのKYCProviderを示すERC7930 address
bytes memory target = hex"000100000101141234567890abcdef1234567890abcdef12345678";

bytes memory hookData = abi.encodeWithSelector(
    HOOK_SELECTOR_WITHOUT_SELECTOR,
    functionSignature,
    functionCall,
    "(string)",
    target
);

originatingContract.setContractMetadata("kyc", hookData);
```

`HOOK_SELECTOR_WITHOUT_SELECTOR`を選ぶため、hook内部へ`functionSelector`を保存しません。
クライアントは`getCredential`関数の定義からselectorを計算します。

`functionSignature`は型を決め、`functionCall`は資格情報の識別子という実引数を持ちます。
`"(string)"`はKYC providerの戻り値を文字列としてdecodeする指定です。
`target`はEthereum mainnetとKYC providerのaddressを1つにしています。

`abi.encodeWithSelector`は、形式selector、関数定義、実引数、戻り値型、targetの順で`bytes`を作ります。
`setContractMetadata("kyc", hookData)`はOptimism側の`kyc` keyへそのbytesを保存します。
例示addressが信頼できるproviderであることまでは保証しません。

#### クライアントでhookを解決する

```js
const value = await originatingContract.getContractMetadata("kyc");
const hasSelector = value.startsWith("0x037f43ed");
let functionSelector, functionSignature, functionCall, returnType, target;

if (value.startsWith("0x037f43ed") || value.startsWith("0x6113bfa3")) {
    if (hasSelector) {
        ({ functionSelector, functionSignature, functionCall, returnType, target } = decodeHook(value));
    } else {
        ({ functionSignature, functionCall, returnType, target } = decodeHook(value));
        functionSelector = null;
    }

    const computedSelector = keccak256(functionSignature).slice(0, 10);
    if (functionSelector && functionSelector !== computedSelector) {
        throw new Error("Selector mismatch");
    }
    const selectorToUse = functionSelector || computedSelector;

    const { chainId, address } = decodeERC7930(target);
    if (!isTrustedResolver(chainId, address)) {
        throw new Error("Untrusted resolver");
    }

    const { functionName, args } = parseFunctionCall(functionCall);
    const targetProvider = getProviderForChain(chainId);
    const targetContract = new ethers.Contract(
        address,
        [`function ${functionSignature} view returns (bytes)`],
        targetProvider.ccipReadEnabled(true)
    );

    const resultBytes = await targetContract[functionName](...args);
    const credential = ethers.utils.defaultAbiCoder.decode([returnType], resultBytes);
}
```

`getContractMetadata`はraw bytesを取得し、先頭が2つの形式selectorのどちらかなら`decodeHook`でfieldを取り出します。
保存済みselectorと`computedSelector`が一致しなければthrowし、selectorなしなら計算値を使います。

公式例は`selectorToUse`を計算しますが、後続コードで直接参照しません。
ABIへ`functionSignature`を渡すことで、ethersがselectorを含むcall dataを構成する概念例です。

`decodeERC7930`はtargetを`chainId`と`address`へ分けます。
`isTrustedResolver`がfalseならRPC callより前にthrowします。
`parseFunctionCall`は関数名と実引数を取り出し、providerでは`ccipReadEnabled(true)`を設定します。
最後にtargetを呼び、`resultBytes`を`returnType`でdecodeします。

`decodeHook`、`decodeERC7930`、`isTrustedResolver`、`parseFunctionCall`、`getProviderForChain`は概念的なhelperです。
完全な実装は掲載されていないため、このコードだけをコピーしても動きません。
実装ではescape処理、payload size、RPC error、対応chainも定めます。

## 補足

#### selectorとsignatureを併記する理由

同じ`getData`関数でも、引数型が固定長bytesか可変長bytesかで異なるselectorを持ちます。
`functionSignature`は人が型を読める表現を提供し、`functionSelector`はその文字列との照合に使えます。
不一致ならcall前に拒否できますが、targetの信頼性は保証しません。

#### ERC7930をtargetに使う理由

自己記述的なcallにはcontract addressだけでなくchainも必要です。
ERC7930は両方を1つのbinary envelopeへ格納するため、外部のchain設定なしで宛先を復元できます。
各chain typeのserializationとcanonicalityはCAIP350 profileも確認します。

#### returnTypeを含める理由

EVM callの戻り値はABI encoded bytesです。
`returnType`を含めると、外部ABIなしで期待型を確認し、同じ型でdecodeできます。
ERC8121自体は特定の戻り値型へ制限しません。

#### ERC3668を必須にする理由

targetは値を直接返さず`OffchainLookup`を通じて外部データを要求できます。
ERC3668対応clientはgatewayのresponseをcallbackへ渡して検証します。
ERC8121ではhookを解決する場面だけ有効化でき、すべての通常callで有効にする必要はありません。

## 互換性

ERC8121非対応clientは、hookをrawな`bytes`または`string`として返します。
値を見ただけで別チェーンのcallを自動的に始めることはありません。

#### 非対応clientが返す値

`0x6113bfa3...`から始まるbytesも、`hook(...)`というstringも通常のmetadata値として返ります。
storageの読み取りは壊れませんが、期待した資格情報へは解決されません。

#### 導入時に分けて確認する境界

known metadata key、二形式のparser、ERC7930 namespace、target chain provider、ERC3668、trusted target、recursive hook上限を別々に確認します。
ERC8121はDraftなので、selector、引数順、検出規則の変更履歴も追います。

## セキュリティ

形式の検証、targetの信頼判断、再帰の停止は別々の防御です。

![信頼済みtargetだけを解決する境界と再帰hookをdepth上限で止める仕組み](/images/qiita-f145cf58-2a6f-4ef4-bd26-52a36c9c8750.webp)

#### 解決前に形式を検証する

攻撃者は、selectorとsignatureが一致しないhook、引数数が異なるcall、不正なERC7930 payloadを保存できます。
検証せずにABI encodeすると、意図しない関数選択、decode error、無駄なRPC callにつながります。

call前に形式selector、field数、文字列構文、selector照合、`returnType`、ERC7930 lengthを検査します。
ERC8121は総payload長を定めないため、実装は**size limit**を設けます。
形式検証を通過してもtargetの安全性は保証されません。

#### targetを信頼する範囲

正しい形式のhookでも、偽の資格情報を返すtargetを指せます。
クライアントはchainとaddressの組を**allowlist**または第三者registryへ照合し、**未確認target**を解決しません。

登録後もproxyのimplementation変更や管理鍵侵害は残ります。
コードhash、upgrade authority、登録期限は別途監視します。
読み取りと書き込みでは影響が異なるため、同じallowlistを無条件に共用しません。
書き込みでは送信可能な関数と金額も制限し、未確認targetの場合は解決自体を失敗させます。

#### 再帰hookを上限で止める

targetの結果が別のhookであり、元のhookへ戻ると解決が循環します。
実装は解決の**depth**を数え、公式仕様が目安とする**3〜5段**で停止します。
訪問済みhook hashも記録すると、上限前の循環を検出できます。
ただし、depth上限だけでは同じhookへの短い循環を上限回数まで実行します。
そのため、宛先と呼び出し内容から作った識別子を訪問済みsetに入れ、再出現した時点で停止します。
正常な連鎖も3〜5段を超えると失敗するため、上限値は利用ケースごとに固定し、エラーとして返します。

正当な長い連鎖も上限を超えれば解決できません。
上限到達時は部分結果を成功扱いにせずerrorを返します。

## 最後に

ERC8121は、別チェーンの一回の関数呼び出しを、型、値、戻り値、宛先を含むhookとして保存します。
クライアントは形式とselectorを検証し、ERC7930のtargetを信頼できると判断してから、ERC3668を有効にした読み取りまたは署名を伴う書き込みを実行します。
実装では未確認targetを呼ばず、payload sizeと再帰depthに上限を設けます。

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

https://amzn.asia/d/gxvJ0Pw

https://cardene.notion.site/EIP-2a03fa3ea33d43baa9ed82288f98d4a9

https://zenn.dev/heku

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

https://twitter.com/cardene777

https://cardene.substack.com/
