ENGINEERING JOURNAL

Kylinxin's Blog

ETH 交易树和收据树学习笔记

1. 交易树和收据树在区块中的位置

ETH 交易树和收据树示意图 1
ETH 交易树和收据树示意图 2
以太坊每个区块可以分为区块头和区块体。区块头中保存若干个根哈希,其中和本节内容相关的主要有:

1
2
3
stateRoot
transactionsRoot
receiptsRoot

它们分别对应:

1
2
3
stateRoot        → 状态树根,表示当前区块执行完成后的全局状态
transactionsRoot → 交易树根,表示当前区块包含的所有交易
receiptsRoot → 收据树根,表示当前区块中所有交易执行后的结果

可以这样理解:

1
2
3
4
5
6
7
8
9
10
11
Block Header
├── stateRoot
├── transactionsRoot
└── receiptsRoot

Block Body
└── Transactions
├── Tx0
├── Tx1
├── Tx2
└── ...

每个区块中有一组交易,交易被执行后会产生对应的收据。因此:

1
一笔交易 → 一个收据

如果当前区块中有三笔交易:

1
2
3
Tx0
Tx1
Tx2

那么执行后也会有三个收据:

1
2
3
Receipt0
Receipt1
Receipt2

交易树和收据树之间是一一对应关系:

1
2
3
Tx0 → Receipt0
Tx1 → Receipt1
Tx2 → Receipt2

2. 交易树 Transaction Trie

交易树用于组织当前区块中的所有交易。它对应区块头中的:

1
transactionsRoot

交易树的作用是:

用一个根哈希承诺当前区块中包含的所有交易。

以太坊交易树的 key 不是交易哈希,而是交易在当前区块中的序号,也就是 transaction index。

逻辑上可以理解为:

1
2
key   = RLP(transactionIndex)
value = RLP(transaction)

例如某个区块中有三笔交易:

1
2
3
0 → Tx0
1 → Tx1
2 → Tx2

构建交易树后,区块头中保存:

1
transactionsRoot = root(Transaction Trie)

这样一来,只要区块中的任意一笔交易发生变化,或者交易顺序发生变化,最终得到的 transactionsRoot 都会改变。

这保证了两点:

1
2
1. 区块中的交易内容不能被篡改
2. 区块中的交易顺序不能被随意改变

交易顺序很重要,因为以太坊是状态机模型。交易执行顺序不同,可能导致最终状态不同。例如:

1
2
Tx0:Alice 给 Bob 转 5 ETH
Tx1:Alice 调用某个合约

如果交换顺序,Alice 的余额、nonce、合约执行结果都可能不同。因此,交易树不仅承诺交易内容,也间接承诺了当前区块的交易执行顺序。


3. 收据树 Receipt Trie

收据树用于组织当前区块中所有交易执行后的结果。它对应区块头中的:

1
receiptsRoot

一笔交易执行完成后,会产生一个收据 Receipt。收据中通常包含:

1
2
3
4
交易执行状态
累计 gas 使用量
日志 Bloom Filter
事件日志 logs

简化结构如下:

1
2
3
4
5
Receipt
├── status
├── cumulativeGasUsed
├── logsBloom
└── logs

其中:

1
2
3
4
status             → 交易是否成功,通常 1 表示成功,0 表示失败
cumulativeGasUsed → 当前区块中执行到这笔交易为止累计使用的 gas
logsBloom → 当前交易日志的布隆过滤器
logs → 交易执行过程中产生的事件日志

收据树的 key 和交易树类似,也是交易在区块中的序号:

1
2
key   = RLP(transactionIndex)
value = RLP(receipt)

例如:

1
2
3
0 → Receipt0
1 → Receipt1
2 → Receipt2

对应:

1
receiptsRoot = root(Receipt Trie)

收据树的作用是:

用一个根哈希承诺当前区块中所有交易的执行结果。

如果有人修改某笔交易的执行状态、gas 使用量或事件日志,那么对应收据会变化,收据树根 receiptsRoot 也会变化,从而无法匹配区块头。


4. 交易树和收据树为什么也使用 MPT

以太坊中的状态树、交易树、收据树都可以用 MPT,即 Merkle Patricia Trie 来组织。

不过它们的使用场景不完全相同。

状态树是全局的、长期存在的、动态更新的结构。它维护的是:

1
address → account state

状态树需要支持大规模账户的查找、更新、证明、回滚等操作,因此使用 MPT 非常必要。

交易树和收据树则不同。它们都是针对单个区块构建的临时性数据结构:

1
2
Transaction Trie:只组织当前区块中的交易
Receipt Trie:只组织当前区块中的收据

由于每个区块中的交易数量有限,而且 key 是连续的交易序号,所以从理论上说,交易树和收据树并不一定必须使用 MPT。它们也可以使用普通 Merkle Tree 来完成承诺和证明功能。

例如对于交易树来说:

1
Tx0, Tx1, Tx2, Tx3

直接构建普通 Merkle Tree 也可以得到一个交易根,用于证明某笔交易属于当前区块。

但是以太坊统一使用 Trie/MPT 风格的数据结构,有利于工程实现和项目管理。这样状态、交易、收据都可以使用类似的 trie 数据结构、编码方式和根哈希计算方式。

因此可以总结为:

1
2
3
状态树使用 MPT:非常必要,因为它是全局动态键值状态
交易树使用 MPT:工程统一,理论上也可以用普通 Merkle Tree
收据树使用 MPT:工程统一,理论上也可以用普通 Merkle Tree

5. 交易树、收据树与状态树的区别

这三棵树虽然都和区块头中的根哈希有关,但含义不同。

对应根 范围 key value 是否跨区块持续更新
状态树 stateRoot 全局账户状态 keccak256(address) RLP(account state)
交易树 transactionsRoot 当前区块交易 RLP(txIndex) RLP(transaction)
收据树 receiptsRoot 当前区块收据 RLP(txIndex) RLP(receipt)

状态树反映的是:

1
当前整个以太坊执行后的世界状态

交易树反映的是:

1
当前区块里有哪些交易,以及交易顺序是什么

收据树反映的是:

1
当前区块里这些交易执行后产生了什么结果

交易树和收据树只属于当前区块,不是跨区块不断更新的全局结构。状态树则不同,它表示区块执行完成后的全局状态版本。


6. 收据中的日志与事件

智能合约执行过程中可以产生事件日志。例如 ERC-20 转账时通常会产生:

1
event Transfer(address indexed from, address indexed to, uint256 value);

当用户进行一次 USDT 转账后,合约可能会产生一条 Transfer 日志。日志一般包括:

1
2
3
Address:产生日志的合约地址
Topics:事件主题,通常包括事件签名和 indexed 参数
Data:非 indexed 参数数据

可以理解为:

1
2
3
4
5
6
7
Log
├── address = USDT 合约地址
├── topics
│ ├── Transfer 事件签名哈希
│ ├── from 地址
│ └── to 地址
└── data = value

这些日志会被放入对应交易的 Receipt 中。也就是说,用户在区块浏览器里看到的 ERC-20 转账记录,很多时候就是通过交易收据中的 logs 解析出来的。

因此,收据树不仅证明交易执行是否成功,还能证明某些事件日志确实由某笔交易产生,并被记录在某个区块中。


7. Bloom Filter 布隆过滤器

以太坊引入 Bloom Filter,主要是为了提高日志查询效率。

如果没有 Bloom Filter,当用户想查询某个合约地址或某个事件时,节点可能需要遍历大量区块和交易收据,逐个检查 logs。这样效率很低。

Bloom Filter 是一种空间效率很高的概率型数据结构,用于快速判断:

1
某个元素是否可能存在于集合中

它的特点是:

1
2
如果判断为不存在,则一定不存在
如果判断为存在,则可能存在

也就是说,Bloom Filter 可能出现 false positive,即假阳性。

例如查询某个事件是否出现在某个区块中:

1
2
Bloom Filter 返回不存在 → 该事件一定不在这个区块
Bloom Filter 返回可能存在 → 需要进一步检查具体 logs

假阳性表示:

1
Bloom Filter 说可能存在,但实际检查 logs 后发现不存在

但是 Bloom Filter 不会出现 false negative。也就是说,如果某个元素真的存在,Bloom Filter 不会说它不存在。

以太坊中 Bloom Filter 的意义是:

快速过滤掉大量明显不相关的区块或收据,只对可能相关的数据进行进一步检查。

这可以显著提高事件日志查询速度。


8. Bloom Filter 为什么一般不支持删除

普通 Bloom Filter 的底层通常是一个 bit array。插入元素时,会通过多个哈希函数计算出多个位置,并把这些位置置为 1。

例如插入元素 A:

1
2
3
hash1(A) → 位置 3
hash2(A) → 位置 8
hash3(A) → 位置 15

于是:

1
2
3
bit[3] = 1
bit[8] = 1
bit[15] = 1

但是多个元素可能会共享某些 bit 位。

例如元素 B 也可能设置:

1
2
3
bit[8] = 1
bit[20] = 1
bit[31] = 1

如果现在要删除 A,不能直接把 bit[8] 置为 0,因为这个位置可能也是 B 需要的。如果直接清零,就会导致 B 被误判为不存在,从而出现 false negative。

所以普通 Bloom Filter 不支持安全删除。

如果需要支持删除,可以使用 Counting Bloom Filter。它不是用 bit,而是用计数器:

1
2
插入元素 → 对应计数器 +1
删除元素 → 对应计数器 -1

但这样会增加存储成本和实现复杂度。


9. 以太坊是交易驱动的状态机

以太坊可以看作一个交易驱动的状态机。

它的基本模型是:

1
旧状态 + 交易 → 新状态

更具体地说:

1
State_N + Block_N 中的交易序列 → State_N+1

每个区块中的交易按照确定顺序执行。执行完成后,得到新的全局状态树根:

1
stateRoot

可以表示为:

1
S_{t+1} = Apply(Tx_1, Tx_2, ..., Tx_n, S_t)

这意味着,只要所有节点从相同的前置状态开始,并按照相同顺序执行相同交易,就必须得到完全相同的新状态和相同的 stateRoot

这要求状态转移必须是确定性的。


10. 什么叫状态转移确定性

状态转移确定性是区块链共识的基本要求。

所谓确定性,就是:

所有节点执行同一笔交易,必须得到同样结果。

如果交易执行依赖不确定因素,就会导致不同节点计算结果不同,进而无法达成共识。

例如,智能合约不能直接调用普通互联网 API:

1
2
3
获取当前天气
读取某个中心化网站价格
调用链下随机数

因为不同节点在不同时间、不同网络环境下可能得到不同结果。

以太坊中合约能访问的是链上确定性数据,例如:

1
2
3
4
5
6
7
账户余额
合约存储
交易参数
区块高度
区块时间戳
msg.sender
msg.value

即使是区块时间戳,也必须由区块生产者写入并被其他节点验证,而不是每个节点自己取本地系统时间。

因此,以太坊状态机的核心原则是:

1
2
3
4
相同输入
相同前置状态
相同执行规则
必然得到相同输出状态

11. BTC 也可以看作状态机吗

可以。

虽然比特币通常被描述为 UTXO 模型,但它也可以被抽象为状态机。

比特币的状态可以理解为:

1
当前所有未花费交易输出集合,即 UTXO Set

每一笔交易都会消耗一些旧 UTXO,并生成一些新 UTXO。

例如:

1
2
3
4
5
6
7
8
9
10
旧状态:
UTXO_A = 1 BTC

交易:
花费 UTXO_A,输出 0.6 BTC 给 Bob,0.399 BTC 找零给 Alice,0.001 BTC 手续费

新状态:
删除 UTXO_A
新增 UTXO_Bob = 0.6 BTC
新增 UTXO_Alice_change = 0.399 BTC

所以 BTC 也可以表示为:

1
UTXO Set_old + Transaction → UTXO Set_new

只不过比特币的状态主要是 UTXO 集合,而以太坊的状态更复杂,包括账户余额、nonce、合约代码和合约存储。

因此:

1
2
BTC 是 UTXO 状态机
ETH 是账户与合约状态机

12. 问题:每个区块只维护当前区块用到的交易账户状态是否可行

这个想法可以描述为:

不维护一个全局状态树,而是在每个区块中只保存当前区块交易涉及到的账户状态。

例如某个区块中只涉及 Alice、Bob 和 USDT 合约,那么这个区块只保存:

1
2
3
Alice state
Bob state
USDT Contract state

表面看这样可以节省空间,因为每个区块不用保存完整状态树。但是这个方案在以太坊中不可行,或者至少非常低效。

主要问题是:查找账户当前状态会变得非常困难。


13. 为什么只保存局部状态会导致查询困难

假设你想查询 Alice 当前余额。如果每个区块只保存当前区块用到的账户状态,那么你需要从最新区块开始向前找:

1
2
3
4
Block 1000:是否包含 Alice 状态?
Block 999:是否包含 Alice 状态?
Block 998:是否包含 Alice 状态?
...

如果 Alice 很久没有交易,就可能要追溯很多区块,甚至一直追溯到创世区块。

这种查询成本非常高。

而以太坊的状态树设计是:

1
每个区块的 stateRoot 都承诺该区块执行后的完整全局状态

因此,只要拿到某个区块对应的状态树,就可以直接查询任意账户在该区块后的状态。

这就是全局状态树的意义。


14. 不存在账户的问题

你笔记中提到:

如果转账账户是一个不存在的区块就需要一直追溯到 Genesis 创世区块。

这里可以进一步解释。

如果每个区块只保存局部状态,当我们想判断某个账户是否存在时,就不能只看当前区块。因为当前区块没有出现该账户,可能有两种情况:

1
2
1. 这个账户真的不存在
2. 这个账户存在,但当前区块没有用到它

为了区分这两种情况,节点必须一直向前追溯,寻找这个账户最后一次出现的位置。如果一直追溯到创世区块都没有出现,才能确认它不存在。

这显然非常低效。

而全局状态树支持不存在性证明。通过 State Trie 的路径,可以证明某个地址在当前状态树中没有对应账户,不需要从当前区块一路追溯到创世区块。


15. 为什么不能只保存“交易涉及账户的状态差分”

另一种想法是:每个区块只保存状态差分,也就是只保存变化的账户状态。

例如:

1
2
3
4
Block N
├── Alice.balance: 5 ETH → 4 ETH
├── Bob.balance: 2 ETH → 3 ETH
└── Alice.nonce: 10 → 11

这种方式可以用于辅助回滚或历史追踪,但不能替代全局状态树。

原因是状态差分只能告诉你当前区块改了什么,却不能直接告诉你某个账户的当前完整状态。如果要查询一个很久没有变化的账户,仍然需要向前追溯它最后一次变化的位置。

而以太坊需要节点能够高效验证交易。例如,Alice 发起一笔交易时,节点必须快速检查:

1
2
3
Alice 是否存在
Alice 的 nonce 是否正确
Alice 的余额是否足够支付 value + gas

如果没有全局状态树,就无法高效完成这些验证。


16. 为什么以太坊状态树必须是全局的

以太坊状态树的设计目标是:

1
任意时刻都能根据 stateRoot 表示完整世界状态

这有几个重要作用:

1
2
3
4
5
6
1. 快速验证新交易
2. 快速查询任意账户状态
3. 支持 Merkle Proof
4. 支持账户存在性和不存在性证明
5. 支持临时分叉下的状态回滚
6. 保证所有节点执行后得到一致的 stateRoot

如果只保存局部状态,这些能力都会受到影响。

因此,每个区块中的 stateRoot 不是只承诺当前区块涉及的账户,而是承诺当前区块执行完成后的完整全局状态。

注意,这并不意味着每个区块都完整复制一份状态树。以太坊状态树是持久化结构,新旧状态树可以共享大部分未变化节点。只有发生变化的路径需要更新。


17. 三棵树的整体关系

可以把以太坊区块理解为:

1
2
3
4
5
6
7
8
9
10
11
12
Block N
├── Header
│ ├── parentHash
│ ├── stateRoot
│ ├── transactionsRoot
│ └── receiptsRoot

└── Body
└── Transactions
├── Tx0
├── Tx1
└── ...

其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
transactionsRoot
└── Transaction Trie
├── 0 → Tx0
├── 1 → Tx1
└── ...

receiptsRoot
└── Receipt Trie
├── 0 → Receipt0
├── 1 → Receipt1
└── ...

stateRoot
└── State Trie
├── Alice → Account State
├── Bob → Account State
├── USDT Contract → Account State
└── ...

交易执行过程可以理解为:

1
2
3
4
5
6
7
1. 节点读取区块中的交易列表
2. 按交易序号依次执行
3. 每执行一笔交易,产生一个收据
4. 所有交易形成 Transaction Trie
5. 所有收据形成 Receipt Trie
6. 执行后的全局状态形成新的 State Trie
7. 三个根哈希写入区块头

18. 总结

交易树和收据树都是针对当前区块构建的数据结构。交易树用来承诺当前区块中的交易内容和顺序,收据树用来承诺这些交易执行后的结果。每一笔交易都会产生一个对应的收据,因此交易树和收据树在索引上是一一对应的。

以太坊中交易树和收据树也采用 MPT,这在工程上有利于统一数据结构和编码方式。由于它们只针对当前区块构建,理论上也可以使用普通 Merkle Tree;真正必须使用 MPT 的主要是状态树,因为状态树维护的是全局动态账户状态。

Bloom Filter 用于加速日志查询。它可以快速判断某个事件或地址是否可能出现在某些日志中。Bloom Filter 的特点是可能出现假阳性,但不会出现假阴性;普通 Bloom Filter 不支持删除,因为多个元素可能共享同一个 bit 位。

以太坊可以看作交易驱动的状态机。每个区块中的交易按确定顺序执行,从旧状态转移到新状态。所有节点必须在相同输入、相同前置状态和相同执行规则下得到相同结果,否则无法达成共识。比特币也可以被抽象为状态机,只不过它的状态是 UTXO Set,而以太坊的状态是账户、合约代码和合约存储组成的世界状态。

“每个区块只保存当前区块用到的账户状态”这个方案并不可行。它会导致账户查询、余额验证和不存在性证明都变得困难。如果某个账户很久没有交易,节点可能需要一直向前追溯到创世区块才能确认它的状态。以太坊使用全局状态树,就是为了让每个区块的 stateRoot 都能承诺当前完整世界状态,同时通过 MPT 的节点共享机制避免完整复制整棵状态树。

ETH-状态树

ETH 状态树示意图

以太坊用 State Trie 记录所有账户的当前状态。每个账户状态包含 nonce、balance、storageRoot 和 codeHash。普通用户账户主要记录 ETH 余额和 nonce;合约账户还通过 storageRoot 连接到自己的 Storage Trie,用来保存合约变量,例如 ERC-20 余额。每次交易执行后,相关账户或合约存储发生变化,树上相关节点哈希逐层更新,最终生成新的 stateRoot,并写入新区块头。

1
交易执行→ 账户状态变化→ State Trie 更新→ stateRoot 改变→ 新区块头记录 stateRoot

以太坊状态树与 Modified Merkle Patricia Trie 学习笔记

1. 以太坊为什么需要状态树

以太坊和比特币最大的区别之一在于账本模型不同。比特币主要使用 UTXO 模型,关注的是“哪些输出还没有被花费”;而以太坊采用账户模型,需要维护一个全局状态映射:

1
address → account state

也就是说,以太坊需要根据账户地址找到对应的账户状态。一个以太坊账户地址长度为 160 bit,也就是 20 字节,通常表示为 40 个十六进制字符,例如:

1
0x742d35Cc6634C0532925a3b844Bc454e4438f44e

每个账户状态主要包括四个字段:

1
2
3
4
nonce
balance
storageRoot
codeHash

其中,nonce 用于记录账户交易次数或合约创建次数,balance 表示账户持有的 ETH 原生余额,storageRoot 指向合约账户的内部存储树,codeHash 表示合约代码的哈希。普通外部账户一般没有合约代码,也没有复杂存储;而智能合约账户则会通过 storageRoot 关联自己的合约存储。

因此,以太坊实际上需要维护的是一个巨大的、动态变化的全局状态数据库。每执行一笔交易,都可能改变账户余额、nonce 或合约内部变量。为了让所有节点能够验证自己维护的是同一份状态,以太坊不能只使用普通数据库结构,而需要一种既能高效查找,又能提供加密证明的数据结构。


2. 为什么不能只用哈希表

如果只从查找角度看,哈希表很适合维护:

1
address → state

例如:

1
map[address] = accountState

这样可以快速根据地址找到对应账户状态,平均查询复杂度接近 O(1)。但是普通哈希表不能满足区块链系统的核心要求。

首先,普通哈希表没有天然的全局根哈希。区块链需要让所有节点用一个简短的值证明“当前全局状态是否一致”。如果每个节点只是本地维护一个哈希表,那么很难用一个统一的哈希值来代表整套账户状态。

其次,普通哈希表不支持 Merkle Proof。假设轻节点想验证 Alice 的账户余额确实是 5 ETH,如果只有哈希表,完整节点只能直接告诉它结果,轻节点无法用一条短证明路径验证该状态是否真的属于某个区块的全局状态。

以太坊需要的是:

1
2
3
一个状态发生变化后,根哈希也随之变化;
一个账户状态可以通过 Merkle Proof 被验证;
所有节点在同样状态下得到同一个 stateRoot。

所以,普通哈希表虽然查找快,但不能提供状态承诺、轻量证明和全局一致性验证。


3. 为什么不能直接使用普通 Merkle Tree

普通 Merkle Tree 可以提供根哈希,也可以用于证明某个叶子是否属于这棵树。例如比特币区块中的交易 Merkle Tree,就是把当前区块里的交易按顺序组织起来,最终得到一个 Merkle Root。

但是,以太坊状态不是一个固定交易列表,而是一个动态的键值映射:

1
address → account state

普通 Merkle Tree 不适合直接维护这种结构,主要有三个问题。

第一,普通 Merkle Tree 本身不提供快速按 key 查找和更新的能力。它只知道叶子节点的位置,却不知道某个地址应该对应哪个叶子。如果要查询某个账户,还需要额外维护地址到叶子位置的索引。

第二,Merkle Tree 的根哈希依赖叶子节点顺序。如果不同全节点按照不同顺序组织账户叶子,即使包含的账户状态完全相同,最终得到的 root hash 也可能不同。区块链要求所有节点在相同状态下必须得到同一个根哈希,因此叶子顺序必须是确定的。

比特币中没有这个问题,是因为区块内交易顺序由出块节点确定,并写入区块,其他节点按同样顺序验证即可。而以太坊状态树维护的是全局账户映射,不是某个区块内的交易列表,因此不能依赖随机或本地顺序。

第三,如果使用 sorted Merkle Tree,也就是先按 key 排序再构建树,虽然可以解决顺序不一致的问题,但新增账户或新存储项时可能插入到叶子序列中间,导致大量叶子位置变化,进而引发大范围重构。

例如当前叶子 key 是:

1
10, 20, 30, 40

新加入一个 key:

1
25

它应该插入到 20 和 30 中间,叶子排列变成:

1
10, 20, 25, 30, 40

如果采用普通排序 Merkle Tree,后续分组和父节点结构可能发生较大变化,更新成本较高。因此,以太坊需要一种更适合动态键值映射的数据结构。


4. Trie、Patricia Trie 与稀疏地址空间

Trie,又称字典树或前缀树,它的特点是用 key 的每一位决定路径。比如 key 是:

1
a7f3...

那么查找路径就是:

1
root → a → 7 → f → 3 → ...

这种结构非常适合维护键值映射,因为 key 本身决定了存储位置,不需要额外排序,也不需要额外索引。对于以太坊来说,账户地址或地址哈希可以作为 key,账户状态作为 value。

但是普通 Trie 也有问题。如果 key 很长,而且很多路径没有分支,就会产生大量只有一个子节点的中间节点。例如只有一个 key:

1
abcdef

普通 Trie 可能会展开为:

1
root → a → b → c → d → e → f → value

这会浪费大量空间。

Patricia Trie 是压缩前缀树,它会把没有分叉的长路径压缩起来。例如:

1
root → abcdef → value

如果有两个 key:

1
2
abcdef
abc999

则可以压缩为:

1
2
3
4
root
└── abc
├── def → value1
└── 999 → value2

以太坊地址空间非常稀疏。地址长度是 160 bit,理论地址数量是:

1
2^160

这是一个极大的空间,但真实存在的账户数量远远小于这个理论上限。如果使用普通 Trie,会有大量空路径和单支路径,因此更适合使用 Patricia Trie 进行路径压缩。

更准确地说,以太坊状态树通常使用的是地址的哈希值作为路径 key,例如:

1
key = keccak256(address)

这样可以让 key 分布更均匀,避免攻击者构造大量相似前缀地址导致树结构退化。虽然地址本身是 160 bit,但哈希后的路径空间更接近 256 bit,不过“极度稀疏”这一点仍然成立。


5. MPT:Merkle Patricia Trie

MPT 全称是 Merkle Patricia Trie,可以理解为:

1
Merkle Tree + Patricia Trie

它结合了两类能力。

Patricia Trie 提供的是高效的键值查找、插入、更新和路径压缩。Merkle 结构提供的是哈希承诺、防篡改能力和 Merkle Proof。

MPT 中每个节点都会参与哈希计算。如果某个账户状态发生变化,那么对应叶子节点的哈希会变化,父节点哈希也会变化,并一路影响到根节点,最终导致 stateRoot 改变。

例如,Alice 的余额从 5 ETH 变成 4 ETH:

1
2
3
4
5
6
7
8
9
Alice.balance 变化

Alice 账户状态 value 变化

叶子节点哈希变化

父节点哈希逐层变化

stateRoot 变化

这意味着如果有人试图篡改账户状态,就无法得到与区块头中记录的 stateRoot 一致的结果。

MPT 还支持 Merkle Proof。轻节点不需要保存完整状态树,只要知道区块头中的 stateRoot,再获得从根到目标账户叶子的证明路径,就可以验证某个账户状态是否真实存在于该状态树中。

MPT 也可以用于证明某个账户不存在。因为 Trie 的路径由 key 决定,如果查找路径中某个分支不存在,或者最终叶子路径不匹配,就可以证明对应 key 不在树中。


6. 以太坊使用的是 Modified MPT

以太坊实际使用的是 Modified Merkle Patricia Trie,即经过工程化修改的 MPT。这里的 “Modified” 主要体现在节点编码、路径编码、节点引用和存储优化等方面。

MPT 中主要有三类节点:

1
2
3
Branch Node
Extension Node
Leaf Node

Branch Node 是分支节点。以太坊把 key 拆成十六进制 nibble,每一位可能是 0f,所以分支节点最多有 16 个方向,此外还可以有一个 value 字段。

Extension Node 是扩展节点,用于压缩公共路径。例如多个 key 共享相同前缀时,可以把这段公共前缀压缩成一个节点。

Leaf Node 是叶子节点,最终保存账户状态或合约存储值。

以太坊还会对节点和值进行统一编码,常用编码方式是 RLP,即 Recursive Length Prefix。账户状态:

1
[nonce, balance, storageRoot, codeHash]

会先被 RLP 编码成字节序列:

1
RLP([nonce, balance, storageRoot, codeHash])

然后作为状态树中的 value 进行存储和哈希计算。统一编码非常重要,因为哈希函数处理的是字节序列。如果不同节点对同一个账户状态采用不同编码方式,就会得到不同哈希,最终导致 stateRoot 不一致。


7. 状态树、存储树与账户状态

以太坊状态树维护的是全局账户状态:

1
keccak256(address) → RLP(account state)

其中 account state 包含:

1
2
3
4
nonce
balance
storageRoot
codeHash

对于普通外部账户 EOA,例如 Alice 或 Bob:

1
Alice 地址 → Alice Account State

其中主要有:

1
2
3
4
nonce:交易计数
balance:ETH 余额
storageRoot:通常为空
codeHash:空代码哈希

对于合约账户,例如 USDT 合约:

1
USDT 合约地址 → USDT Account State

其中 storageRoot 指向该合约自己的 Storage Trie,codeHash 表示合约代码哈希。

这说明 ERC-20 余额并不是直接存在用户账户的 balance 字段里。用户账户的 balance 只表示 ETH 原生余额,而 USDT、USDC 等 ERC-20 余额存在对应代币合约自己的存储树中。

例如:

1
2
Alice 有 3 ETH
Alice 有 100 USDT

两者存储位置不同:

1
2
3 ETH → Alice 账户状态中的 balance 字段
100 USDT → USDT 合约 Storage Trie 中的 balances[Alice]

USDT 合约内部可能有:

1
2
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowances;

对应到存储树中可以理解为:

1
2
3
balances[Alice] = 100 USDT
balances[Bob] = 250 USDT
allowances[Alice][Uniswap] = 50 USDT

因此,以太坊的状态结构可以理解为“树中挂树”:

1
2
3
4
5
6
7
Block Header
└── stateRoot
└── State Trie
├── EOA Account State
└── Contract Account State
└── storageRoot
└── Storage Trie

8. 每个区块为什么会产生新的状态树

以太坊每个区块执行完交易后,都会得到一个新的状态根:

1
2
3
Block 100 执行后:stateRoot = root_100
Block 101 执行后:stateRoot = root_101
Block 102 执行后:stateRoot = root_102

从逻辑上看,每个区块都对应一个新的世界状态版本,也可以理解为产生了一棵新的状态树。

但是,这并不意味着每个区块都完整复制一整棵 MPT。实际更像是一种持久化数据结构。新状态树和旧状态树共享大部分未改变的节点,只有发生变化的路径需要创建新节点。

例如 Alice 给 Bob 转 1 ETH。执行前:

1
2
3
Alice.balance = 5 ETH
Bob.balance = 2 ETH
Alice.nonce = 10

执行后:

1
2
3
Alice.balance = 4 ETH - gas
Bob.balance = 3 ETH
Alice.nonce = 11

这次交易只影响 Alice 和 Bob 的账户状态,因此只需要更新 Alice 和 Bob 对应路径上的节点。其他没有变化的账户和合约子树可以继续复用。

这类似于 Git 的 commit。一次 commit 不会复制整个项目所有文件,而是记录变化,并通过哈希对象复用未变化的内容。


9. 为什么不能原地修改状态树

以太坊不适合直接原地修改状态树,主要原因是区块链中临时性分叉很常见。

例如某一高度同时出现两个区块:

1
2
3
Block 100
├── Block 101A
└── Block 101B

一部分节点先看到 101A,另一部分节点先看到 101B。后来其中一条分支成为规范链,另一条分支被丢弃。如果某个节点之前执行了 101B,而后来发现 101A 才是主链,就需要回滚 101B 的状态变化,再执行 101A。

如果状态是原地修改的,旧状态已经被覆盖,回滚会非常困难。而如果每个区块都有自己的 stateRoot,并且旧版本状态节点仍然可以被引用,那么节点就可以从错误分支切回正确分支。

此外,智能合约执行过程复杂,很多交易并不是简单可逆的。智能合约交易可能修改多个 storage slot,调用其他合约,依赖当前区块环境、价格预言机、合约内部状态等。交易执行后,仅根据新状态和交易内容,通常无法可靠反推出旧状态。

例如一个 DeFi 清算交易可能会:

1
2
3
4
5
6
读取用户抵押品
计算健康因子
修改债务状态
转移抵押资产
更新协议统计变量
调用价格预言机

执行完成后,如果没有保存旧状态或变更日志,很难从新状态反推出执行前的所有旧值。因此,以太坊需要保留足够的历史状态信息,或者使用可共享旧节点的持久化结构来支持回滚和状态切换。


10. RLP 编码的作用

状态树中的 value 在存储前需要序列化。以太坊使用的序列化方式是 RLP,全称是:

1
Recursive Length Prefix

例如账户状态为:

1
2
3
4
nonce = 1
balance = 100
storageRoot = 0xabc...
codeHash = 0xdef...

它首先会被组织成列表:

1
[1, 100, 0xabc..., 0xdef...]

然后经过 RLP 编码变成确定的字节序列:

1
RLP([1, 100, storageRoot, codeHash])

这个编码后的结果才会作为 MPT 中的 value 存储,并参与哈希计算。

RLP 的意义在于提供统一、确定的编码方式。因为不同节点必须对同一个账户状态计算出完全相同的字节表示,才能得到相同的哈希和相同的 stateRoot。如果编码规则不统一,即使账户状态内容一样,不同节点也可能得到不同的根哈希。


11. 用完整例子串联理解

假设当前状态中有三个账户:

1
2
3
Alice: 5 ETH, nonce = 10
Bob: 2 ETH, nonce = 3
USDT Contract: storageRoot = root_usdt

状态树中逻辑上保存:

1
2
3
keccak256(Alice 地址) → RLP(Alice account state)
keccak256(Bob 地址) → RLP(Bob account state)
keccak256(USDT 地址) → RLP(USDT account state)

当前区块头记录:

1
stateRoot = root_100

现在 Alice 向 Bob 转账 1 ETH。交易执行后:

1
2
3
Alice.balance = 4 ETH - gas
Alice.nonce = 11
Bob.balance = 3 ETH

于是 Alice 和 Bob 的账户状态发生变化,对应的叶子节点 value 发生变化,叶子节点哈希随之变化;然后路径上的父节点哈希逐层变化,最后整棵状态树的根哈希从:

1
root_100

变成:

1
root_101

新区块头记录新的:

1
stateRoot = root_101

但是没有变化的账户,例如 Carol、USDT、Uniswap 等,它们对应的子树可以继续复用,不需要重新构建整棵树。

如果后来这个区块所在分支不是主链,节点可以回到旧的:

1
root_100

再执行另一条分支上的区块。这就是状态树版本化和非原地修改的重要意义。


12. 总结

以太坊选择 Modified Merkle Patricia Trie,不是单纯为了存储数据,而是为了同时满足以下需求:

1
2
3
4
5
6
7
根据地址快速查找账户状态
交易执行后局部更新状态
所有节点在相同状态下得到同一个 stateRoot
支持 Merkle Proof 轻量验证
支持账户存在性和不存在性证明
支持临时分叉下的状态回滚
通过 RLP 保证状态编码的一致性

普通哈希表虽然查找快,但不能提供全局根哈希和 Merkle Proof。普通 Merkle Tree 虽然能提供根哈希,但不适合动态的大规模键值映射。Sorted Merkle Tree 虽然能保证顺序一致,但新增节点可能导致较大范围重构。Trie 可以让 key 决定路径,Patricia Trie 可以压缩稀疏路径,Merkle 化之后又可以提供防篡改和证明能力。

因此,以太坊使用 Modified Merkle Patricia Trie 来维护全局状态。每个区块执行后都会产生新的 stateRoot,该根哈希写入区块头,用来承诺当前完整世界状态。新旧状态树共享大部分节点,只更新发生变化的路径,从而兼顾查找效率、状态证明、节点一致性和分叉回滚能力。

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 9 - King

本关目标

成为 King 后,让关卡合约无法重新夺回王位。

考察知识点

  • 拒收 ETH
  • 外部转账阻塞状态机
  • push payment 风险

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {

address king;
uint public prize;
address public owner;

constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address) {
return king;
}
}

源码与漏洞解析

  1. King 的 receive() 逻辑是:检查出价,再给旧 king 转账,然后更新 king 和 prize。
  2. 转账使用 transfer,如果旧 king 是合约且拒收 ETH,整个 receive 会 revert,新的挑战者无法成为 king。
  3. 攻击合约用足够 ETH 成为 king 后,在自己的 receive() 中 revert。提交实例时,Ethernaut 关卡会尝试重新成为 king,但给攻击合约退款时会失败。
  4. 这关考察拒绝服务型漏洞:不是偷资产,而是利用外部调用失败阻断关键流程。

解题过程

  1. 部署攻击合约,并向 King 实例发送不低于当前 prize 的 ETH。
  2. King 的 receive 会尝试把旧 prize 转给旧 king,然后更新 king。
  3. 攻击合约没有可收款逻辑或主动 revert,之后任何人替换 king 时都会失败。

攻击合约 WP

1
2
3
4
5
6
7
8
9
10
contract KingAttack {
constructor(address payable instance) payable {
(bool ok, ) = instance.call{value: msg.value}("");
require(ok, "take throne failed");
}

receive() external payable {
revert("no refund");
}
}

最终 WP

  1. 读取当前 prize(),部署攻击合约并发送至少 prize 数量的 ETH 到 King 实例。
  2. 攻击合约成为 king。
  3. 攻击合约的 receive() 永远 revert,拒绝后续退款。
  4. 提交实例,关卡合约夺回王位失败,通关。

复盘与拓展

  • 易错点:把外部转账放进关键路径,会被拒收方阻断。
  • 防御建议:使用 pull payment,让收款人主动提款;不要在状态更新前强制向未知地址转账。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 8 - Vault

本关目标

读取 Vault 的私有 password,调用 unlock 解锁。

考察知识点

  • 链上 storage 可读
  • private 不是加密
  • slot 编号

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Vault {
bool public locked;
bytes32 private password;

constructor(bytes32 _password) {
locked = true;
password = _password;
}

function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}

源码与漏洞解析

  1. private 只限制 Solidity 语法层面的访问,链上 storage 对所有节点公开。
  2. Vault 有两个状态变量:locked 在 slot0,passwordbytes32,通常在 slot1。
  3. web3.eth.getStorageAt(instance, 1) 可以直接读取 slot1 中的 32 字节密码。
  4. 读取结果本身就是 bytes32,可以原样传给 unlock

过程截图

从 storage slot 中读取 password 的记录。

图注:从 storage slot 中读取 password 的记录。

解题过程

  1. locked 在 slot0,password 在 slot1。
  2. 使用 web3.eth.getStorageAt(instance, 1) 读取 slot1。
  3. 把读到的 bytes32 传给 unlock

Console WP

1
2
3
const password = await web3.eth.getStorageAt(instance, 1)
await contract.unlock(password)
await contract.locked()

最终 WP

  1. 调用 web3.eth.getStorageAt(instance, 1) 读取 password。
  2. 把读出的 bytes32 传给 contract.unlock(password)
  3. 确认 locked() 为 false。
  4. 提交实例。

复盘与拓展

  • 易错点:链上数据对所有节点公开,private 不等于加密。
  • 防御建议:不要把秘密明文上链;需要秘密时使用承诺、零知识、链下签名或延迟揭示机制。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 7 - Force

本关目标

让 Force 合约余额大于 0。

考察知识点

  • 强制转 ETH
  • selfdestruct 余额转移
  • 不要依赖合约余额为 0

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force {/*

MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)

*/}

源码与漏洞解析

  1. Force 合约没有任何 payable 函数,正常转账会失败,但 EVM 仍有强制改变地址余额的路径。
  2. 旧语义下 selfdestruct(payable(target)) 会销毁当前合约并把余额发送到目标地址;目标合约无需实现 receive/fallback,也无法拒收。
  3. 因此部署一个带余额的攻击合约,再让它 selfdestruct 到 Force 地址即可。
  4. 这关的安全启发是:不要把业务判断建立在 address(this).balance == 0 这类外部可强制改变的状态上。

解题过程

  1. 部署攻击合约并在部署或调用时给它一点 ETH。
  2. 调用攻击合约中的 selfdestruct(payable(instance))
  3. 目标合约余额被强制增加。

攻击合约 WP

1
2
3
4
5
6
7
contract ForceAttack {
constructor() payable {}

function attack(address payable instance) external {
selfdestruct(instance);
}
}

最终 WP

  1. 部署攻击合约,并在部署或调用时给它一点 ETH。
  2. 调用攻击合约的 attack(instance)
  3. 攻击合约执行 selfdestruct(payable(instance)),Force 余额增加。
  4. 提交实例。

复盘与拓展

  • 易错点:合约余额不是只能通过自身代码路径变化。
  • 防御建议:不要把 address(this).balance == 0 当作安全不变量;使用内部记账变量表示业务余额。
  • 拓展:Dencun 后 selfdestruct 的删除代码语义发生变化,但强制转余额这个学习点仍然需要单独理解。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 6 - Delegation

本关目标

通过 Delegation 的 fallback + delegatecall 执行 Delegate.pwn,接管 owner。

考察知识点

  • delegatecall
  • 函数选择器
  • 存储上下文复用

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {

address public owner;

constructor(address _owner) {
owner = _owner;
}

function pwn() public {
owner = msg.sender;
}
}

contract Delegation {

address public owner;
Delegate delegate;

constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

源码与漏洞解析

  1. Delegation 自己没有 pwn(),但 fallback 会把任意 calldata 通过 delegatecall 交给 Delegate
  2. delegatecall 的关键点:执行的是被调用合约代码,但读写的是调用方的 storage,msg.sender 也保持为原始调用者。
  3. Delegate.pwn()owner = msg.sender。通过 delegatecall 执行时,写入的是 Delegation.owner
  4. 要触发它,只需要把 pwn() 的函数选择器作为 calldata 发给 Delegation 实例。选择器是 bytes4(keccak256("pwn()")) = 0xdd365b8b

解题过程

  1. 目标 fallback 把 calldata 委托给 Delegate 合约。
  2. pwn() 的函数选择器为 0xdd365b8b
  3. 直接向实例发送这 4 字节 calldata,fallback 会 delegatecall 到 pwn()
  4. Delegate 代码写入 slot0,实际改的是 Delegation 的 slot0。

Console WP

1
2
3
await web3.eth.abi.encodeFunctionSignature("pwn()")
await contract.sendTransaction({ data: "0xdd365b8b" })
await contract.owner()

最终 WP

  1. 计算或记下 pwn() selector:0xdd365b8b
  2. 向实例发送一笔 calldata 为 0xdd365b8b 的交易。
  3. fallback delegatecall 到 Delegate,实际覆盖 Delegation 的 slot0 owner。
  4. 确认 owner() 变成玩家地址并提交。

复盘与拓展

  • 易错点:delegatecall 的危险点在于代码来自别处,但状态是自己的。
  • 防御建议:只 delegatecall 到可信、固定、存储布局兼容的实现;升级代理要严格控制实现地址。
  • 拓展:代理模式、库合约和 upgradeable 合约都大量依赖 delegatecall,审计时必须检查目标地址可控性与 storage layout。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 5 - Token

本关目标

让玩家持有的 Token 数量超过初始 20。

考察知识点

  • 无符号整数下溢
  • Solidity 0.6 算术行为
  • 余额检查顺序

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

mapping(address => uint) balances;
uint public totalSupply;

constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}

function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}

源码与漏洞解析

  1. 漏洞在 transfer 的检查:require(balances[msg.sender] - _value >= 0)
  2. balances[msg.sender]uint,在 Solidity 0.6 中无下溢检查。当余额 20 转出 21 时,20 - 21 不会报错,而是回绕成一个极大的无符号整数。
  3. 无符号整数永远大于等于 0,所以 require 通过。随后 balances[msg.sender] -= _value 再次下溢,玩家余额变成极大值。
  4. Solidity 0.8 默认会检查算术上下溢;旧版本必须用 SafeMath 或显式 require(balance >= amount)

过程截图

转出 21 个 token 后余额发生下溢回绕的记录。

图注:转出 21 个 token 后余额发生下溢回绕的记录。

解题过程

  1. 初始余额为 20。
  2. transfer 里先检查 balances[msg.sender] - value >= 0,但无符号整数下溢后会变成极大值。
  3. 转出 21 个代币即可让余额回绕。

Console WP

1
2
3
await contract.balanceOf(player)
await contract.transfer(instance, 21)
await contract.balanceOf(player)

最终 WP

  1. 查看初始余额:balanceOf(player) 应为 20。
  2. 调用 transfer(instance, 21),转出比余额多 1 的数量。
  3. 再次查看余额,看到余额回绕为极大值。
  4. 提交实例。

复盘与拓展

  • 易错点:无符号整数永远不小于 0,配合旧编译器的回绕行为会让检查失效。
  • 防御建议:使用 Solidity 0.8+ 或 SafeMath;扣减前显式检查 balance >= amount

参考资料

0%