一、朴素做法的代价
假设一个全节点公布了「本区块包含以下 3000 笔交易」,轻节点想核实其中第 1723 笔确实在里面。 直接的方法是下载完整列表并逐项比对,但这会增加传输量与验证时间。 当对象扩大到包含大量账户和 storage 槽的状态集合时,按项提供证明可以避免反复下载全部数据。
Merkle 树给出的答案结构很简洁:让集合本身被压缩成一个哈希,同时保留对单个元素的短路证明。
二、八个叶子:把证明手算一遍
取 SHA-256,把 8 笔交易分别哈希成叶子。下面这些值是真实计算结果, 为节省篇幅只显示前 12 个十六进制字符(完整长度为 64 个字符):
h0 = 95cd603fe577… h4 = 41b637cfd9eb…
h1 = 709b55bd3da0… h5 = a8c0cce8bb06…
h2 = 27ca64c092a9… h6 = d20a624740ce…
h3 = 1f3cb18e8962… h7 = 281b9dba1065…
两两拼接后逐层上卷:
n0 = H(h0 ‖ h1) = 9db4d4c69f3d… n2 = H(h4 ‖ h5) = 8feb1f9ee5e1…
n1 = H(h2 ‖ h3) = 163209155d28… n3 = H(h6 ‖ h7) = a423b34b250e…
m0 = H(n0 ‖ n1) = 9db19fd4038d… m1 = H(n2 ‖ n3) = c16ec3554fac…
root = H(m0 ‖ m1) = f63bbac3bede…
现在只公布 root,其余全部丢弃。别人要相信你持有 h2 这一项,
只需提供它在每条分叉上的兄弟节点——不需要提供你自己那一路,也不需要任何其他叶子:
proof(h2) = [ h3 = 1f3cb18e8962… (右侧兄弟)
n0 = 9db4d4c69f3d… (左侧兄弟)
m1 = c16ec3554fac… (右侧兄弟) ]
验证:
k1 = H(h2 ‖ h3) = 163209155d28… // 与 n1 一致
k2 = H(n0 ‖ k1) = 9db19fd4038d… // 与 m0 一致
k3 = H(k2 ‖ m1) = f63bbac3bede… // 与公布的 root 一致 ✓
8 个元素、3 层、96 字节的证明。元素数量翻一倍,证明只多 32 字节——
这就是 log₂ n 的含义。把 8 换成 2 的 20 次方(约一百万笔交易),证明长度是 20 × 32 = 640 字节。
root f63bbac3bede
/ \
m0 9db19fd4038d m1 c16ec3554fac ← 证明项
/ \ / \
n0 9db4d4… n1 163209… n2 8feb1f… n3 a423b3… ← 证明项
/ \ / \
h0 h1 h2★ h3 ← 证明项
↑
被证明的叶子
可运行的验证脚本
把树的构造与验证写成不到二十行,可以自己改数据跑一遍:
import hashlib
def H(b): return hashlib.sha256(b).digest()
def levels(txids):
"""逐层构建,levels[0] 为叶子层,最后一层只有 root。"""
lv = [[H(t.encode()) for t in txids]]
while len(lv[-1]) > 1:
prev = lv[-1]
lv.append([H(prev[i] + prev[i+1]) for i in range(0, len(prev), 2)])
return lv
def proof_for(lv, index):
"""自底向上取兄弟节点;'L' 表示该兄弟位于左侧。"""
proof, i = [], index
for depth in range(len(lv) - 1):
sib = i ^ 1
proof.append((lv[depth][sib].hex(), 'L' if sib < i else 'R'))
i //= 2
return proof
def verify(leaf_hex, proof, root_hex):
node = bytes.fromhex(leaf_hex)
for sib, side in proof:
s = bytes.fromhex(sib)
node = H(s + node) if side == 'L' else H(node + s)
return node.hex() == root_hex
lv = levels([f"tx{i}" for i in range(8)])
root = lv[-1][0].hex()
print(root[:12]) # f63bbac3bede
print(verify(lv[0][2].hex(), proof_for(lv, 2), root)) # True
三、为什么这个结构是可靠的
安全性依赖哈希函数的两条性质。抗原像性保证攻击者无法从 root 反推出任何一层的内容;
更关键的是抗第二原像性:给定 h2,攻击者需要找到另一组叶子与兄弟节点,
使上卷结果仍等于同一个 root,这在实际成本上不可行。
哈希函数之外,构造方与验证方还必须遵守相同的编码规范。 实现需要明确左右节点的拼接顺序、叶子与内部节点的区分方式,以及奇数个节点和重复数据的处理规则。 验证时应同时检查证明路径与结构约束,不能仅因为最终哈希相同就省略这些检查。
教训是通用的:Merkle 树的安全性不只来自哈希函数,还来自编码约定的严格程度。 拼接顺序、字节序、空子树的表示、叶子与内部节点是否需要域分隔前缀(防止把内部节点当作叶子重放), 每一项都必须在规范里写死。轻客户端实现与全节点实现之间的这类约定不一致,是跨链桥事故中反复出现的成因。
四、以太坊为什么用 trie 而不是二叉树
上面那棵树有个隐含限制:它假设集合是静态的、按顺序排列的。 而世界状态需要频繁增删改:一笔转账改变两个账户余额,一次调用写入几个 storage 槽。 每改一项就重算整棵二叉树的所有路径,代价过高。
以太坊因此使用 Merkle Patricia Trie(MPT),它把三种节点组合起来:
| 类型 | 作用 | 子节点数 |
|---|---|---|
| branch | 按路径 nibble 分叉 | 16 个孩子 + 1 个值 |
| extension | 压缩只有单一子节点的路径段 | 1 |
| leaf | 存放键的剩余部分与值 | 0 |
键是 256 位哈希(账户地址先做一次哈希),路径按 4 位一段向下走,因此最深不超过 64 层, 实践中远小于此。改动一个账户只需要重算从该叶子到根的一条路径, 其余分支的哈希保持不变——这正是「增量更新」所需要的性质。
每个区块头里列出的根包括:
- stateRoot:世界状态 trie 的根,账户余额、nonce、代码哈希与 storage 根都在其下;
- transactionsRoot:本区块交易列表的 trie 根;
- receiptsRoot:执行结果、gas 消耗与日志的 trie 根;
- withdrawalsRoot 等后续新增字段。
于是三类问题都有了对数级证明:这笔交易的哈希能折进 transactionsRoot 吗?
这条日志(比如一次 ERC-20 转账事件)在 receiptsRoot 里吗?
某个账户在 stateRoot 下的余额是多少?
第三种尤其重要,它是跨链桥与轻客户端最常见的查询:不需要重放任何交易,只要拿到一条路径上的节点集合即可。
五、缺失的一环:这个根来自哪个区块
Merkle 证明回答的是「给定根,某项是否在其中」。它不回答「这个根是不是当前有效的根」。 后者是一个共识问题,与最终性直接相关:
- 工作量证明链:轻节点下载连续或稀疏的区块头,比较累计难度,据此判断哪条分支有效。 这只需要几十个字节的头信息,是 SPV 的原始形态。其局限是只能验证「头」层面的事实,无法验证具体状态, 且一旦本地算力估计错误就可能被诱导接受一条假链。
- 权益证明链:区块头由验证者签名,轻节点需要知道「当前哪个签名委员会算数」。 这就回到弱主观性前提:必须从一个受信任的检查点开始同步,之后才能靠同步委员会的轮换自行跟进。
换句话说,Merkle 树负责把大量数据的完整性压缩成一个数, 共识机制负责告诉你现在该相信哪个数。两者缺一,轻客户端都不成立。 跨链桥的事故多发生在第二环:桥的合约正确验证了证明本身,却对「哪个根是最新的」采用了过弱的判断规则。
六、小结
- Merkle 证明的成本是
log₂ n个哈希,验证方只需一个已知根。 - 可靠性同时依赖哈希性质与编码约定;顺序、域分隔、重复分支都是真实攻击面。
- 以太坊用 MPT 换取增量更新能力,代价是证明结构与验证逻辑更复杂。
- 「根是否有效」不由 Merkle 树回答,那是共识层的职责,也是轻客户端信任假设的落点。
- R. Merkle, A Certified Digital Signature, CRYPTO 1989。
- Satoshi Nakamoto 白皮书 §7「Merkle Tree」;比特币早期重复分支缺陷的修复记录。
- P. Liu, Ethereum: an executable blockchain 与 Yellow Paper 附录 D「Hex-Prefix Encoding」。
- EIP-1186, State Proofs,定义
eth_getProof的返回结构。