news 2026/10/23 19:45:45

深入理解以太坊交易树与收据树:从MPT结构到实战验证

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解以太坊交易树与收据树:从MPT结构到实战验证

搞懂以太坊的交易树和收据树,你就看懂了半个区块

老实说,在ETH的各类数据结构里,交易树(Transaction Trie)和收据树(Receipt Trie)是最容易被一笔带过、又最值得花时间吃透的东西。你日常查转账记录、解析DEX的swap日志、甚至写一个轻节点做交易验证,都绕不开这两棵树。很多时候不是我唬人,很多同学对block.blockhash、状态根讲得头头是道,但一问你“交易树和收据树到底存了什么、根哈希怎么来的、为什么以太坊要搞两棵树”,当场卡壳。这一篇我会用做项目的视角,把交易树和收据树的底层逻辑、节点结构、实际读取方法、以及我踩过的坑一次性说清楚。

这个题目适合谁看?三类人:第一类是在用ethers.js/web3.js写数据服务、却只停留在getBlock和getTransaction的开发者;第二类是想自己实现交易存在性证明、跨链中继逻辑、或者做事件索引的工具党;第三类是纯粹想把以太坊区块结构搞明白、不想再被“默克尔树”泛泛概念糊弄过去的学习者。我会先从“为什么是两棵树”切入,再深入到MPT的具体结构,最后给一段可以直接跑起来的读取和验证思路。

1. 先搞清楚一件事:为什么以太坊需要“两棵树”

1.1 从比特币的一棵树说起

比特币区块里只有一棵Merkle树,树的叶子就是交易。轻节点拿到一个区块头,再拿到一条从目标交易到根节点的哈希路径,就能证明“这笔交易确实被打包在这个区块里”。这个设计在只转钱的系统里没问题——你关心的是钱有没有转出去、转给了谁,至于交易执行产生了什么状态变化,比特币根本不关心,因为它没有复杂的智能合约状态。

但以太坊不行。以太坊里的交易不光会改变账户余额,还会创建合约、调用合约、触发事件、燃烧Gas、中途回滚。一个区块执行完以后,你不仅要证明“有哪些交易被包含”,还得证明“这些交易执行的结果是什么”。比如一个swap交易到底成交了多少Token,光看交易本身看不出任何东西,你必须去看交易日志和收据。所以单棵Merkle树装不下这个信息量。

1.2 状态与记录:区块链为什么必须分开记账

以太坊有三个底层数据结构:状态树(State Trie)、交易树(Transactions Trie)、收据树(Receipts Trie)。很容易把状态树和收据树搞混,我打个比方你就明白了。

状态树是这个区块链“执行完所有交易之后”的账户快照,它回答的问题是:现在谁有多少钱、哪个合约存了什么变量。交易树回答的问题是:这个区块里到底打包了哪些原始交易、顺序是什么。收据树回答的问题是:每笔交易执行完以后产生了什么结果、烧了多少Gas、发出了哪些日志。

如果你只存状态树,你能知道结果,但不知道结果是怎么来的,也无法回答“某笔交易有没有被打包”。如果你只存交易树,你能看到交易内容,但不知道交易执行后发生了什么。只有交易树加收据树一起,才能让一个没完整执行交易的节点,通过区块头里的两个根哈希,同时校验“交易存在”和“交易执行结果”。所以这两棵树本质上不是冗余,而是从两个角度锁定了一个区块的完整历史。

1.3 ETH区块里至少有三棵树

打开一个以太坊区块头,你会看到stateRoot、transactionsRoot、receiptsRoot三个字段。初学者最容易犯的错就是把它们当成三个独立打包好的数组,其实它们都是MPT的根哈希。节点在执行/验证区块时,会重新构造这三棵树,比对哈希是否一致。

一个值得注意的细节是:交易树和收据树的键(key)基本上是同一个东西,都是交易在区块里的索引编号(RLP编码后的索引),区别只在于叶子节点存的值完全不同。所以,你甚至可以理解成“同一组键、两棵独立的树”,它们的结构镜像,但哈希路径完全各走各的。这一点在后面手动验证时会变得非常重要。

2. 交易树:记录“发生”的历史

2.1 交易树长什么样:Merkle Patricia Trie

交易树不是普通二叉Merkle树,而是MPT(Merkle Patricia Trie)。简单说,它把交易的索引当作键,把交易的完整数据当作值,然后对这个“键-值”集合进行特殊编码和哈希。为什么要用Patricia而不是简单二叉树?因为以太坊的key和value都是RLP编码后的字节,很多key有共同前缀,比如索引1(0x01)和索引1f(0x1f)前半截都是0x1。用现代前缀树结构,可以压缩存储,减少哈希层级。

MPT节点一共有四种:空节点、叶子节点、扩展节点、分支节点。交易树在区块交易数量不多的时候,往往是若干叶子节点挂在一个或两个扩展节点/分支节点下面。每上升到一层,父节点就是子节点的RLP哈希拼接后的哈希。这个递归一直到根。这个根就是transactionsRoot。

要理解这个结构,我建议你不要去背四种节点的定义,而是想象你在图书馆用标签归档书籍:先按第一个字符分架,再按第二个字符分架,最后精确到一个书架格子里放一本书。MPT就是这种逐字符分叉的归档系统,只不过每个分叉节点都多了一个“孩子列表的哈希值”。整个树最后只往外暴露一个根哈希。从根开始,拿着只有一半路径信息就能往下走,但如果你想篡改某一笔交易,必须把从叶子到根的每一层哈希全部重算,这就保证了交易历史一旦上链就无法被单点篡改。

2.2 索引不是数组:交易在树里的位置

很多人在读取区块后,习惯把block.transactions当成普通列表遍历,这没错。但在树结构里,交易的位置是index,不是数组下标那么简单。交易树的键是区块内交易的排序编号,从0开始,通过RLP编码后作为MPT的路径。你如果去Geth的实现里翻代码,会发现它对每个索引执行rlp.EncodeToBytes(index)作为key,把交易本身的RLP编码作为value。

这个排序和真正的执行顺序严格一致,因为协议规定了交易必须按区块里的顺序逐条执行。收据树里每一笔收据也按同一顺序排列,这样交易树和收据树在“同一下标”上就能对应起来。我见过有的新手误以为交易索引是交易哈希的顺序,那完全是另一回事。交易哈希是整笔交易的keccak哈希,和排序无关。

2.3 怎么证明一笔交易真的被打包了

交易树存在的直接价值就是提供“默克尔证明”。轻节点下一笔交易哈希,想确认它归属某个区块,就向全节点要一条Merkle路径:从交易叶子开始,每次告诉当前节点该选哪个分支、兄弟哈希是什么,一直拼到根。根和区块头里的transactionsRoot一致,说明交易确实在这个区块里。

但在以太坊里,“交易被打包”不等于“交易一定生效”。交易可能执行失败并回滚状态,但交易本身仍在链上,也会进入交易树。所以,只验证交易树只能确定内容存在,要确认执行结果是否成功、产生了什么事件,必须看收据树。这就是为什么一个完整的轻客户端在验证一次转账时,往往需要同时拿交易Merkle证明和收据Merkle证明。

3. 收据树:记录“结果”的证据

3.1 收据里到底有什么

收据树是很多人相对陌生的部分,打开一个收据对象,你至少会看到这些核心字段:

字段含义
status交易执行结果,1成功,0失败
cumulativeGasUsed到此为止区块累计消耗的Gas
logsBloom本收据日志生成的Bloom过滤器
logs交易触发的日志事件
contractAddress如果是创建合约的交易,这里是合约地址
effectiveGasPrice交易实际支付的Gas价格
type交易类型,0是legacy,2是EIP-1559
transactionHash / transactionIndex关联的交易

收据树里的value,不是存JSON,而是把这些字段按协议顺序进行RLP编码后序列化。叶子节点放的就是这段序列化数据。哈希时,你要把RLP后的收据整体作为一个值参与MPT构造。

这里有个细节容易出错:status区分成功和失败,但EIP-658之前是用root字段表示状态根,后来改成status。如果你在翻老区块或者老代码,会看到完全不同的收据结构。我处理过一笔2017年的旧交易,当时数据服务按新结构解析收据,status字段怎么都读不出来,最后发现是历史区块用的旧格式。别以为链上数据都长得一样,协议升级会让历史数据结构“看起来像一个样,解析却千差万别”。

3.2 日志和Bloom Filter:收据树的隐藏入口

收据树里最让人头疼也最常被调用的部分是日志(log)。一个日志包含三个核心元素:合约地址、topics数组、data。topics通常是事件签名的keccak哈希以及indexed参数,data则是非indexed参数打包后的字节。

为什么会和Bloom Filter有关?因为节点不可能为了找某个合约的Transfer事件,遍历区块里所有收据并逐条对比data。它会先看这个区块头自带的logsBloom,这是一个经过所有日志地址和topic叠加生成的2048位Bloom过滤器。查询某类事件时,先把目标地址和事件topic哈希换算成几个bit位,如果区块级logsBloom里对应位不是全部为1,那这个区块肯定没有匹配日志,就不用再进收据树了。如果全为1,说明“可能存在”,再拉收据树的日志逐条精确匹配。

实际操作中,我经常看到有人直接用provider.getLogs带一个大范围区块区间查询,结果节点把几百个区块的Bloom都加载了一遍,查询极慢。更合理的做法是先用节点自带的过滤或自己索引好的Bloom位判断,缩小范围。

3.3 收据树根哈希怎么计算

收据树根(receiptsRoot)是收据树构造完成后的根。和交易树一样,键也是交易索引,值则是收据RLP序列化。你只需要和区块头中的根做比对,就能验证一个区块里所有收据有没有被篡改过。

我强调一个容易忽略的事实:区块头里的receiptsRoot与交易树根是用同一个“键体系”,但每个键对应的叶子值不同。所以即使两棵树的流淌路径看起来一样,哈希路径也不可能一样,因为它们底层序列化的内容不同。

验证收据树的方法和验证交易树类似,但为了证明“某笔交易执行状态成功且产生了某条日志”,你需要拿到从收据叶子到根的Merkle路径,再配合Bloom过滤器。在跨链桥和轻客户端的设计里,这种证明是资产中继的核心依赖。

4. 实操:用ethers.js把交易树和收据树读出来

4.1 准备环境和样例区块

我不会让你真的从头实现MPT,那是几周的活。更实用的路径是:用节点API读出交易和收据,再用开源库验证根哈希。先准备一个能连接网络的主网或测试网节点,本地跑个Geth或者用公共RPC都行。

在Node环境里装依赖:

npm install ethers @ethereumjs/trie rlp @ethereumjs/util

然后连接节点:

const { ethers } = require("ethers"); const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"); const blockNumber = 19102923; const block = await provider.getBlock(blockNumber, true); console.log("format:", block.format); console.log("transactionsRoot:", block.transactionsRoot); console.log("receiptsRoot:", block.receiptsRoot);

如果你拿公共RPC,注意限流规则,本地Geth更稳。

4.2 读取区块里的交易

getBlockWithTransactions会返回区块内所有完整交易对象。交易对象里有一个字段叫position,这就是交易在区块内的索引,也是交易树的key。

const txs = block.prefetchedTransactions; for (const tx of txs) { console.log(tx.index, tx.transactionHash, tx.to, tx.value); }

从这里你可以遍历全部交易,但要知道,直接拿交易对象是读不出“结果”的。必须走收据。

4.3 读取收据并解析日志

拿到交易哈希后调用getTransactionReceipt:

const receipt = await provider.getTransactionReceipt(tx.hash); console.log("status:", receipt.status); console.log("gasUsed:", receipt.gasUsed.toString()); for (const log of receipt.logs) { console.log("address:", log.address); console.log("topics:", log.topics); console.log("data:", log.data); }

如果你在做一个事件索引工具,最常碰到的场景是批量读取收据:

const blockReceipts = await provider.send("eth_getBlockReceipts", [blockNumber]); for (const raw of blockReceipts) { const r = ethers.parseReceipt? // 取决于你用的版本 }

但注意,eth_getBlockReceipts并不是所有RPC都支持。公共RPC经常禁用这个JSON-RPC方法,这时候你得退回到逐笔eth_getTransactionReceipt,速度会慢很多。我自己的经验是:本地archive node用eth_getBlockReceipts,线上限流环境就改用并发的getTransactionReceipt池,一次拉8到16个请求。

4.4 手动验证交易根和收据根(简化思路)

我不建议你先去啃黄皮书完整推导,而是用一个现成的trie库把交易和收据重新构造成树,然后对比根。核心思路是这样的:

  1. 把每笔交易用协议规则RLP编码;
  2. 把每个交易索引RLP编码后作为key;
  3. 把编码后的交易/收据作为value塞进@ethereumjs/trie;
  4. 算出根哈希,对比区块头里的transactionsRoot/receiptsRoot。

代码骨架如下:

const { MerklePatriciaTrie } = require("@ethereumjs/trie"); const { RLP } = require("@ethereumjs/rlp"); async function buildTrie(entries) { const trie = new MerklePatriciaTrie(); for (const [key, value] of entries) { await trie.put(RLP.encode(key), value); } return trie.root(); } // 交易树的key和value都需要自己按序列化规则构造 // 这一步最麻烦的是交易必须按黄皮书规则做RLP,建议直接复用ethers库的内部编码

实际项目里,如果你只是想“验证根一致”,直接用@ethereumjs/tx构造交易对象,再用serialize()拿原始字节,比手写RLP靠谱得多。真实的核心难点在于:不同交易类型的RLP格式不同,普通交易和类型化交易的前缀不一样,网上大量教程只会处理legacy交易,遇到EIP-1559就会对不上根。

5. 实战中踩过的坑与排查技巧

5.1 为什么查询不到某些交易

最典型的场景:你拿到一个交易哈希,getTransaction能查到,但getTransactionReceipt返回null。原因有两个:交易在待定池(pending)里还没被打包,或者节点是一个不做收据索引的轻客户端。

解决方案是先去getBlock里看它到底在哪个区块,确认后再查收据。如果一个区块包含几百笔交易,节点只保留区块头而剪枝掉收据树,那么老区块的收据会查不到甚至整棵收据树都不完整。这就是为什么全节点的“全”是有代价的,archive node和pruned node在收据树上的行为完全不同。

5.2 日志丢失或indexed字段错位

你在解析Transfer(address,address,uint256)时,如果不先把事件签名哈希算出来,再对比topics[0],很容易被假事件的同名函数带偏。然后,事件的indexed字段包含在topics里,非indexed字段拼接在data里,但顺序不能乱。

例如ERC-20 Transfer的三个参数,前两个是indexed,最后一个数量是非indexed,所以topics长度是3,data里是数量。如果你把data按“地址+地址+数量”解,第三个地址就会明显错位。

5.3 收据树和交易树互相验证有什么用

跨链和轻节点场景里,你会收到“交易证明”和“收据证明”两个对象。交易证明只能证明交易被打包,收据证明用来证明执行后的事件和状态。两者结合起来,才能完成“一笔跨链操作确实在目标链生效”的完整闭环。

我在做侧链事件监听时遇到过一种情况:目标链的节点返回了交易证明,但收据证明拿不到。原因是跨链使用的API返回的是普通JSON,而不是原始MPT证明。后来我用RPC方法eth_getTransactionReceipt加eth_getProof手动取到收据树叶子路径,再用earl或者自写的MPT验证器做了校验,才算打通。记住一点:很多公有链API并不会默认给你证明数据,你得让节点服务商开特殊RPC,或者自己连全节点。

5.4 使用archive node和full node的差异

全节点(full node)会保留最近128个区块的状态快照,更早的部分会被修剪。但交易树和收据树的保留策略取决于节点配置,一般full node会保留所有收据用于日志查询,但archive node才能保证任意历史状态下都能获取完整证明数据。

如果你的服务需要频繁重放历史日志和构建收据树,建议直接上archive node。我之前在full node上跑历史事件索引,到第3000万区块时部分收据树节点已经被剪掉,日志查询开始随机失败。切到archive node之后,一切恢复。这个差异很实在,不是理论问题。

最后再分享一个我自己的体会:交易树和收据树真的是以太坊区块里最“骨盆小、功能大”的设计。第一次手动把树根重新算出来并和区块头值对上号的那一刻,你对区块链数据结构的信任感会不一样。你不再觉得区块是一个神秘的黑盒子,而是一个可验证、可重建、可裁剪的数据结构。如果你以后要写链上索引器、跨链桥、轻节点钱包,强烈建议把这一步做实,用测试网多刷几轮,把legacy交易和EIP-1559交易各验证一遍。踩过这个坑,后面处理任意区块都不会慌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 6:11:14

WebSocket流式聊天:从零接入DeepSeek大模型的完整指南

简介:在AI应用开发中,实时交互逐渐成为标配。如何将大模型生成的内容以流式方式推送到前端,直接影响用户体验的流畅度。基于HTTP轮询或一次性返回的常规方案,在长文本生成场景下往往延迟偏高、交互生硬,而WebSocket凭借…

作者头像 李华
网站建设 2026/10/21 4:47:22

VS Code与AI编程工具的关系:用TaoToken统一Key打通插件与智能体协作

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 13:40:14

玉米叶片缺陷检测数据集:2225张YOLOv5训练资源与实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 15:31:22

Windows cmd切换盘符:cd /d 用法详解与常见坑

先别急着骂 cmd。你在 Windows 命令提示符里敲cd D:\projects,回车之后光标纹丝不动,还稳稳停在C:\Users\你那个目录上。继续敲命令,还是 C 盘。这事我见过太多人卡住了,包括我自己刚接触命令行时也愣了半天,以为输入法…

作者头像 李华
网站建设 2026/10/22 7:00:26

Flutter跑通OpenHarmony:盲盒抽奖App首页实战全解析

各位做跨端开发的朋友,如果你正在关注OpenHarmony,又被“要不要单独学一套ArkTS”困扰,那这篇实战总结应该对你有用。我最近把一个盲盒抽奖App的首页完整跑在了开源鸿蒙设备上,用的还是Flutter这套老本行技术栈,整体体…

作者头像 李华