Ethereum learning path · 11.03

一枚根哈希,如何承诺整个以太坊?Modified Merkle Patricia Trie · State · Storage · Transaction

根哈希里没有塞进所有账户与交易。它更像一枚无法偷偷换内容的“总封条”: 沿着正确路径,只带少量节点,就能验证一条账户状态、一格合约存储, 或区块中的一笔交易。本课把这套结构从半字节讲到区块头。

  • 预计75 分钟
  • 难度从零到协议细节
  • 实验3 个本地演示

Commitment

根哈希是承诺,不是压缩包 数据仍在节点中;根只把整套键值与结构绑定为 32 字节。

Path

路径由 nibble 驱动 一个字节拆成两个十六进制半字节,每一步最多选 16 个方向。

Nesting

状态树里嵌着存储树根 全局账户叶子不直接装合约变量,而是装它自己的 storageRoot。

Order

交易树承诺内容与顺序 它用区块内索引作 key,而不是用 transaction hash。

00 · Orientation

先把“三棵树”分开

它们复用同一种 MPT 骨架,但生命周期、key、value 与根所在位置都不同。

读到 “State Trie、Storage Trie、Transaction Trie” 时,最危险的做法是把它们想成 一棵巨大目录树的三个文件夹。更准确的模型是:三类独立的键值承诺结构, 其中 State Trie 的账户值会引用某个账户自己的 Storage Trie 根。

Global / evolving 现在全网有哪些账户,它们各是什么状态?

由 State Trie 回答。每执行一个区块,客户端从旧状态导出新状态,并得到新的 stateRoot。

Per account / evolving 这个合约的每一个持久化槽位是什么值?

由该账户独立的 Storage Trie 回答。它的根被装进 State Trie 的账户记录。

Per block / immutable 这个区块按什么顺序包含了哪些交易?

由该区块的 Transaction Trie 回答。区块确定后,这棵树不再更新。

Sibling / clarification 交易执行后的状态、日志与 Gas 结果在哪里?

状态结果进入新 stateRoot;每笔交易的执行收据另有 Receipts Trie 与 receiptsRoot。

本课第一句总纲

MPT 是确定性的、可验证的键值映射承诺。相同键值集合必得相同根; 改动任何被承诺的键、值或结构,沿途哈希都会改变,最终得到不同根。

01 · Commitment

为什么不直接保存一张清单?

因为节点不只要“读到数据”,还要能在不信任数据提供者时验证它。

假设服务器告诉你:“地址 A 的余额是 12 ETH。”普通数据库可以很快返回 12, 但这个答案本身不能证明服务器没有说谎。若你已经信任某区块头里的 stateRoot,服务器再提供从根到 A 的少量 MPT 节点,你就能在本地重算并验证。

01 / TRIE 按前缀寻址

把 key 拆成符号序列,沿路径逐步找到 value。

02 / PATRICIA 压缩共同路径

把没有分叉的一长段合并,避免许多只有一个孩子的空层。

03 / MERKLE 用哈希绑定结构

父节点引用子节点内容,所有改变最终传导到唯一根。

CONCEPTUAL FORMULA root = Keccak256(RLP(rootNode)) 根不是所有数据的可逆压缩,而是对整棵结构的密码学承诺。

四个能力,正好对应节点的四个需要

  1. 查找:给定 key,能走到对应 value。
  2. 更新:只重建受影响路径,其余不可变节点可复用。
  3. 证明:只提供路径上的节点,就能对可信根验证存在或不存在。
  4. 确定性:不同客户端处理相同键值后,必须算出同一个根。
别把“Merkle”误听成压缩

32 字节根不能还原整棵树。验证者仍需要叶子与路径节点;根的作用是让这些数据 不能在不被发现的情况下被替换

02 · Anatomy

MPT 的路径与三种节点

先把 byte 拆成 nibble,再让 Branch 分叉、Extension 跳过共同前缀、Leaf 收尾。

一步不是一个字节,而是半个字节

nibble 是 4 bit,可表示 0…f 共 16 种值。 一个 byte,例如 0xa7,会拆成两个 nibble:a7。 所以 32 字节哈希会形成 64 步的候选路径。

17 items Branch Node

[child₀ … child₁₅, value]。下一个 nibble 是几,就选第几个 child;第 17 项供“key 恰在此结束”时存值。

shared: a7·3f→ child
2 items / non-terminal Extension Node

[encodedPath, childRef]。把所有后代共同拥有、且中途没有分叉的一长段路径压成一次跳跃。

rest: 09·b2→ value
2 items / terminal Leaf Node

[encodedPath, value]。确认剩余路径完全匹配后,返回最终 value;它必须能与 Extension 区分。

Hex-prefix:两 bit 标记两件事

两项节点的路径要被装回 bytes。首个 flag nibble 同时记录: 它是 Leaf 还是 Extension,以及剩余 nibble 数为奇数还是偶数。

Flag节点类型路径奇偶首部含义
0Extension偶数0 + padding 0
1Extension奇数1 + first nibble
2Leaf偶数2 + padding 0
3Leaf奇数3 + first nibble
公式

flag = 2 × isLeaf + isOdd。例如剩余路径 [a,b,c]: Extension 编为 0x1abc;Leaf 编为 0x3abc

为什么有时父节点存哈希,有时直接嵌入?

节点先做 RLP 编码。若 RLP(node) 长度小于 32 字节, 父节点直接嵌入它;若长度大于等于 32 字节,父节点存 Keccak256(RLP(node))。短节点直接放进去,比再放一个 32 字节哈希更省空间。

03 · State Trie

全局账户状态的总索引

路径来自地址哈希,叶子是四项账户记录;storageRoot 让“树中有树”成为可能。

stateRoot =
  MPT_ROOT({
    Keccak256(address20)
      ↦ RLP([nonce, balance, storageRoot, codeHash])
  })

四个字段分别承诺什么?

  • nonce:EOA 已发送交易数,或合约创建计数相关状态。
  • balance:该账户持有的 wei 数量。
  • storageRoot:该账户独立 Storage Trie 的根。
  • codeHash:账户 EVM 字节码的 Keccak 哈希。
重要边界

合约代码正文不在账户叶子里,也不在 Storage Trie 里。叶子只存 codeHash;客户端按该哈希在代码数据库中另存字节码。没有代码、也没有 EIP-7702 delegation designation 的传统 EOA,其空代码哈希是 Keccak256(""),不是空 Trie 根。EIP-7702 之后,EOA 地址可以保存一段 0xef0100 || delegateAddress 委托指示器,此时它的 codeHash 也会相应改变。

04 · Storage Trie

每个账户自己的持久化存储

逻辑 slot 先确定,再经一次 Keccak 变成 MPT 路径;零值默认不占叶子。

EVM 把合约持久化 storage 看成 2²⁵⁶ 个 32 字节槽位。 Storage Trie 不是一棵全网共用树:每个账户都有自己的独立 storage trie, 空账户使用统一的空 Trie 根。

storageRoot(account) =
  MPT_ROOT({
    Keccak256(slot32)
      ↦ RLP(value)
    | value ≠ 0
  })

Mapping 为什么看起来“哈希了两次”?

这是两个不同层级。Solidity 先把高级语言变量映射到 EVM 的逻辑槽位; Storage Trie 再把这个 32 字节槽位哈希成树路径

// balances 位于声明槽 p,查询 balances[user]
logicalSlot = Keccak256(pad32(user) || pad32(p))
triePath   = nibbles(Keccak256(bytes32(logicalSlot)))
两层不要折叠

第一次 Keccak 来自 Solidity storage layout,用来求 mapping 元素的逻辑槽; 第二次 Keccak 来自 secured Storage Trie,用来均匀化 MPT 路径。

RPC 参数停在哪一层?

调用 eth_getStorageAt,或在 eth_getProofstorageKeys 中指定槽位时,传入的是逻辑 slot, 不是 Keccak256(slot32) 后的 MPT 路径。客户端会在内部完成 secured Storage Trie 的第二次 Keccak。

为什么写入 0 常被称为“删除槽位”?

Storage 的默认值是 0。MPT 不必为默认值保存叶子,因此把某槽写回 0,会从 trie 中移除该 key。 读取不存在的槽仍返回 0;这也是“缺省值”和“显式存一个 0”在协议状态里不作区分的结果。

05 · Transaction Trie

一棵树,承诺一个区块的交易与顺序

它不是全局交易数据库,也不用 txHash 作 key;它为每个区块单独构建。

transactionsRoot =
  MPT_ROOT({
    RLP(transactionIndex)
      ↦ encodedTransaction
  })

三个精确点

  1. key 是零基索引的 RLP。 index 0 → 0x80,index 1 → 0x01, index 128 → 0x8180。它不会再被 Keccak 哈希。
  2. value 保留交易封装。 Legacy 交易为 RLP 列表;typed transaction 为 typeByte || payload
  3. 顺序本身被承诺。 只要交换两笔不同的编码交易,它们与 index 的 key/value 绑定就会改变, transactionsRoot 也会改变;若两个 value 逐字节完全相同,互换当然不可观察。
常见偷换

transactionHash 用来标识一笔交易;transactionsRoot 承诺区块内按索引排列的整组编码交易。两者都用哈希,却回答不同问题。

06 · Comparison

把三棵 Trie 放进同一张表

先看生命周期,再看 key 是否哈希,最后看 root 被谁引用。

Trie 生命周期 逻辑 key 实际路径 value root 所在
State 全局状态随区块演进 20-byte address nibbles(Keccak(address)) RLP(account) 区块头 stateRoot
Storage 每账户一棵,随存储变化 32-byte slot nibbles(Keccak(slot)) RLP(U256),0 省略 账户叶子 storageRoot
Transaction 每区块一棵,确定后不变 区块内 index nibbles(RLP(index)) 编码交易 区块头 transactionsRoot

PATH EXPLORER

切换三类 Trie,看同一个 “key → path → value → root” 模板如何改变。

LOCAL DEMO
LOGICAL KEY address 0x71…a9

20 字节账户地址

TRANSFORM Keccak256(address)

secured trie:先哈希

NIBBLE PATH a · 7 · 3 · f · … · 9

64 个 nibble

VALUERLP([nonce,balance,storageRoot,codeHash])
COMMITMENTblockHeader.stateRoot

07 · Update Ripple

一次 SSTORE,如何一路改变 stateRoot?

只重建两条受影响路径:先是账户的存储路径,再是全局账户路径。

假设合约把 slot 1 从 640 写成 900。客户端不会重写所有账户和槽位; 它创建新的相关叶子与祖先节点,未受影响的节点可以继续复用。最终得到新的 storageRoot,再把它写入该账户记录,继而得到新的 stateRoot

UPDATE RIPPLE

观察值改变如何穿过两个嵌套 Trie,抵达区块头。

ILLUSTRATIVE HASHES
01 / SLOT VALUE slot 1 = 640 leaf 0x14…b0
02 / STORAGE PATH 沿途节点重算 path 0x8a…20
03 / ACCOUNT ROOT storageRoot 改变 0x6b…e1
04 / STATE PATH 账户叶子与祖先重算 0xa7…09
05 / BLOCK HEADER stateRoot 改变 0x11…c8

修改前:旧 stateRoot 对应旧世界状态。点击“执行 SSTORE”查看哈希连锁。

持久化结构的价值

新旧状态可以共享绝大多数未变节点。概念上,你可以把不同区块的 stateRoot 理解为对不同“状态版本”的入口,而不是每个区块复制一份完整数据库。

08 · Merkle Proof

如何在不信任提供者时验证一条数据?

从可信 root 开始,逐个解 RLP、核对引用、消费路径,直到 Leaf 完整匹配。

存在证明不是“给我一个值”

  1. 你先拥有一个可信区块头,因而信任其中的 stateRoot
  2. 提供者给出账户值,以及从 root 到该 Leaf 的 RLP 节点序列。
  3. 你计算地址的 Keccak 与 nibble 路径,从 root 逐步核对节点引用。
  4. Leaf 剩余路径完全匹配,且 value 等于该账户 RLP,证明成立。

PROOF WALKER

逐步检查一份简化的账户 inclusion proof;再篡改 Leaf 看根承诺如何拒绝它。

EDUCATIONAL MODEL
ROOTstateRoot = 0x11…c8trusted
BRANCH[a]childRef = 0x7c…91pending
EXT[73f]shared path matchespending
LEAF[…9]balance = 4.2 ETHpending
验证进行中 继续检查路径节点

不存在也能证明

如果路径落到空 child,或遇到一个剩余路径不匹配的 Extension / Leaf, 这组节点同样可以证明该 key 在此根下不存在。MPT proof 因此不只是“一串兄弟哈希”: 它包含路径上的 RLP 节点,短节点还可能直接嵌在父节点里。

现实接口

JSON-RPC 方法 eth_getProof 可返回账户的 accountProof 与指定槽位的 storageProof。账户证明从区块 stateRoot 开始; storage 证明从账户记录里的 storageHash 开始。

09 · Client Reality

协议里的 Trie,不等于磁盘上的文件夹

根与证明是共识语义;客户端可以自由优化底层数据库、缓存、快照和同步方式。

协议要求所有正确执行客户端对同一状态算出相同根,但不要求它们用同一种磁盘布局。 客户端可能采用 flat state、snapshot、缓存、path-based 存储或其他索引来加速读取; 只要最终 trie 承诺与执行结果满足规范即可。

Consensus semantic 必须一致

账户与 storage 的协议值、交易顺序、RLP/typed 编码、MPT 根、区块头字段。

Implementation choice 可以不同

节点怎样落盘、怎样缓存、是否维护扁平索引、如何做垃圾回收与快照。

三个 32 字节常量不要混

  • 空 Trie 根:0x56e81f…63b421,即 Keccak256(RLP(""))
  • 空代码哈希:0xc5d246…85a470,即 Keccak256("")
  • 零值:storage 默认 0;它通常意味着该 key 根本不进入 Storage Trie。
历史状态不是“永远免费保存”

区块头永久承诺当时的根,不等于每个普通节点都必须无限保留所有历史 Trie 节点。 节点的历史数据保留、归档与同步策略,是协议可验证性与本地存储策略之间的另一个层次。

10 · Roadmap Context

MPT 是当前事实,不是永恒终点

路线图曾长期研究 Verkle;截至 2026 年,官方长期方向已表述为 binary trees 与 statelessness。

CURRENT PROTOCOL Hexary MPT

十六叉、Keccak、RLP、账户树嵌套 storage 树。本课全部核心细节以此为准。

RESEARCH HISTORY Verkle direction

曾是 The Verge 的主要研究路线,目标之一是显著缩小状态 witness。

LONG-TERM DRAFT Unified Binary Tree

EIP-7864 提议统一二叉状态树,但仍为 Draft,连最终哈希函数也未定。

2026 年 Ethereum Foundation 的协议优先级更新,把长期状态扩展方向写成 “a move to binary trees and statelessness”。这说明学习 MPT 时要区分两句话:

  1. 现在如何验证主网执行状态?答案仍是当前十六叉 MPT 语义。
  2. 未来状态结构可能怎样改?统一二叉树是草案方向,不是已经迁移的事实。
写技术材料时的时态

可以说“Ethereum 正在研究/提议用统一二叉树替代当前 MPT”; 不应说“主网已经使用 Verkle”或“EIP-7864 已确定上线”。

11 · Misconceptions

五个必须当场纠正的误区

能解释这些差异,才算真正把三棵 Trie 分开。

01 “三棵 Trie 的 key 都先做 Keccak。”

错。State 与 Storage 是 secured trie;Transaction Trie 使用原始 RLP(index) 路径。

02 “Transaction Trie 用 txHash 作 key。”

错。key 是区块内索引;因此 transactionsRoot 同时承诺交易内容与顺序。

03 “Mapping 算出的 Keccak 就是最终 Trie 路径。”

不完整。它先给出逻辑 storage slot;secured Storage Trie 再对 slot32 做一次 Keccak。

04 “合约代码存在 State 或 Storage Trie 叶子里。”

错。账户叶子存 codeHash;字节码正文由客户端按哈希单独保存。

05 “Proof 只能证明存在,就是一串兄弟哈希。”

错。MPT proof 是路径上的 RLP 节点;空分支或不匹配路径也能证明不存在。

12 · Checkpoint

六题检验你是否真的掌握

每题只有一个最佳答案。先作答,再阅读反馈。

Q1为什么 Branch Node 最多有 16 个 child?

选择一个答案。

Q2合约字节码正文直接存在哪里?

选择一个答案。

Q3区块内第 2 笔交易在 Transaction Trie 里用什么作 key?

选择一个答案。

Q4某 storage slot 被写回 0,Trie 通常怎样表示?

选择一个答案。

Q5一次 SSTORE 为什么会改变区块的新 stateRoot?

选择一个答案。

Q6MPT 能否证明某个 key 不存在?

选择一个答案。

13 · Sources & Glossary

一手资料与最小术语表

正文以当前执行规范、Ethereum.org、EIP 与 Solidity 官方存储布局为事实边界。

Trie
按 key 的符号序列逐步寻址的前缀树;名称来自 retrieval,不是拼错的 tree。
Patricia
Practical Algorithm To Retrieve Information Coded in Alphanumeric;在这里主要体现为共同路径压缩。
Merkle
父节点通过哈希引用子内容,使任意底层改变传导到根,并支持局部证明。
Nibble
4 bit 半字节,取值 0–15;十六进制恰好用 0–f 表示。
RLP
Recursive Length Prefix,以太坊执行层用于嵌套字节序列的规范序列化编码。
Root
32 字节密码学承诺;它绑定整棵 trie 的键、值与结构,但不包含可逆的全部数据。
Proof
从可信根到目标 key 的路径节点;可验证 inclusion,也可验证 non-inclusion。

Compressed understanding

State Trie 回答谁是什么状态; Storage Trie 回答这个账户记住了什么; Transaction Trie 回答这个区块按何顺序收了什么。 MPT 把三类答案都变成可验证的根承诺。

当你下一次在区块浏览器或 RPC 里看到 stateRoot、transactionsRoot、storageHash, 不要把它们只当作随机十六进制。它们分别是进入一套确定键值关系的可验证入口。