イーサリアムのブロックヘッダには、32 バイトのフィールド stateRoot があります。そこにはアカウントのデータが一切含まれていないのに、ネットワーク全体のすべてのアカウントの残高、nonce、コントラクトコード、コントラクトストレージをまとめて固定できると主張します。このハッシュが一致しさえすれば、その状態がどのバージョンなのかが分かります。このフィールドは Merkle Patricia Trie(MPT、マークル・パトリシア・トライ)のルートハッシュであり、実行レイヤーがワールドステートに対して示す唯一の約束でもあります。
この約束がどれほど重いかを理解するには、三つのことを分解する必要があります。木の構造、ルートハッシュがなぜすべての状態を束縛できるのか、そしてこの木を維持するのにどんなコストがかかるのかです。三つ目は、後で状態シャーディングと状態木の世代交代を議論する出発点になります。
一つのブロックヘッダに三つの trie が入っている
イーサリアムのブロックヘッダには、実行結果を記述する三つの trie のルートが付いています。stateRoot(ワールドステート trie)、transactionsRoot(トランザクション trie)、receiptsRoot(レシート trie)です。イエローペーパーはこれらを H_r、H_t、H_e と表記します。上海アップグレード以降、ブロックヘッダには出金リストを記述する withdrawalsRoot(H_w)も加わりました。構造はトランザクション trie に似ていますが、出金一件あたりのデータ量が少なく、ブロックあたりの件数も少ないだけです。イエローペーパーは「H_r が、ブロック内の全トランザクションを順に実行し、さらに全出金を実行した後に得られる状態ルートと等しいこと」を、ブロックヘッダの有効性条件の一つに挙げています。stateRoot が記述するのは累積状態であり、ほかのルートはそのブロックだけを記述します。
| trie | キー | 値 | ライフサイクル |
|---|---|---|---|
| ワールドステート trie | keccak256(アカウントアドレス) | RLP エンコードされたアカウントの四つ組 | ブロックをまたいで継続的に更新 |
| トランザクション trie | rlp(ブロック内でのトランザクションの通し番号) | rlp(トランザクション)。型付きトランザクションは型プレフィックスにエンコード後のトランザクションを連結したもの | ブロックごとに一つ、その後は変更されない |
| レシート trie | rlp(ブロック内でのトランザクションの通し番号) | 型付きレシート、または rlp([status, cumulativeGasUsed, logsBloom, logs]) | ブロックごとに一つ、その後は変更されない |
トランザクション trie とレシート trie の葉の数は、そのブロックのトランザクション数に比例します。したがって、あるトランザクションが取り込まれたかどうかを検証するコストはチェーンの長さに依存せず、そのブロックの規模だけに関係します。ワールドステート trie はネットワーク全体で一つしかなく、規模はネットワーク全体のアカウント数とストレージスロット数に応じて増えます。ブロックヘッダの logsBloom は、レシート内のログのアドレスと topic を集約したもので、クエリの確率的なフィルタリングに使われます。これはインデックスの加速装置であり、約束ではありません。
アドレスはハッシュを経てから trie に入り、アカウントは四つ組
ワールドステート trie のキーは keccak256(アカウントアドレス) で、値は RLP エンコードされたアカウントの四つ組 [nonce, balance, storageRoot, codeHash] です。アドレスそのものは trie に入らず、入るのはその 256 ビットハッシュです。32 バイトは 64 個の nibble(ニブル、半バイト)に対応し、つまり根から葉まで最大 64 層になります。
四つのフィールドにはそれぞれ明確な意味があります。nonce はそのアカウントが送信したトランザクション数で、コントラクトアカウントの場合はそれが作成したコントラクトの数も含みます。balance は wei 単位の残高です。codeHash はアカウントコードの keccak256 で、コード本体はハッシュに基づいて状態データベースに保存されるため、同じバイトコードのコントラクトは同じデータを共有します。storageRoot は別の trie のルートであり、その木はこのアカウントだけに属します。
構造はこうして木の中の木として現れます。ワールドステート trie の葉ノードには、あるアカウントの storageRoot が入っており、このルートがそのアカウント自身のストレージ trie を指します。ストレージ trie のキーは keccak256(32 バイトのスロット番号) で、値はそのスロットの内容(256 ビット)の RLP エンコードです。スロットの値がゼロであることは、仕様上そのスロットが存在しないことと同じであり、あるスロットを 0 に書き戻すことは、状態レイヤーでは削除と同じ効果を持ちます。
キーを先にハッシュ化することで、木の形はハッシュの分布で決まり、攻撃者がキーを選んで形を操作することはできません。もしキーにアドレスやスロット番号をそのまま使うなら、攻撃者は長いプレフィックスを共有するキーを選び出し、ある部分木を極端に深い鎖に押しつぶして、アクセスと証明のコストを膨らませられます。keccak256 がキーを一様に散らすため、こうした構成は成立しなくなります。EIP-8297 のドラフトも、新しい木の構造を論じる中で、ハッシュによって位置が決まり、それによってバランスが保たれる点を設計根拠として挙げています。代価はスロット番号が不可逆になることで、コントラクトはストレージ trie から自分がどのスロットに書いたかを逆算できず、外部からも既知のスロット番号で一件ずつ問い合わせるしかありません。
二つの空値定数は覚えておく価値があります。後続の証明と検証で使うためです。空の trie のルートと、空のアカウントコードのハッシュです。
# pip install eth-utils rlp
import rlp
from eth_utils import keccak
# 空の trie のルート:RLP エンコードした空バイト列に keccak を適用
print(keccak(rlp.encode(b"")).hex())
# 56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421
# 空のアカウントコードのハッシュ
print(keccak(b"").hex())
# c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
三種類のノードと 2 ビットのフラグ:64 層のパスはどう短縮されるか
MPT は二分木ではなく 16 分岐(hexary)の木です。イエローペーパーの付録 D は三種類のノードと一つの空ノードを定義しています。branch ノードは 17 項目を持ち、最初の 16 項目が次の nibble の 16 通りの値に対応し、17 番目はキーがそこで終わる場合のために残されています。extension ノードは [encodedPath, key] の 2 項目で、少なくとも 2 nibble の共通プレフィックスを読み飛ばします。leaf ノードは [encodedPath, value] の 2 項目で、キーの残りと最終値を担います。空ノードは空バイト列で表されます。仕様にはもう一つ見落としやすい不変条件があります。非ゼロの項目を一つしか持たない branch ノードは存在してはならない、というものです。だからこそ、同じキーと値の組に対してエンコード方法は一つしかなく、ルートハッシュが一意の値を取ります。
パスはバイトではなく nibble 単位で構成されるため、パスの長さの偶奇とノードの種類という二つの情報を先頭の nibble に詰め込むコンパクトエンコードが必要になります。
| 先頭 nibble | 2 進数 | ノードの種類 | パスの長さ |
|---|---|---|---|
| 0 | 0000 | extension | 偶数 |
| 1 | 0001 | extension | 奇数 |
| 2 | 0010 | leaf | 偶数 |
| 3 | 0011 | leaf | 奇数 |
長さが偶数のときは、先頭 nibble の後に 0 nibble を一つ補い、エンコード全体の nibble 数が偶数になるようにして、バイト列に落とし込みます。ethereum.org が示す参考実装は次のとおりです(戻り値はここでは bytes に整えています)。
def compact_encode(hexarray):
# 葉ノードのパスは 16 で終わる。検出したら剥がして leaf フラグを立てる
term = 1 if hexarray[-1] == 16 else 0
if term:
hexarray = hexarray[:-1]
oddlen = len(hexarray) % 2
flags = 2 * term + oddlen # 先頭 nibble が種類と偶奇を同時にエンコードする
if oddlen:
hexarray = [flags] + hexarray
else:
hexarray = [flags] + [0] + hexarray # 偶数長では 0 nibble を一つ補う
return bytes(16 * hexarray[i] + hexarray[i + 1] for i in range(0, len(hexarray), 2))
パスの圧縮は、64 nibble という理論上の深さが実際のメインネットでは遠く使い切られていない理由を説明します。EIP-8297 のドラフトは動機の部分で、アカウント trie の現在の最大深度を約 12 層と見積もっています。これはドラフト自身による推定であり、メインネットでの実測データセットではありません。層が浅いほど証明は短くなります。
ノード同士がどう参照し合うかは、32 バイト規則で決まります。子ノードを RLP エンコードした結果が 32 バイト未満なら、その内容はそのまま親ノードにインライン展開されます。32 バイト以上なら、親ノードは keccak(RLP(子ノード)) だけを参照として書き込みます。この規則は一度きりの小さなノード読み取りを大量に省きますが、その代価として、証明を検証する際には実際のエンコード形態どおりにノードを再構成する必要があり、各層が独立したデータベースクエリ一回分だと仮定することはできません。
stateRoot はすべての状態に対する一つの約束
構造全体を一つのハッシュに畳み込む操作は一行だけです。根ノードの RLP エンコードに keccak256 を適用します。イエローペーパーがワールドステートを説明する際に挙げる理由はきわめて直接的で、根ノードは暗号学的に内部のすべてのデータに依存するため、このハッシュをシステム全体の状態の安全な識別子として使える、というものです。
約束の強さは参照の仕方から来ます。親ノードが子ノードを参照するときには子ノードのハッシュを使うため、ある葉の 1 ビットでも変われば、それが属するノードが変わり、それが層ごとに根まで伝わります。keccak256 の衝突耐性を前提にすれば、二つの異なる状態が同じルートを与えるものを見つけることは、ハッシュ衝突を見つけることと同義です。
そこから三つの性質が得られます。根ハッシュはキーと値の集合を一意に確定します。旧状態は根から取り戻せます。ノードは内容でアドレス指定され、構造が不変であり、ノードがデータベースに残っていれば、古い根を知るだけでその時点の状態を復元できるからです。検証者はパスに沿って約束を再構築することもできます。パス上の兄弟ハッシュがあれば、ある葉から根まで再計算するのに十分です。イエローペーパーの付録 D は、証明の空間要件を O(log N) と記しています。
ブロックヘッダにおける定義は時点も限定します。stateRoot は「ブロック内の全トランザクションと出金を実行し、最終処理まで適用し終えた後」の状態ルートです。約束するのはこの瞬間のキーと値の集合であり、この状態がどうやって今の形になったかは約束しません。異なる歴史が同じ状態に収束することもあり、同じ根が連続する複数のブロックに繰り返し現れることもあります。状態の可用性も約束しません。根ハッシュ自体にはノードのデータが一切含まれておらず、あるアカウントを検証するには、パス上のノードを別途取得する必要があります。
根から一つのアカウントへ:Merkle 証明の使い方
アカウントの証明はノードのリストであり、根ノードから始めて keccak256(アドレス) の nibble に沿って層を下っていきます。各層では、branch の対応する項目に当たるか、extension または leaf のパスプレフィックスに当たるかのどちらかです。検証者の動作は機械的です。最初のノードに対して keccak(RLP(ノード)) を再計算し、既知の stateRoot と等しいことを確認します。デコードして nibble に従って次の参照を探し、葉まで繰り返します。葉の中の値が RLP エンコードされたアカウントです。存在しないアカウントの証明も同じように可能で、鍵となるのはパス上で最後に一致したノードを差し出すことです。それが branch なら対応する分岐が空であり、leaf なら目標のパスとどこかの nibble で分岐しています。ストレージの証明はもう一巡必要で、出発点を stateRoot からアカウント内の storageRoot に置き換えます。
EIP-1186 はこの手順を eth_getProof として標準化しました。以下はリクエストの例です(アドレスとスロット番号は置き換え可能です)。
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getProof",
"params": [
"0x7f0d15c7faae65896648c8273b6d7e43f58fa842",
["0x0000000000000000000000000000000000000000000000000000000000000000"],
"latest"
]
}
戻り値の accountProof は stateRoot を起点とするノードの配列で、storageProof の各レコードはそのアカウントの storageRoot を起点とします。どちらも単なるデータであり、信頼できるかどうかは検証者自身の再計算が決めます。
Helios のような軽クライアントは、まさにこの考え方で動きます。コンセンサスレイヤーの軽クライアントがまずビーコンチェーンでブロックヘッダを検証し、認証済みの実行レイヤー payload と stateRoot を取得します。次に、信頼していない任意の RPC に eth_getProof を要求し、最後にローカルで MPT の検証を完了します。EIP-1186 の動機の節も、こうした証明によって、IoT デバイスやモバイルアプリが一つの信頼できるブロックハッシュだけで、信頼できない出所のアカウントとストレージのデータを検証できるようになると述べています。
約束の境界:証明、古い根、軽クライアント
証明の能力には明確な上限があります。証明が覆うのは証明したそのパスだけで、状態のほかの部分を導き出すことはできません。証明のサイズはパスの深さと分岐の広さに応じて増えます。EIP-8297 のドラフトは、いくつかの例による見積もりでこの桁を示しています。アカウント trie の最大深度を約 12 層として計算すると、一つのアカウント分岐には 12 層、各層に 15 個の兄弟ハッシュが必要で、合計 15 × 32 × 12 = 5760 バイトになります。60M Gas のすべてを、多数の異なるコントラクトコードの 1 バイトに触れることに使い、コードが分割されない場合、ドラフトが見積もる証明量は約 1.8 GB です。いずれもドラフト自身の推定であり、メインネットでの実測による検証は受けていません。方向性としての結論は採ってよいものの、具体的な数値を基準として扱うべきではありません。これが MPT が有効性証明に向かないとされる理由でもあります。RLP エンコード、Keccak ハッシュ、木の中の木という構造、そしてコードを部分ごとに証明できないことです。
古い根の証明は永久に得られるものではありません。約束はブロックヘッダに残りますが、それを履行できるかどうかは、クライアントが状態をどう保存するかにかかっています。Geth の v1.16 以降のパスベース・アーカイブは、フラットな状態の履歴差分を保存するため、履歴状態は読めますが、v1.16.x では古い根に対して Merkle 証明を生成できません。v1.17 以降になって、trie の履歴を明示的に保持することで履歴証明をサポートできるようになりました。従来のハッシュベース・アーカイブは履歴 trie ノードを保持し、任意の古い根に対して証明を出せます。ディスクのコストは次の節で扱います。約束の意味論はコンセンサスに属し、約束を履行する方法は実装の選択に属します。
実行レイヤーの軽クライアントも一度縮小を経験しました。メインネットの LES プロトコルはマージ以降、長く安定して動作しない状態が続き、Geth は 2023 年末に関連コードを削除しました。今日のオフチェーン検証は、より多くが「コンセンサスレイヤーが根を認証し、実行レイヤーが証明を提供する」という組み合わせで現れます。一方でコンセンサスレイヤーの軽クライアントが検証するのはビーコンチェーンのブロックヘッダであり、それ自体がトランザクションを再実行することはありません。
状態の膨張:約束の請求書はディスクに届く
stateRoot の表現力は状態の規模とは無関係ですが、それを維持するコストは状態の規模に正の相関を持ちます。状態更新のたびに、葉から根までのパス全体を書き直す必要があり、状態が深く広いほど、トランザクション一件あたりに必要な読み書きが増えます。新しいノードがチェーンに追いつくには、この状態を丸ごとダウンロードしなければなりません。
イーサリアム研究フォーラムの 2025 年 11 月の分析は、一組の桁を示しています。同記事によれば、2025 年 5 月時点で状態だけを保持する Geth ノードの非圧縮データベースは約 340 GiB でした。Gas 上限が 30M から 36M に引き上げられた後、1 日あたりの新規状態の中央値は約 102 MiB から約 205 MiB へ倍増しました。bloatnet プロジェクトのページは 650 GB を臨界規模としており、この規模に近づくと状態アクセス時間が約 40% 増え、メモリ使用量と同期時間が明らかに悪化するとしています。同ページは再現可能なベンチマークデータを示しておらず、ここでの記述はプロジェクトの自己申告に沿って転述したもので、しかも同ページは GiB ではなく GB を使っています。同フォーラムの分析は続けて、保守・基準・積極の三つの Gas 上限経路で外挿し(2027 年半ばにそれぞれ 200M、400M、700M へ上昇)、2027 年半ばの総状態規模が 686 GiB から 1.08 TiB に収まると結論しています。これはシナリオ外挿であり、既成事実ではありません。値はクライアント実装、圧縮とプルーニングの方針、そして Gas 上限の実際の推移に依存します。
ノード運用の話に落とすと、ethereum.org がまとめる Geth フルノード(snap 同期)のディスク要件は 500 GB 以上です。アーカイブノードは Geth 公式の口径ではパスベースが約 2 TB、履歴 trie データを保持する場合は約 6.5 TB、ハッシュベースは 20 TB 超であり、一方で ethereum.org がまとめるクライアント横断のアーカイブ要件は 3 TB から 12 TB 以上です。これらの数字はクライアント実装、圧縮方式、プルーニング方針、Gas 上限に依存し、実装をまたいだ直接比較に意味はありません。状態の大きさはオンチェーンの履歴の大きさとも等しくなく、過去のトランザクションとレシートはまた別の勘定です。
イーサリアムのメインネットの状態は増える一方で減ることはなく、アカウントとストレージスロットは一度書き込まれると永久に領域を占めます。状態の有効期限や状態レンタルの仕組みはなく、圧力は一方向に積み上がります。緩和の方向は大きく二つです。木の構造を置き換えること、たとえば長く議論されている Verkle ツリーや、まだドラフト段階にある分区二分木(Partitioned Binary Tree、EIP-8297 ドラフト)です。もう一つは状態を分割し、個々のノードがその一部だけを保持するようにすることです。後者は後続記事の主題であり、ここでは圧力の出所だけを述べておきます。スロットがコントラクト層でどう並ぶかは、0.19「Storage Layout」を参照してください。
出典
- Ethereum Yellow Paper:第 2 節の状態遷移関数、第 4 章のブロックヘッダフィールドと全体の有効性(stateRoot の定義と検証条件)、付録 D の MPT ノード定義、hex-prefix エンコーディング、32 バイトのインライン規則、D.1 の O(log N) の証明空間
- ethereum.org: Merkle Patricia Trie(ノードの種類、コンパクトエンコードの参考実装、三つの trie のキーと値の定義、トランザクションとレシートの値のエンコード)
- ethereum.org: Ethereum accounts(storageRoot と codeHash の公式な記述)
- EIP-1186: RPC-Method to get Merkle Proofs(eth_getProof のフィールド、存在しないことの証明方法と利用場面。公式例にある空の storageHash(0x56e81f…)と空の codeHash(0xc5d246…)は、本文のコードブロックにある二つの空値定数の照合にも使っている)
- EIP-8297: Partitioned Binary Tree(Draft、作成 2026-06-11、ハッシュ関数は未確定。アカウント trie の最大深度 約 12 層、分岐証明 5760 バイト、最悪ケースで約 1.8 GB の証明量、そして MPT が有効性証明に向かない理由。本文中のこれらの項目はいずれも同ドラフトの自己申告の推定として注記している)
- Ethereum Research: State growth scenarios and the impact of repricings(2025-11-19。340 GiB の状態規模、102 MiB から 205 MiB への 1 日あたりの新規増加、そして三つの Gas 上限経路による 2027 年のシナリオ外挿。同記事はシナリオ分析であり、既成事実ではない)
- Bloatnet Initiative(650 GB の臨界規模と状態アクセス時間の約 40% 増というプロジェクト自己申告の口径。ページに再現可能なベンチマークデータは添付されていない)
- go-ethereum: Archive mode(パスベース・アーカイブの 2 TB と 6.5 TB という口径、ハッシュベース・アーカイブは 20 TB 超になり得ること、そして v1.16.x と v1.17 の履歴証明サポートの違い)
- ethereum.org: Ethereum archive node と Spin up your own Ethereum node(クライアント横断のアーカイブとフルノードのディスク要件の範囲。アーカイブ 3 TB から 12 TB 以上、Geth snap 同期 500 GB 以上)
- go-ethereum PR #28586(LES および関連する軽クライアントコードの削除)
- a16z crypto: Building Helios(コンセンサスレイヤーが stateRoot を認証し、実行レイヤーが eth_getProof でローカル検証する軽クライアントの道筋)