← 全部笔记

默克尔证明:如何高效验证数据

入门 密码学

轻节点不必下载完整数据集,也能结合可信区块头和包含性证明核验一条记录。 Merkle 树把「证明某项在集合里」所需的证明体积与验证计算量降到对数级,但并不单独保证记录的最终性。 本文用八个叶子节点说明证明过程,再介绍以太坊的 trie 结构与根节点的信任来源。

一、朴素做法的代价

假设一个全节点公布了「本区块包含以下 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                                  ← 证明项
                ↑
             被证明的叶子
星号标出被证明的叶子,其余高亮项构成证明。注意证明里不含 h0、h1,也不含 n2、n3 之下的任何叶子。

可运行的验证脚本

把树的构造与验证写成不到二十行,可以自己改数据跑一遍:

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),它把三种节点组合起来:

MPT 的节点类型与用途
类型作用子节点数
branch按路径 nibble 分叉16 个孩子 + 1 个值
extension压缩只有单一子节点的路径段1
leaf存放键的剩余部分与值0

键是 256 位哈希(账户地址先做一次哈希),路径按 4 位一段向下走,因此最深不超过 64 层, 实践中远小于此。改动一个账户只需要重算从该叶子到根的一条路径, 其余分支的哈希保持不变——这正是「增量更新」所需要的性质。

每个区块头里列出的根包括:

于是三类问题都有了对数级证明:这笔交易的哈希能折进 transactionsRoot 吗? 这条日志(比如一次 ERC-20 转账事件)在 receiptsRoot 里吗? 某个账户在 stateRoot 下的余额是多少? 第三种尤其重要,它是跨链桥与轻客户端最常见的查询:不需要重放任何交易,只要拿到一条路径上的节点集合即可。

日志证明的实际问题 MPT 的分支因子是 16,因此同样规模的集合,其证明体积与层数并不像二叉树那样整齐; 节点是 RLP 编码的,验证方还要顺带验证编码合法性。工程上常见的错误是只校验哈希链而忽略编码约束, 这会让构造出的畸形节点通过验证。

五、缺失的一环:这个根来自哪个区块

Merkle 证明回答的是「给定根,某项是否在其中」。它不回答「这个根是不是当前有效的根」。 后者是一个共识问题,与最终性直接相关:

换句话说,Merkle 树负责把大量数据的完整性压缩成一个数, 共识机制负责告诉你现在该相信哪个数。两者缺一,轻客户端都不成立。 跨链桥的事故多发生在第二环:桥的合约正确验证了证明本身,却对「哪个根是最新的」采用了过弱的判断规则。

六、小结

  1. Merkle 证明的成本是 log₂ n 个哈希,验证方只需一个已知根。
  2. 可靠性同时依赖哈希性质与编码约定;顺序、域分隔、重复分支都是真实攻击面。
  3. 以太坊用 MPT 换取增量更新能力,代价是证明结构与验证逻辑更复杂。
  4. 「根是否有效」不由 Merkle 树回答,那是共识层的职责,也是轻客户端信任假设的落点。
  1. R. Merkle, A Certified Digital Signature, CRYPTO 1989。
  2. Satoshi Nakamoto 白皮书 §7「Merkle Tree」;比特币早期重复分支缺陷的修复记录。
  3. P. Liu, Ethereum: an executable blockchain 与 Yellow Paper 附录 D「Hex-Prefix Encoding」。
  4. EIP-1186, State Proofs,定义 eth_getProof 的返回结构。