はじめに
初めまして。
『DApps開発入門』という本や色々記事を書いているかるでねです。
以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!
今回は「EIP8016」についてまとめていきます。
EIP8016は、SSZにCompatibleUnionという複合型を追加し、複数の型オプションを持つデータでも共通フィールドのMerkle証明を安定して扱えるようにする提案です。
トランザクションのように複数の形式が併存するデータで、同じ意味のフィールドを同じ位置番号で検証し続けるための仕組みです。
概要
EIP8016は、SSZにCompatibleUnion({selector: type})という型を追加します。
この型は、selectorで選ばれた1つの型オプションを中に持ちます。
ただし、通常のUnionと違い、選べる型オプションはMerkleizationの互換性を満たすものに限定されます。
SSZは、Ethereumのコンセンサスレイヤーで使われるシリアライズ形式です。
SSZではデータをMerkleツリーへ変換し、特定フィールドのMerkle証明を作れます。
この時、フィールドの位置はgeneralized index(以下、gindex)で表されます。
CompatibleUnionの目的は、型オプションが変わっても、共通フィールドが同じgindexに割り当てられるようにすることです。
これにより、検証者は「型1ならamountはこの位置、型2なら別の位置」といった個別対応表を持たずに、共通フィールドを検証できます。
CompatibleUnionの全体像は以下です。
左側の型1と型2は、amountとrecipientを共通フィールドとして持ちます。
中央のCompatibleUnionはselectorで使う型を選びます。
右側では、共通フィールドが同じ位置番号に置かれるため、検証者は同じMerkle証明の考え方で値を確認できます。
動機
トランザクションのようなデータ型は、時間とともに複数の形式を持つようになります。
新しい機能を追加した形式、古い形式、特定の用途に合わせた形式が同時に存在するためです。
この時に問題になるのが、同じ意味のフィールドが型ごとに別のgindexへ割り当てられることです。
例えば、複数のトランザクション型がどれもamountやrecipientを持っているのに、Merkleツリーの中で別々の位置に置かれると、検証者は型ごとの対応表を持つ必要があります。
型が増えるほど、対応表の更新漏れや検証ロジックの複雑化が起きます。
CompatibleUnionは、この負担を減らします。
型オプションを互換なMerkleizationを持つものだけに制限することで、共通フィールドのgindexを安定させます。
その結果、複数の型が併存しても、検証者は共通フィールドを同じ位置として扱えます。
プログラミング言語では、似た目的でタグ付きUnionが使われます。
EIP8016はSSZの世界で、タグにあたるselectorと、Merkle証明で使う安定した位置関係を同時に扱えるようにします。
仕様
EIP8016の仕様は、CompatibleUnion型の定義、互換なMerkleization、シリアライズ、デシリアライズ、JSON形式、Merkleizationの拡張に分かれます。
CompatibleUnion({selector: type})
CompatibleUnion({selector: type})は、指定された型オプションのうち1つを含むSSZ複合型です。
表記は以下のようになります。
CompatibleUnion({1: Square, 2: Circle})
この例では、selectorが1ならSquareとして、2ならCircleとして中身を解釈します。
CompatibleUnionは、すべての型オプションが固定長であっても、常に可変長型として扱われます。
デフォルト値は定義されません。
つまり、CompatibleUnionの初期値を自動的に作ることはエラーです。
不正な型定義は以下です。
| 型定義 | 扱い |
|---|---|
CompatibleUnion({}) |
型オプションがないため不正です。 |
CompatibleUnion({selector: type})でselectorが1から127の範囲外 |
使えるselector範囲を外れるため不正です。 |
| 型オプション同士のMerkleizationに互換性がない | 共通フィールドの位置を安定させられないため不正です。 |
selectorの範囲はuint8(1)からuint8(127)までです。
0は未初期化との衝突を避けるために予約されます。
128以上は最上位ビットが立つ領域なので、将来の互換拡張のために予約されます。
互換なMerkleization
型オプションとして使えるかどうかは、Merkleizationの互換性で決まります。
互換であるとは、Merkleツリーの構造上、同じ意味のフィールドを同じ位置で扱えることです。
互換性のルールは以下です。
| 型 | 互換条件 |
|---|---|
| 同じ型 | 自分自身と互換です。 |
byteとuint8
|
相互に互換です。 |
Bitlist[N] |
容量Nが同じなら互換です。 |
Bitvector[N] |
容量Nが同じなら互換です。 |
List[type, N] |
要素型が互換で、容量Nが同じなら互換です。 |
Vector[type, N] |
要素型が互換で、長さNが同じなら互換です。 |
ProgressiveList[type] |
要素型が互換なら互換です。 |
Container |
フィールド名が同じ順序で並び、各フィールド型が互換なら互換です。 |
ProgressiveContainer(active_fields) |
両方のactive_fieldsで有効なフィールドが同じ名前と互換型に対応し、ほかに共有フィールド名がなければ互換です。 |
CompatibleUnion同士 |
両方の型オプションを横断して、すべて互換なら互換です。 |
| そのほか | 互換ではありません。 |
ProgressiveListは、EIP7916で提案されている、容量上限を固定せずに将来拡張しやすいリスト型です。
ProgressiveContainerは、EIP7495で提案されている、有効フィールド集合を管理してコンテナを拡張しやすくする型です。
EIP8016は、この2つの拡張型も互換性判定の中に入れています。
そのため、将来フィールドやリストを伸ばす設計と、共通フィールドのMerkle証明を安定させる設計を組み合わせられます。
シリアライズ
CompatibleUnionの値は、value.selectorとvalue.dataを持ちます。
value.selectorは選ばれた型オプションを表し、value.dataは実際の中身です。
シリアライズは、先頭1バイトにselectorをリトルエンディアンで置き、その後ろにdataのSSZシリアライズ結果を連結します。
return value.selector.to_bytes(1, "little") + serialize(value.data)
selectorは1バイトなので、バイト列の先頭を読むだけで、後続データをどの型として解釈するかが分かります。
これは、ネストしたCompatibleUnionを扱う時にも重要です。
パーサーは早い段階で後続データの型を決められるため、全体の木構造を後から推測する必要がありません。
デシリアライズ
デシリアライズでは、まず入力範囲の先頭1バイトをselectorとして読みます。
その後、残りのバイト列を、selectorで選ばれた型オプションとしてデシリアライズします。
処理の流れは以下です。
先頭1バイトが型選択に使われ、残りのデータが選ばれた型として解釈されます。
Merkleルートを作る時は、dataのhash_tree_rootにselectorを混ぜるため、同じ木構造を持つ別の型オプションでもrootを区別できます。
実装は以下の不正入力に対して堅牢である必要があります。
| 不正入力 | 扱い |
|---|---|
範囲外のselector
|
型オプションを選べないため失敗します。 |
| 1バイト未満の入力 |
selectorを読めないため失敗します。 |
| 壊れたシリアライズデータ | 正しいSSZ値として読めないため失敗します。 |
| 内側の型として不正なデータ | 選ばれた型のデシリアライズ規則に従って失敗します。 |
JSON形式
CompatibleUnionのJSON形式は、selectorとdataを持つオブジェクトです。
dataの中身は、selectorで選ばれた型に合わせて変わります。
{
"selector": "1",
"data": {
"width": "10",
"height": "20"
}
}
selectorをJSONにも明示することで、JSONからSSZ値へ戻す時にも、どの型としてdataを読むべきかを決められます。
Merkleization
EIP8016は、SSZのMerkleizationにmix_in_selectorという補助関数を追加します。
mix_in_selectorは、Merkleルートとselectorを受け取り、それらをハッシュしてCompatibleUnion全体のrootを作ります。
ここで渡すselectorは、uint8としてシリアライズされた値です。
def mix_in_selector(root, selector):
return hash(root, selector)
CompatibleUnionのhash_tree_rootは以下です。
mix_in_selector(hash_tree_root(value.data), value.selector)
この処理により、data部分のMerkleツリーが同じ形でも、selectorが違えば最終rootは変わります。
同じ木構造を持つ別の型オプションや、空のリストのように要素型だけではrootが区別しにくい値でも、selectorによって型の違いをrootへ反映できます。
補足
selectorを1から127に制限する理由
selectorの0は、未初期化の値との衝突を避けるために使いません。
また、将来のEIPでOptionalを表す値として使う余地も残されています。
128以上は、最上位ビットが立つ領域です。
この領域を空けておくことで、将来の後方互換な拡張に使えます。
現在想定されている用途では、1から127までの127個の型オプションで足ります。
フィールド集合ではなくタグを使う理由
代替案として、active_fieldsのビットベクトルをシリアライズに含める設計も検討されました。
この方式では、どのフィールドが有効かをビット列で表せます。
しかし、ProgressiveContainerがネストしている時に、パーサーが全体の木構造をすぐに判断できません。
さらに、ネストの各層が可変長になり、シリアライズ形式のオーバーヘッドも増えます。
CompatibleUnionでは、先頭のselectorが型を先に示します。
そのため、パーサーは早い段階で後続データの型を決められます。
ネストしたデータでも、各層でどの型を読むべきかが明確になります。
早めに導入する必要
既存フィールドを後からCompatibleUnionで包む変更は、後方互換な変更ではありません。
そのため、将来の拡張が見込まれるフィールドでは、型オプションが1つだけの段階でも早めにCompatibleUnionを導入する必要があります。
一方で、一度CompatibleUnionを導入しておけば、新しい型オプションの追加や古い型オプションの削除は、検証者を壊さずに進められます。
検証者が共通フィールドのgindexを見続けられるためです。
互換性
CompatibleUnion({selector: type})は、SSZの以前のUnion提案に代わる仕組みです。
以前のUnion案は、非推奨になった仕様で使われていました。
Portal NetworkにもUnionの考え方があります。
ただし、Portal Networkのネットワーク型ではhash_tree_rootを使っていないため、新しいネットワークバージョンを導入すればCompatibleUnionへ移行できます。
重要なのは、CompatibleUnionで包む変更自体は後方互換ではない点です。
将来の型拡張を見込む場所では、あとから包むのではなく、早い段階でCompatibleUnionを使う設計が求められます。
テストケース
EIP8016は、既存のSSZ実装とテスト資産を使って検証されます。
ethereum/remerkleableには、test_impl.pyとtest_typing.pyの静的テストがあります。
また、ethereum/consensus-specsのリリースには、tests/general/phase0/ssz_generic以下のランダムテストが含まれます。
これらのランダムテストは、tests/format/ssz_genericで定義された形式に従って生成されます。
参考実装
参考実装はethereum/remerkleableにあります。
CompatibleUnionはSSZ型システム、シリアライズ、デシリアライズ、Merkleizationのすべてに関わるため、単体のアプリケーションコードだけで扱うより、SSZライブラリ側で対応するのが自然です。
実装で特に確認するべき点は、selectorの範囲チェック、型オプションの互換性判定、内側の型へのデシリアライズ委譲、mix_in_selectorによるroot計算です。
セキュリティ
CompatibleUnionでは、selectorをMerkleルートへ混ぜることが重要です。
これにより、複数の型オプションが同じMerkleツリー構造を持つ時でも、最終的なhash_tree_rootを区別できます。
selectorを混ぜない場合、同じ木構造を持つ別の型オプションが同じrootになることがあります。
また、List[type, N]やProgressiveList[type]で要素型だけが違う空リストは、hash_tree_rootだけでは型の違いを表しにくくなります。
その場合、アプリケーション側が署名rootに型情報を混ぜる、別フィールドで型を表す、といった追加対応を持つ必要があります。
EIP8016は、この責務をSSZのMerkleizationへ組み込みます。
mix_in_selector(hash_tree_root(value.data), value.selector)を使うことで、型の選択とデータ本体のrootを1つのrootに結びつけます。
デシリアライズ側では、範囲外のselector、1バイト未満の入力、壊れたデータ、内側の型として不正な値を確実に拒否する必要があります。
ここを曖昧にすると、実装ごとに同じバイト列の解釈がずれ、検証結果が一致しなくなります。
最後に
今回は「EIP8016」についてまとめてきました。
CompatibleUnionは、複数のSSZ型オプションを扱いながら、共通フィールドのMerkle証明を安定させるための提案です。
先頭1バイトのselectorで型を選び、dataのMerkleルートにselectorを混ぜることで、型の違いと共通フィールドの検証しやすさを両立します。
将来拡張が見込まれるSSZデータでは、早い段階でUnion型を設計に入れておくことが重要になります。
他でも色々記事を書いているのでぜひよろしければ読んでいってください!

