はじめに
初めまして。
『DApps開発入門』という本や色々記事を書いているかるでねです。
以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!
ERC8121は、保存された値から別チェーンの関数呼び出しを組み立てるための提案です。
呼び出す関数、引数、戻り値の型、チェーン、コントラクトを1つの値へまとめます。
概要
アプリがあるチェーンのmetadataを読んだ時、必要な資格情報が別のチェーンに保存されていることがあります。
関数名だけを保存しても、引数の型、渡す値、戻り値の読み方、呼び出すチェーン、コントラクトは分かりません。
20-byteのアドレスだけでは、同じアドレスを持つ別チェーンのコントラクトを取り違えるおそれもあります。
ERC8121では、一回の関数呼び出しに必要な情報をまとめた保存値をhookと呼びます。
呼び出し先のチェーンとコントラクトを1つにした値はtargetです。
hookを検証し、指定された関数を呼び、その結果を元の値として取り出す処理を解決と呼びます。
hookには、関数の型、実際の引数値、戻り値の型、targetを保存します。
targetには、チェーンとコントラクトアドレスを1つにまとめたERC7930の値を保存します。
クライアントはtargetを復号し、どのチェーンのどのコントラクトを呼ぶかを確定します。
hookは呼び出しを再現する情報を持ちますが、targetが安全であることまでは証明しません。
クライアントは形式と型の整合性を検証し、targetを信頼できると判断してから関数を呼び出します。
保存元と実行先を直接つなぐのではなく、クライアントが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などを同じ解決手順で扱えます。
左側では情報が存在していても対応関係が保存されていません。
右側では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へまとめます。
クライアントはその値から対象チェーンとコントラクトアドレスを同時に特定できます。
ERC7930については以下の記事を参考にしてください。
人と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を含む形式は以下です。
function hook(
bytes4 functionSelector,
string calldata functionSignature,
string calldata functionCall,
string calldata returnType,
bytes calldata target
)
処理は、signatureからselectorを計算し、保存値と照合し、引数をencodeしてtargetを呼ぶ順です。
このschema自体に戻り値はありません。
selectorを省略する形式は以下です。
function hook(
string calldata functionSignature,
string calldata functionCall,
string calldata returnType,
bytes calldata target
)
この形式ではfunctionSignatureが最初のparameterです。
二形式を判別する定数は以下です。
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) |
hook(
0xc41a360a,
"getOwner(uint256)",
"getOwner(42)",
"(address)",
0x000100000101141234567890abcdef1234567890abcdef12345678
)
42はuint256としてABI encodeし、戻り値は(address)としてdecodeします。
hookの符号化
保存先がbytesかstringかによって表現が変わります。
上段では形式selectorと任意のfunctionSelectorを分けています。
下段ではhook(の第一引数からfunctionSelectorの有無を判定します。
bytes形式
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形式
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を検証します。
読み取りの解決
-
形式を検出する
bytesの形式selector、またはstringの
hook(を確認する。 -
hookをparseする
5つのfieldを取り出す。
-
selectorを照合する
保存値があればsignatureから計算した値と比較する。
-
targetを復号する
ERC7930からchainとaddressを取り出する。
-
targetを信頼できるか確認する
未確認targetなら停止する。
-
functionCallをparseする
関数名と値を取り出し、signatureの型でencodeする。
-
ERC3668を有効にする
target chainのproviderで有効化する。
-
targetをcallする
selectorとABI encoded parametersを送る。
-
結果を取得する
returnTypeに従ってdecodeする。
selector照合は関数型の取り違えを防ぎ、target確認は呼び出し先の信頼判断を担います。
一方が成功しても、もう一方を省略できません。
ERC3668は、コントラクトがOffchainLookupを返した時にgatewayからデータを取得し、元のコントラクトのcallbackで検証する手順です。
ERC8121の読み取りでは、この手順を有効にしたproviderでtargetを呼び出します。
ERC3668については以下の記事を参考にしてください。
書き込みの実行
書き込みもtargetの検証までは読み取りと共通です。
違いは、結果を読むのではなくstate-changing transactionへ署名して対象チェーンへbroadcastする点です。
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を保存する
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を解決する
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の信頼判断、再帰の停止は別々の防御です。
解決前に形式を検証する
攻撃者は、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に上限を設けます。
他でも色々記事を書いているのでぜひよろしければ読んでいってください!




