
刚啃以太坊源码时我最先跳过的是区块头里的receiptsRoot。txRoot好歹能猜出来是交易集合的承诺收据树receipt trie是什么交易执行完不就行了为什么还要单独为“收据”再建一棵树直到后来被日志查询、轻节点验证这些需求反复教育才把这笔账彻底算清楚交易树管“交易确实被包进这个区块了”收据树管“交易执行后到底发生了什么”两者缺一不可。这篇文章就围绕 ETH 的交易树和收据树把定位、构建细节、验证路径和实战中的坑一次讲透。适合正在读 go-ethereum 源码、想搞懂区块头三个 Root 的人也适合要写区块索引器或轻节点验证逻辑的开发者参考。1. 区块头里的三棵树先定位交易树和收据树1.1 三个 Root 各自守护什么以太坊区块头里躺着三棵默克尔树的根哈希Root状态树根stateRoot执行完区块内所有交易后的全局状态承诺包含账户余额、Nonce、合约存储等。TxHash交易树根transactionsRoot区块内所有交易列表的承诺。ReceiptHash收据树根receiptsRoot区块内每笔交易执行结果的承诺。很多人能记住这三个字段但说不清为什么需要三棵而不是一棵。我的理解是这三个根分别回答了三个不同的问题。状态树回答“世界长什么样”交易树回答“这个世界为什么变成这样”也就是输入收据树回答“输入被执行后输出了什么”也就是结果。一个区块从网络传输角度可以拆成两部分区块头Header和区块体Block Body。区块体里存的是交易列表和叔块哈希交易执行后产生的收据在传统 Block Body 里并不直接跟随广播而是通过共识协议或同步流程另行获取。轻节点通常只同步区块头不下载完整交易和状态。它们要信任一个区块依赖的就是头里这几个 Root。如果交易列表被人篡改TxHash对不上如果执行结果被人改动ReceiptHash对不上如果账户数据被改stateRoot对不上。三棵树各自独立又相互锚定构成了区块的“指纹”。1.2 为什么交易列表和交易结果要分开承诺直接一点说交易本身不包含执行结果。同一笔交易在不同时刻、不同 Gas 价格、不同前置状态下执行消耗的 Gas、产生的日志、最终是否成功都不同。交易是“输入”收据是“输出”两者必须分开记录。如果把收据塞进状态树会带来两个问题第一状态树是全局账户模型的载体它关心的是账户和存储的最终形态不是某个区块内的过程性事件第二日志和收据是强顺序、区块级别的数据和账户的全局状态没有直接对应关系硬塞进去会导致状态树过度膨胀也会让状态树的叶子编码变得极其复杂。所以以太坊选择了独立的两棵区块级树交易树管输入收据树管输出。两者用同一个 key 串起来交易在区块里的序号。三棵树的对比关系大致如下树KeyValue作用域主要价值状态树keccak256(地址)RLP(账户)全局账户与合约存储的可验证快照交易树RLP(交易序号)RLP(交易)区块级交易包含性证明与完整性校验收据树RLP(交易序号)RLP(收据)区块级执行结果、Gas、日志的可验证记录2. 交易树MPT 的另一种应用key 是序号而非哈希2.1 树的构建规则交易树和状态树一样底层用的是 Merkle Patricia TrieMPT但构建方式有明显差异。状态树的 key 是账户地址的 keccak256 哈希value 是账户的 RLP 编码交易树的 key 是交易在区块中的序号的 RLP 编码value 是交易本身的 RLP 编码。在 go-ethereum 中计算交易树根的核心逻辑封装在types.DeriveSha里。简化后的行为是这样的func DeriveSha(list DerivableList, hasher TrieHasher) common.Hash { trie : newEmpty(hasher) for i : 0; i list.Len(); i { key, _ : rlp.EncodeToBytes(uint64(i)) value : list.GetRlp(i) trie.Update(key, value) } return trie.Hash() }关键点在于 key。交易在区块内从 0 开始编号第 i 笔交易的 key 就是rlp(i)。这里有个很细节的编码现象RLP 编码整数时0 会被编码为0x801 到 127 直接编码为对应字节128 之后就带长度前缀了。比如rlp(0)0x80rlp(1)0x01rlp(127)0x7frlp(128)0x81 0x80这个细节会影响 MPT 的路径形状但不会带来正确性问题。MPT 的路径压缩机制对连续、短小的 key 非常友好无论是几个交易还是一个区块塞满上百笔交易树的深度都能控制在合理范围内。2.2 为什么交易树不用安全哈希状态树在构建时key 会先经过keccak256再作为树路径这种改造在以太坊里叫 secure trie。目的是防止攻击者精心构造 key 序列让树退化成非常深的长链进而引发严重的性能问题甚至 DoS。因为账户地址是用户可以控制的输入恶魔节点可以挑选特殊地址让路径几乎不共享前缀。交易树的 key 是序号天然紧凑有序。而且一个区块内的交易顺序是由矿工打包时决定的外部攻击者无法在已经打包好的交易列表里强行植入恶意 key 分布。即使某个矿工想刻意构造坏形状的交易树那也是一条“伤敌一千自损八百”的路共识验证阶段其他节点会重新计算 Merkle Root一旦算出来的根不匹配整个区块直接作废。更重要的是序号这种短 key 本身就能让 MPT 高效工作不需要再额外哈希。省掉keccak256既有性能收益也避免了 pointless 的存储开销。2.3 交易树能提供什么证明交易树的第一个价值是包含性证明。给定区块头里的TxHash再给定一笔交易的原始 RLP 和从叶子到根沿途的兄弟节点哈希任何人都能从这笔交易出发一层层向上计算出根哈希并与TxHash比对。一致就证明这笔交易确实出现在这个区块的这个位置。轻节点不需要下载整个区块就能完成这种验证。第二个价值是篡改检测。全节点在同步或验证一个新区块时会把区块体里的交易重新执行并重新计算交易树根。只要交易列表里任何一笔交易被改动、调换顺序或增删重建出的TxHash就会和 Header 里记录的对不上区块会被直接拒绝。第三个价值是有序枚举。因为 key 就是序号索引器或分析工具可以轻松地按顺序遍历一个区块内的全部交易而不用额外维护二级索引。这也是为什么交易哈希虽然是交易的全局 ID但在区块内部树的结构始终以序号为准。3. 收据树交易结果的“官方账本”3.1 收据里到底有什么在以太坊中每笔交易经过 EVM 执行后都会产出一个 Receipt收据。它不是钱包里那种纸质回执而是一份结构化的执行结果记录。收据树就是把这些收据按同样的序号组织成另一棵 MPT。一个典型的收据包含以下核心字段Status执行结果状态码1 表示成功0 表示失败或回滚。CumulativeGasUsed区块内从第一笔交易到当前这笔交易累计消耗的 Gas。Bloom该交易日志集合对应的布隆过滤器。Logs交易触发的事件日志列表包括日志地址、主题数组和 Data 数据。ContractAddress如果是创建合约的交易这里会记录新合约地址。GasUsed这一笔交易消耗的 Gas这个字段本身是派生出来的便于索引。在拜占庭升级EIP-658之前Receipt 里记录的是PostState也就是交易执行后的状态根升级之后改成了Status0/1。所以你在老教程里看到的“收据包含 state root”和现代源码里的Status并不矛盾只是历史演化。理解这一点看不同年代代码时不会懵。收据树的 key 和交易树完全一致也是rlp(i)value 是收据的 RLP 编码。这意味着第 i 笔交易有唯一的第 i 棵收据树叶节点交易和它的执行结果通过同一个序号严格绑定。3.2 失败的交易也有收据树照常生长这是新手最容易误解的地方。很多人以为交易执行失败就不会产生收据或者收据树里只记录成功的交易。实际上完全不是这样。在 EVM 里一笔交易发生 revert 或执行异常时状态变更会被回滚但交易本身已经消耗了 Gas区块也已经包含了这笔交易所以必须存在一个收据来记录这次失败。这个收据的Status字段是 0CumulativeGasUsed照样累加Logs里可能也有部分日志比如 revert 前产生的事件虽然最终状态回滚但日志是历史记录不可抹除。收据树会像处理成功交易一样把失败交易的收据作为叶子节点放进去。我在调试一条被 revert 的合约调用时就曾踩过这个认知坑以为eth_getTransactionReceipt返回空是因为交易不存在后来才发现返回值里status明明是0x0。所以请记住树不关心交易成功与否只关心交易是否在这个区块里被执行过。3.3 Bloom 过滤器与日志检索收据树能支撑起链上日志查询靠的是 Bloom 过滤器。每个收据的Bloom是一个 2048 位的位数组由日志的合约地址和 Topics 分别做哈希并映射到若干位。计算方式是典型的 Bloom 过滤器对每个值取 3 个哈希位把对应 bit 置 1。多个日志的位做并集就是这个收据的 Bloom。区块头里还有一个LogsBloom字段它是区块内所有收据 Bloom 的并集。这个设计非常聪明查询日志时比如用eth_getLogs按地址和 topic 检索客户端可以先看区块头的LogsBloom。如果该区块的 Bloom 都不包含要查的地址或 topic那整个区块都可以直接跳过完全不用下载交易和收据只有 Bloom 命中可能误判的区块才值得进一步打开逐笔交易过滤。在实际同步和索引场景里这种初筛能省下大量 IO。收据树的另一个价值是它为日志和 Gas 数据提供了 Merkle 承诺。某个第三方索引服务要向你证明“这个日志确实在链上”不必拉取整条链重放只要给出收据叶子节点和兄弟哈希就能用ReceiptHash完成验证。这对去中心化索引、跨链证明、审计场景都非常实用。4. 区块构建与验证中的顺序先执行后建树4.1 矿工打包流程里的位置交易树和收据树不是凭空来的它们在一个区块的生命周期里有明确的构建时机。矿工的工作流程大致是从交易池挑选交易拼出一个候选交易列表。按列表顺序逐笔执行交易每笔交易执行完生成对应的 Receipt。所有交易执行完后把交易列表交给DeriveSha得到TxHash把收据列表交给DeriveSha得到ReceiptHash。从执行后的状态数据库拿到stateRoot。组装区块头广播区块。注意一个事实交易树可以在执行之前就构建因为交易列表是确定的但收据树不行必须等所有交易执行完毕、拿到全部收据后再构建。所以在打包阶段ReceiptHash天然晚于TxHash产生。这也从另一个角度解释了为什么收据是执行结果不是输入的一部分。4.2 全节点验证如何比对验证节点收到一个新区块时不会单方面信任 Header 里的 Root它会自己做一遍同样的工作从区块体拿到交易列表。按顺序重新执行每一笔交易。得到新的状态根、交易根、收据根。依次与 Header 里的Root、TxHash、ReceiptHash比对。任何一个 Root 不匹配区块都会被拒绝。实际上交易树根在验证时几乎不可能出错因为交易列表就是原样拿到的真正考验执行环境一致性的是状态树根和收据树根。收据树根一旦算错说明执行器对某些 opcode 的语义、Gas 计费、日志编码等细节和共识规则不一致这也是为什么运行一个非标准客户端很难混过验证——你可以在状态根上碰运气但收据树和日志数据的分歧会先暴露出来。4.3 链重组时树的变化交易树和收据树的 key 是“区块内序号”这是理解一切后续现象的基础。同一个交易哈希在不同区块里可能出现在不同序号位置因为不同矿工打包顺序不同。一旦发生链重组原先在区块 A 里的某笔交易可能被塞进新区块 B 的不同位置那么它在两棵树里的 Merkle 路径就完全变了对应收据树的叶子也变了。这对区块索引器是个重要提醒永远不要只拿交易哈希当主键去关联收据必须用(blockHash, transactionIndex)作为定位依据。交易哈希适合做全局搜索但树的结构和验证逻辑只认区块内序号。5. 用代码复现交易树和收据树的生成5.1 最小可运行的构建示例纸上谈兵不如动手敲一遍。下面这段 Go 代码演示了用 go-ethereum 的 trie 包构建一棵树的过程逻辑和交易树、收据树构建完全一致package main import ( fmt github.com/ethereum/go-ethereum/common github.com/ethereum/go-ethereum/core/rawdb github.com/ethereum/go-ethereum/rlp github.com/ethereum/go-ethereum/trie ) func buildTrie(keys, values [][]byte) common.Hash { t, _ : trie.New(common.Hash{}, trie.NewDatabase(rawdb.NewMemoryDatabase())) for i : 0; i len(keys); i { _ t.Update(keys[i], values[i]) } return t.Hash() } func main() { keys : make([][]byte, 0) values : make([][]byte, 0) for i : 0; i 3; i { // key 是 rlp(交易序号)交易树/收据树都这么干 k, _ : rlp.EncodeToBytes(uint64(i)) // 这里用任意字节模拟一笔交易或一个收据的 RLP 值 v, _ : rlp.EncodeToBytes([]byte{byte(i), byte(i 1)}) keys append(keys, k) values append(values, v) } root : buildTrie(keys, values) fmt.Println(common.Bytes2Hex(root[:])) }实际项目中你不会手动调trie.Update而是直接用types.DeriveSha(txs, hasher)计算交易树根用types.DeriveSha(receipts, hasher)计算收据树根。里面帮你处理了 RLP key 的生成、树节点批量写入和哈希计算结果和我上面的手动示例等价。5.2 观察两个容易被忽略的事实跑几次上面这段代码你会发现几个有意思的现象。第一调换 key 顺序根哈希就变了。树是顺序敏感的。对应到真实场景同一组交易只要矿工打包顺序不同TxHash和ReceiptHash就不同。这本身没什么共识机制正是靠这个顺序敏感来保证区块唯一性。第二日志内容会影响收据树根。假设你把某个 Receipt 里Logs的Data字段改成别的字节哪怕只改一位重新构建收据树后根就和原区块对不上。这个性质保证了链上日志不可被第三方索引服务随意篡改。第三rlp(0) 是0x80而不是0x00。这意味着序号 0 的 key 在 MPT 里代表的是一个“空字符串长度”的编码路径和其余 key 开头不同。实际验证时如果自己写代码用了简单的uint64ToBytes(i)这种大端编码当 key就永远算不出和以太坊一致的交易树根。5.3 和链上实际 Root 对一次根如果你手头有以太坊节点或者能用公开 RPC可以做一个简单的验证随便挑一个高度用eth_getBlockByNumber(blockNumber, false)拿到transactionsRoot和receiptsRoot再把区块里的交易列表、收据列表导出来本地用DeriveSha重建一遍比对结果。这个验证动作我强烈建议做一次一旦跑通你对“Merkle 承诺到底承诺了什么”的理解会比读十篇文章都深刻。6. 实际项目里的几个坑和解法6.1 拿交易哈希当树 key根永远对不上这是最常见的误区。交易树和收据树里的 key 是rlp(序号)不是tx.Hash。我从没见过哪个区块能通过“交易哈希当 key”重建出和 Header 一致的根。如果你正在写自定义索引或审计工具记住这条树内定位靠的是区块内序号transactionIndex不是交易哈希。交易哈希是全局身份树内位置是局部身份。6.2 收据的 RLP 编码隐藏了“日志元数据”的边界Receipt 在 RPC 返回时带有一堆衍生字段BlockHash、BlockNumber、TransactionHash、TransactionIndex、LogIndex 等。这些字段方便了使用方但不参与收据树的 RLP 哈希计算。在 go-ethereum 的实现里receipt 存储层的 RLP 编码只包含 Status、CumulativeGasUsed、Bloom、Logs而 Logs 的编码又进一步收敛为 Address、Topics、Data 三个字段。换句话说日志的 BlockNumber、TxIndex、LogIndex 这些“上下文元数据”是链外派生并附加到 Log 对象上的用于查询展示不是 Merkle 承诺的一部分。理解这一点你在把 Receipt 序列化去算根哈希时才不会多塞字段导致根对不上。6.3 注意 Receipt 的编码态和 RPC 展示态不一致有段时间调试一条历史交易我发现本地从 LevelDB 读出来的 Receipt 和 RPC 返回的 Receipt字段数量明显不同。原因在于 go-ethereum 在Process拿到初版 Receipt 后会调用DeriveFields把 BlockHash、TxHash、BlockNumber、TransactionIndex 回填进去同时给每个 Log 补上 BlockNumber、TxHash、TxIndex、Index 这些信息。这一步主要服务日志索引和 RPC 展示不会改变用于哈希计算的存储编码。但这带来一个实际的坑如果你拿“已经填充完整衍生字段”的 Receipt 对象直接做rlp.Encode和链上计算根时用的编码可能不一致。正确做法是走types.Receipts自带的EncodeIndex或者手动构造存储层结构只保留 Status、CumulativeGasUsed、Bloom、Logs 这几个核心字段。很多自研 ETL 工具在这个地方翻过车。6.4 老区块的收据可能不是始终可查的全节点为了控制磁盘占用可能会裁剪旧区块的收据数据。用eth_getTransactionReceipt查询很早以前的交易时有时返回空有时能查到取决于节点配置和同步方式比如 fast sync 是否丢弃了 receipts。这是存储策略问题不是树本身的问题。对于需要全量历史收据的索引服务建议从归档节点拉数据或在同步时保留 receipt 数据并在自己数据库里建立(blockNumber, transactionIndex) - receipt的索引而不是每次依赖远程节点的eth_getTransactionReceipt。6.5 轻节点验证的取舍轻节点可以通过收据树根验证日志或执行结果但它无法仅凭一棵树判断某笔交易“是否已确认”。确认性来自共识层一个区块被足够多的后续区块压过、被足够多的验证者承认它里面的树根才被赋予了真实权重。树只负责“数据确实长这样”的证明“数据在链上算不算数”是共识要回答的问题。做跨链或链下验证方案时一定要把这个层次分清。最后分享一点我的习惯我在本地调试自定义索引器时经常会用“对根”的方式自查数据每个批次处理完把交易列表和收据列表重新DeriveSha一遍和对应区块头的TxHash、ReceiptHash比对一旦不一致就立刻停下查数据 pipeline而不是等跑到用户界面才发现问题。这个方法成本很低但能拦住绝大多数编码、排序、字段截断带来的隐性 bug。交易树和收据树看起来只是两个哈希字段它们背后却是一整套“可证明、可验证、可审计”的设计哲学。搞懂它们以太坊区块在你眼里就不再是一坨编码数据而是一棵可以随手验证的默克尔森林。