EIP-8297 分区二叉树(PBT)深度解析:以太坊状态树从 MPT 迈向有效性证明的完整方案
【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs
导读
本文围绕 EIPS/eip-8297.md(Partitioned Binary Tree,PBT,状态 Draft)展开,系统讲解以太坊如何用一棵"分区二叉树"取代现有的十六叉 Merkle Patricia Trie(MPT):账户树与存储树合并为单棵树、代码按内容寻址并分块入树、第一字节作为 zone 标识符划分状态类别。读完本文你将掌握 PBT 的树形结构、节点 Merkle 化规则、zone 分区与树嵌入(Tree Embedding)的密钥派生、代码分块与 PUSHDATA 标记、头部/存储布局、零值与删除语义,以及 EIP 给出的测试向量与安全分析——并能结合仓库中的 EIPS/eip-8347.md(离线迁移)理解它在主网落地的完整路径。
一、EIP-8297 是什么:从"树之树"到"单树分区"
EIP-8297 提议将以太坊状态树切换为一棵分区二叉树(Partitioned Binary Tree),其核心目标是为"区块有效性证明(validity proof)"铺路。作者阵容包括 Vitalik Buterin、Guillaume Ballet、Dankrad Feist 等核心研究者,属于 Standards Track / Core 类别,创建于 2026-06-11。
与它的直接前身 EIPS/eip-7864.md(Ethereum state using a unified binary tree,2025-01-20 提出)相比,EIP-8297 在"统一二叉树"的基础上显式引入 zone 分区机制:树的根节点直接按 zone 字节的最高位分裂,账户头、代码、存储分别落在固定的低/高 zone 中,从而获得结构边界与代码去重两大额外能力。
需要注意:本草案中的哈希函数尚未定稿。参考实现目前使用 BLAKE3 以便客户端实验,但最终选择保持开放(候选包括 Keccak 与 Poseidon2),后续章节会详细展开。
二、动机:MPT 为何对有效性证明不友好
以太坊的长期目标是让区块可以被有效性证明验证,使链验证尽可能简单快速。其中最难的部分之一,就是证明 EVM 执行期间读取的状态。而 Merkle Patricia Trie 在这方面存在多个结构性障碍:
- 节点编码使用 RLP,不利于电路内证明;
- 哈希使用 Keccak,在证明系统中效率偏低;
- 它是"树之树"(tree of trees):账户树套存储树、再套代码,多层嵌套;
- 无法高效证明字节码的某一段——代码未分块时,要证明任意一个字节,必须揭示整段代码;
- 生成的 Merkle 证明体积大。
原文档给出了两组直观的量化数据:
- 常规证明:今天账户树最大深度约 12,该深度下的一个分支为
15 * 32 * 12 = 5760字节(12 层 × 每层 15 个 32 字节兄弟哈希)。 - 最坏情况区块:用满
60Mgas 去触碰大量不同账户代码的单个字节(且代码均未分块),需要60M/2400 * (12*480 + 64k) ≈ 1.8GB。其中2400是触碰新账户的最便宜 gas(EIP-2930 访问列表地址成本,而 EIP-2929 冷访问为2600);12*480是到该账户的分支路径;64k是 EIP-7954 的 64 KiB 代码大小上限——未分块时证明任意一个代码字节都必须完整揭示 64 KiB。
二叉树为何能缩小证明:证明大小与siblings * log_arity(N)成正比,arity=2 时该值最小;同时把 Keccak 换成更利于证明的哈希函数,可显著改善电路性能。
在平坦统一树的基础上,分区额外带来两个性质:
- 结构边界(Structural boundaries):已知长度的密钥前缀总是某类别的根——zone 字节标识账户头/代码/存储;存储区内
key_hash(address)标识一个账户的桶。协议无需额外结构即可把这些密钥空间区域当作承诺(commitment)引用。这正是后续状态过期(state expiry)与部分无状态化(partial statelessness)方案所依赖的基础。 - 代码去重(Code deduplication):代码按 code hash 而非按账户内容寻址,因此由同一工厂部署的数千个合约共享同一批代码叶子,而非各自保存一份拷贝。
三、规范(Specification)
3.1 与十六叉结构的主要变化
- 账户树与存储树合并为单棵树;
- 不再使用 RLP;
- 账户代码被分块并纳入树中;
- 账户数据(余额、nonce、前 64 个存储槽)共置(co-located),减少分支开启次数;
- 代码内容寻址而非按账户键控,字节码相同的合约共享代码。
3.2 树结构(Tree structure)
树存储键值对:键是非空、可变长字节串,最多MAX_KEY_LENGTH字节(见 3.5);值是 32 字节。键必须无前缀(prefix-free):树中任何键都不能是另一个键的前缀。计算根时,违反这两个约束的键都会被拒绝。
树只有两种节点类型:
LeafNode:包含完整key与 32 字节value;BranchNode:包含prefix(位串,可为空)、left、right。
没有独立的扩展节点(extension node)。BranchNode的prefix承载其下所有键共享、且未被祖先消费的位序列。BranchNode必须有两个非空子节点:若前缀短于真实共享位串,键在下一比特仍会一致,导致某一侧为空,构不成合法BranchNode。这强制每个前缀恰好等于共享位串,因此每个键值集合恰好对应一棵合法的树。
LeafNode提交的是完整键而非相对树中位置的片段,因此其含义不依赖所在位置;在别处拆分/合并分支不会改变无关叶子的哈希。
参考实现的建树逻辑(binarize)如下:
def _bytes_to_bits(data: bytes) -> list[int]: return [(byte >> (7 - i)) & 1 for byte in data for i in range(8)] class LeafNode: def __init__(self, key: bytes, value: bytes): self.key = key self.value = value class BranchNode: def __init__(self, prefix: list[int], left: "BinaryNode", right: "BinaryNode"): self.prefix = prefix self.left = left self.right = right BinaryNode = LeafNode | BranchNode def binarize(entries: dict[bytes, bytes], depth: int) -> BinaryNode: assert len(entries) > 0 if len(entries) == 1: ((key, value),) = entries.items() return LeafNode(key, value) bits = {key: _bytes_to_bits(key) for key in entries} split = depth while True: # A key that runs out of bits while still grouped with others is # a prefix of theirs. for key_bits in bits.values(): assert split < len(key_bits), "keys are not prefix-free" if len({key_bits[split] for key_bits in bits.values()}) > 1: break split += 1 left = {k: v for k, v in entries.items() if bits[k][split] == 0} right = {k: v for k, v in entries.items() if bits[k][split] == 1} prefix = next(iter(bits.values()))[depth:split] return BranchNode(prefix, binarize(left, split + 1), binarize(right, split + 1))下图(assets/eip-8297/diagram.png)直观展示了 PBT 的结构:根节点按 zone 字节的最高位分裂;蓝色分支把长共享位串折叠进单个前缀(如账户头叶子只在最后子索引位不同、相邻存储槽共享一个 group);整个树不存在单链延伸,因此比全深度 SMT(稀疏 Merkle 树)浅得多。
3.3 节点 Merkle 化(Node merkelization)
定义标签LEAF_TAG = 0x00、BRANCH_TAG = 0x01。H是树的 32 字节哈希函数,与key_hash使用同一函数(即当前实现中的 BLAKE3,见 Abstract 的说明)。
encode_bit_prefix将位串打包用于哈希:两字节大端比特计数 + 位串本身(高位优先,按字节边界零填充):
def encode_bit_prefix(prefix: list[int]) -> bytes: assert len(prefix) < 2**16, "prefix exceeds encodable bit count" packed = bytearray((len(prefix) + 7) // 8) for i, bit in enumerate(prefix): packed[i // 8] |= bit << (7 - i % 8) return len(prefix).to_bytes(2, "big") + bytes(packed)各节点类型的 Merkle 化规则:
leaf_hash = H(LEAF_TAG || key || value)branch_hash = H(BRANCH_TAG || encode_bit_prefix(prefix) || left_hash || right_hash)- 空树的哈希为
[0x00] * 32
def merkelize(node: BinaryNode) -> bytes: if isinstance(node, LeafNode): return H(bytes([LEAF_TAG]) + node.key + node.value) return H( bytes([BRANCH_TAG]) + encode_bit_prefix(node.prefix) + merkelize(node.left) + merkelize(node.right) )键值集合的状态根为:
def state_root(entries: dict[bytes, bytes]) -> bytes: for key, value in entries.items(): assert 1 <= len(key) <= MAX_KEY_LENGTH, "key length out of range" assert len(value) == 32, "value must be 32 bytes" if len(entries) == 0: return b"\x00" * 32 return merkelize(binarize(entries, 0))3.4 插入与删除(Insertion and deletion)
一次变更即对条目集合的一次更新:插入设置某键的值,删除移除该键。两者对值空间都是通用的——树没有表示"缺失"的特殊值:任何 32 字节值都可存储,只有键的存在与否区分存在与缺失。变更后的根就是结果条目集合的state_root。
增量维护树的实现(而非每次重建)必须产生同样的根。对删除而言这意味着恢复规范形式:删除一个键会让其父分支只剩单个子节点,该子节点必须顶替父分支的位置:
- 幸存的
LeafNode原样提升(因为它提交完整键); - 幸存的
BranchNode则取父分支的前缀,后接选中它的那个比特,再接自身前缀。
只有直接父节点可能只剩一个子节点,因此合并被限制在一层之内,且恰好是插入时拆分操作的逆过程。
3.5 最大键长(Maximum key length)
MAX_KEY_LENGTH = 8192字节。该上界来自分支前缀编码的限制:
encode_bit_prefix用两字节存储分支比特计数,最大可表示前缀为2**16 - 1 = 65535比特;- 分支前缀是键共享位串的长度,终止于它们开始分歧的比特之前;
- 两个
L字节(8*L比特)的不同键至少有一位不同,因此最长共享位串为8*L - 1比特(除最后一位外全部一致); - 要求
8*L - 1 <= 65535,得L <= 8192。
超过MAX_KEY_LENGTH的键必须被拒绝。对每个键都强制执行该约束(而非仅在encode_bit_prefix内检查),是为了让上界成为每个键的固有属性,而不是一个依赖"恰好有其他键存在"的偶发失败:一个超长键本身并非无效,只有当第二个键与它共享足够多的前缀以致溢出计数域时才会出问题。
3.6 分区(Zones)
每个键的第一个字节是 zone 标识符Z:
ZoneZ | Category |
|---|---|
0x00 | 账户头(Account headers) |
0x01 | 代码块(Code chunks,内容寻址) |
0x02-0xFE | 保留给未来类别(Reserved) |
0xFF | 存储(Storage) |
新类别必须从0x02-0xFE分配,且必须保持键彼此无前缀(见 3.7 树嵌入)。
3.7 树嵌入(Tree embedding)
所有状态都嵌入同一个键值空间。一起访问的数据在**共享键前缀("stem")**下共置,以减少分支开启。账户头持有账户的基本数据、code hash 或委托、以及前 64 个存储槽,共享同一个 header stem。代码不在 header 中,而是位于CODE_ZONE,内容寻址。
关键参数如下:
| Parameter | Value |
|---|---|
| BASIC_DATA_LEAF_KEY | 0 |
| CODE_HASH_LEAF_KEY | 1 |
| DELEGATION_LEAF_KEY | 2 |
| HEADER_STORAGE_OFFSET | 64 |
| HEADER_STORAGE_SLOTS | 64 |
| STEM_SUBTREE_WIDTH | 256 |
| ACCOUNT_ZONE | 0x00 |
| CODE_ZONE | 0x01 |
| STORAGE_ZONE | 0xFF |
| ACCOUNT_KEY_LENGTH | 34 |
| CODE_KEY_LENGTH | 34 |
| STORAGE_KEY_LENGTH | 66 |
一个必需的常量约束是:HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS <= STEM_SUBTREE_WIDTH。
本嵌入产生的每个键,其长度由所属 zone 固定:账户、代码、存储 zone 分别为ACCOUNT_KEY_LENGTH、CODE_KEY_LENGTH、STORAGE_KEY_LENGTH。每个 zone 固定单一键长正是保持区内键无前缀的关键——否则同一 zone 的较短键会成为较长键的真前缀。不同 zone 的键首字节不同,天然不会互相冲突。实现必须断言其构造的每个键的长度。
地址以Address32传递。将传统地址转换为Address32时前置 12 个零字节:
def address20_to_address32(address: Address) -> Address32: return b'\x00' * 12 + address键由三部分组成:zone 字节、哈希派生的树位置、子索引字节。zone 字节与树位置合起来构成键的 stem:
def key_hash(inp: bytes) -> bytes32: return blake3(inp).digest() def get_tree_key(zone: int, tree_position: bytes, sub_index: int) -> bytes: return bytes([zone]) + tree_position + bytes([sub_index])3.8 头部值(Header values)
账户头的 stem 位于ACCOUNT_ZONE,仅以地址为键,因此每个账户恰好一个 header stem:
def get_tree_key_for_header(address: Address32, sub_index: int) -> bytes: key = get_tree_key(ACCOUNT_ZONE, key_hash(address), sub_index) assert len(key) == ACCOUNT_KEY_LENGTH return key def get_tree_key_for_basic_data(address: Address32): return get_tree_key_for_header(address, BASIC_DATA_LEAF_KEY) def get_tree_key_for_code_hash(address: Address32): return get_tree_key_for_header(address, CODE_HASH_LEAF_KEY)version、balance、nonce、code_size在BASIC_DATA_LEAF_KEY处的值中以大端序打包:
| Name | Offset | Size |
|---|---|---|
version | 0 | 1 |
code_size | 4 | 4 |
nonce | 8 | 8 |
balance | 16 | 16 |
字节 1 到 3 保留。4 字节的code_size可容纳高达2^32 - 1字节,远超任何可预见的合约大小上限。将这些字段打包进一个叶子,只需一次分支开启而非三次或四次,从而降低 gas 并简化见证(witness)生成。
设置任何头部字段都会将version置零。code_hash与code_size在合约或 EOA 创建时设置;无代码账户的 code hash 叶子持有空字节码的 Keccak 哈希,不受本 EIP 选择 Merkle 化哈希的影响(见第 5 节向后兼容)。
已使用的 header 子索引为:BASIC_DATA_LEAF_KEY、CODE_HASH_LEAF_KEY、DELEGATION_LEAF_KEY,以及HEADER_STORAGE_OFFSET..HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS - 1。本 EIP 定义的键不会解析到其他子索引;其余子索引保留给未来的头部字段。
3.9 委托(Delegation)
若账户代码是 EIP-7702 委托指示符——23 字节的0xef0100 || target——则其存于 header stem 而非作为代码:
def get_tree_key_for_delegation(address: Address32): return get_tree_key_for_header(address, DELEGATION_LEAF_KEY)值为指示符后接九个零字节,code_size为 23。此类账户没有CODE_ZONE叶子,也没有code_hash叶子——因为该叶子同时决定了代码及其哈希:读取代码时取前code_size字节,EXTCODEHASH对其求哈希。委托与持有合约代码互斥,因此每个存在的账户恰好持有CODE_HASH_LEAF_KEY与DELEGATION_LEAF_KEY两个叶子之一。对零地址的授权会清除委托:将该叶子替换为持有空字节码哈希的code_hash叶子,并把code_size置零。
由于指示符无法作为合约代码部署、且没有早于禁令(EIP-3541)的先例,账户只能通过委托持有它。
3.10 代码(Code)
每个代码块位于CODE_ZONE,以code_hash内容寻址,因此字节码相同的合约共享叶子。共享同一tree_index的STEM_SUBTREE_WIDTH个对齐块构成一个代码组(code group);组内块共享 stem,仅子索引字节不同。没有代码块按地址键控。
def get_tree_key_for_code_chunk(code_hash: bytes32, chunk_id: int): tree_index = chunk_id // STEM_SUBTREE_WIDTH sub_index = chunk_id % STEM_SUBTREE_WIDTH key = get_tree_key( CODE_ZONE, key_hash(code_hash + tree_index.to_bytes(32, "big")), sub_index ) assert len(key) == CODE_KEY_LENGTH return key块i存储 32 字节值:字节 1..31 是代码的第 i 个 31 字节切片,字节 0 是开头属于PUSHDATA的字节数。例如代码为...PUSH4 99 98 | 97 96 PUSH1 128 MSTORE...(|处开始新块),则后一块以2 97 96 PUSH1 128 MSTORE开头,记录其前 2 字节是 PUSHDATA。
PUSH_OFFSET = 95 PUSH1 = PUSH_OFFSET + 1 PUSH32 = PUSH_OFFSET + 32 def chunkify_code(code: bytes) -> Sequence[bytes32]: if len(code) % 31 != 0: code += b'\x00' * (31 - (len(code) % 31)) bytes_to_exec_data = [0] * (len(code) + 32) pos = 0 while pos < len(code): if PUSH1 <= code[pos] <= PUSH32: pushdata_bytes = code[pos] - PUSH_OFFSET else: pushdata_bytes = 0 pos += 1 for x in range(pushdata_bytes): bytes_to_exec_data[pos + x] = pushdata_bytes - x pos += pushdata_bytes return [ bytes([min(bytes_to_exec_data[pos], 31)]) + code[pos: pos+31] for pos in range(0, len(code), 31) ]当某块的 31 个代码字节全为0x00且字节 0 的 PUSHDATA 计数也为零时(如连续STOP或零填充的数据区),该块编码为 32 个零字节。从更早块延续下来的 PUSHDATA 零字节不满足此条件(此时字节 0 会记录延续)。这种零块像其他零值一样从树中缺席(见 3.12)。因此块的存在性不界定合约代码的边界:块数为ceil(code_size / 31),缺席的块读回为它本应持有的 32 个零字节,EVM 无法区分两者。代码全为零的合约根本没有代码叶子,但仍可通过code_size与code_hash与无代码账户区分。见证在原本证明其值的地方,改为证明该块缺席。
3.11 存储(Storage)
存储槽 0 到 63 位于账户 header stem 的子索引HEADER_STORAGE_OFFSET..HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS - 1。槽 64 及以上位于存储 zone。
存储键的 stem 以存储 zone 字节开头,后接其树位置:两个完整哈希摘要。
key_hash(address)将账户的全部溢出存储置于一个共享前缀(该账户的"桶")下;- 共享同一
tree_index的STEM_SUBTREE_WIDTH个对齐槽构成一个存储组(storage group),组内槽共享 stem,仅子索引字节不同; key_hash(address || tree_index)在桶内分散该账户的存储组。第二个摘要同时绑定地址与tree_index(见第 6 节安全考虑)。
def storage_tree_position(address: Address32, tree_index: int) -> bytes: prefix = key_hash(address) suffix = key_hash(address + tree_index.to_bytes(32, "big")) return prefix + suffix def get_tree_key_for_storage_slot(address: Address32, storage_key: int): if storage_key < HEADER_STORAGE_SLOTS: return get_tree_key_for_header(address, HEADER_STORAGE_OFFSET + storage_key) tree_index = storage_key // STEM_SUBTREE_WIDTH sub_index = storage_key % STEM_SUBTREE_WIDTH key = get_tree_key( STORAGE_ZONE, storage_tree_position(address, tree_index), sub_index ) assert len(key) == STORAGE_KEY_LENGTH return key组 0 是例外:槽 0..63 位于 header,因此其存储区叶子只有槽 64..255。映射(mapping)和数组中常见的相邻槽共享一个组。
3.12 零值与删除(Zero values and deletion)
"零映射为缺失"属于状态转移函数的职责,它必须把写入 32 个零字节解析为删除而非插入:
def state_write(entries: dict[bytes, bytes], key: bytes, value: bytes) -> None: if value == b"\x00" * 32: entries.pop(key, None) else: entries[key] = value因此对缺席键写零是空操作,状态树中没有任何键持有 32 个零字节,读取缺席键返回零。零与缺失是同一状态、承诺同一根——与 MPT 一致(见 4.7 合并零与缺失)。
删除账户(EIP-161 状态清理,或按 EIP-6780 在创建该账户的交易中执行SELFDESTRUCT)必须移除其 header 叶子与存储叶子。其代码是内容寻址的、可能与其他账户共享,因此其CODE_ZONE叶子仅当结果状态中没有其他账户持有相同code_hash时才移除,否则必须保留。
MPT 靠检查storage_root判断地址是否有非空存储(即 EIP-7610 在合约创建前检查的条件)。本树没有该节点,因此地址有非空存储当且仅当在其 header 子索引HEADER_STORAGE_OFFSET..HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS - 1之一、或其存储桶任意位置存在叶子。
3.13 分叉(Fork)
激活与既有 MPT 状态向本树的迁移,由 EIPS/eip-8347.md 规定。迁移在每种情况下都脱离共识关键路径:节点要么在已定稿的锚定块(anchor block)自行转换状态,要么下载快照并对照锚定块状态根验证。转换后的状态通过重放区块级访问列表(BAL)追赶链头,树在单个协调的硬分叉点成为规范状态承诺。
四、设计论证(Rationale)
本 EIP 只定义树本身,不定义既有状态如何转换——迁移由 EIPS/eip-8347.md 规定:MPT 状态离线转换,树在分叉时以满状态激活。
4.1 单树+分区
单棵键值树比"树之树"更简单:数据库访问、缓存、同步与证明代码都只操作一个抽象,见证 gas 规则也更清晰。各层级的位置都由哈希派生:stem 在 zone 内均匀散布,账户的存储组在桶内均匀散布,因此树保持平衡——除那些刻意为之的共享前缀外,而压缩会将其折叠掉(见 4.6)。
zone 在不牺牲平衡的前提下增加结构:每个 zone 是自包含的密钥空间区域,节点可以独立同步、证明或过期一个类别而不触碰其他类别。由于叶子不存storage_root,账户 zone 的 nonce 与存储 zone 的某个槽是相互独立的写入;根在一次自底向上遍历中重算,两条分支在根附近汇合。这允许跨 zone、zone 内跨账户、账户内跨 stem 的并行。
4.2 存储布局
存储是体量最大、被证明最频繁的状态类别,因此其桶分配获得最强保证:完整的key_hash(address)摘要给每个账户独享的存储桶,两个账户共享一个桶的概率可忽略;桶正是后续过期与部分有状态方案可剪枝或同步的单位。组分散摘要同时绑定地址与tree_index,将 4.6 中分析的磨削(grinding)限制在攻击者自己的桶内。
在 header stem 内部,HEADER_STORAGE_OFFSET位于 2 的幂边界:HEADER_STORAGE_SLOTS = 64时,header 槽恰好是前两位为01的子索引,整个范围挂在单个分支下,触及多个 header 槽的见证共享一条下行路径。CODE_HASH_LEAF_KEY与HEADER_STORAGE_OFFSET之间的子索引保留给未来头部字段——保留是免费的(缺席键不占节点),且分配在那里的字段与每个账户访问都会读取的 basic data、code hash 叶子共享前导00位,落在见证已开启的分支上。
4.3 内容寻址代码
按code_hash而非按账户键控代码,使字节码相同的所有合约共享叶子。大多数已部署合约重复少量模板,这从状态中移除了大量重复代码;同理,无论多少合约触及它,区块见证至多包含共享块的一份拷贝。共享也是账户删除前要检查再删块的原因(见 3.12)。
该检查仅凭交易即可判定:代码叶子存在的充要条件是某个账户持有该合约代码;EIP-161 清理只触及无代码账户,因此带代码的账户只能被创建它的交易中的SELFDESTRUCT删除。先于交易的叶子必被交易无法删除的账户持有,故保留;交易插入的叶子只被交易写入该代码的账户持有,当它们都不复存在时随之删除。两种情况都不读取早于交易的状态,因此不需要跨状态的引用计数。
委托指示符不能作为代码保存,是因为账户可以替换委托指示符但永不替换合约代码。判断另一个账户是否仍委托同一目标是交易与见证都无法回答的(共享叶子在 1 个账户持有与 100 万个账户持有时字节相同),那需要跨整个状态的引用计数,且要在重启、快照同步与重组中保持正确。未来若允许活跃账户替换CODE_ZONE中的代码,必须另行说明如何保持检查的局部性。
指示符占用独立子索引而非与账户不同时需要的 code hash 叶子共用,是因为若靠前导字节区分二者,攻击者可磨削出哈希以0xef0100开头的代码,使合约被误读为委托。把代码前缀放入账户头可避开此检查,但那会使前缀按地址键控——模板代码每部署一份就存一份拷贝,正是该 zone 存在的意义所在;该推理不适用于 23 字节的委托指示符,它本就要替换账户持有的 code hash 叶子。
4.4 SNARK 友好性与后量子安全
设计避免了 RLP 与 MPT 的可变分支。但主导因素是Merkle 化哈希,它应在电路内外都高效。选择仍开放,候选如下:
- BLAKE3:原生性能好、电路内合理、研究充分,当前参考实现使用;
- Keccak:以太坊已有、研究充分,但证明效率较低;
- Poseidon2:电路内性能强,安全性分析正通过以太坊基金会(EF)密码学计划进行,需要额外的域编码规范。
因为树只依赖哈希函数而非椭圆曲线,它对量子对手仍然安全;Verkle 基于曲线的栈则不,且 NIST 指南要求在 2030 年前淘汰椭圆曲线密码学。证明系统方面的进展表明前后状态证明可以足够快生成,追平 Verkle 的主要优势。
4.5 二分支(Arity-2)
二叉树最小化见证大小。在含N个元素、每节点k个子节点的树中,平均分支约为32 * (k-1) * log(N) / log(k)字节,在k = 2时最小。对N = 2**24:
k | Branch length (chunks) | Branch length (bytes) |
|---|---|---|
| 2 | 24 | 768 |
| 4 | 36 | 1152 |
| 8 | 56 | 1792 |
| 16 | 90 | 2880 |
4.6 树深度
设计避免全深度稀疏 Merkle 树(SMT),以降低证明系统中的哈希负载——这目前是普通硬件上的吞吐瓶颈。
BranchNode的prefix存在是因为存储桶制造出长共享位串:账户的每个溢出存储组共享同一个key_hash(address),最长 256 位。若不压缩,会产生一串只有单个占用子节点的长分支链。把共享位串折叠进分支前缀(见 3.2),每条链都塌缩为一个节点——这也界定了 4.6 中磨削一组存储组的证明大小代价。
4.7 合并零与缺失(Collapsing zero and absent)
合并零与缺失使根只取决于状态本身,而非产生它的写入序列——与 MPT 相同。客户端的扁平状态可以继续把零表示为记录缺席;从状态导出重建的树可复现根;测试夹具可表达任意前状态;从 MPT 转换来的树(MPT 不记录分叉前清空的槽)与跨分叉增量维护的树一致。零在证明层也有单一表示。
该规则位于状态转移函数中,使树保持通用——这正是 MPT 的划分:trie 定义在任意键值对上、无特殊值,存储 trie 只含非零槽是状态如何映射进它的属性,而非 trie 本身的属性。分层分离意味着树可以在不携带属于 EVM 值语义的规则的情况下被规范、测试与复用;客户端可自由地把零写入拼写为显式删除或由自己的树层折叠,因为只有最终的条目集合被承诺。零是触发条件,因为 EVM 没有其他"未设置"的拼写:存储槽是 256 位字,在写入前读回为零,SSTORE零正是合约清槽的方式。
在此规则下账户无需存在标记,因此version保持为零,与 EIP-7864 一致。唯一BASIC_DATA全零的账户是 EIP-161 的空账户(零 nonce、零余额、无代码),EVM 无法将其与不存在的账户区分,状态清理在触碰它时将其删除。
规则同样适用于代码块——这是本树中零编码带有含义(而非"未设置"的自然拼写)的唯一值(见 3.10)。豁免它们会让规则依赖键而非值;即使把所有代码放在CODE_ZONE、让豁免落在 zone 边界而非子索引区间,也买不到什么:code_size已经界定代码,缺席块读回为它本会持有的零。豁免也不能让状态更小,因为存储的叶子内容已由code_size隐含。
另一种方案——零值叶子留在树中、与缺席键相区别——是多树状态过期设计所需的,其中"缺席"意味着对象的最新版本可能在更旧的树中。本树的过期改为在过期分叉引入的桩(stub)标记过期区域(见 4.8)。
代价是增量维护树的客户端需要删除逻辑,以及清空后重写的槽要重付状态创建 gas。删除所需的合并被限制在一层,且是插入时拆分的逆操作(见 3.4)。gas 不对称性是定价问题,更适合在 gas 计划中回答,而非为每个曾创建的槽持有约一百字节的共识状态。
4.8 状态过期(State expiry)
按账户、按桶的过期是 zone 拓扑上的自然操作。以key_hash(address)键控的存储桶在常见情况下根植于一个账户的存储:记录其哈希并剪除其下内容。账户 header stem 一步过期账户的核心数据、热存储与委托。内容寻址代码需要引用计数或推迟到状态清扫(因为其叶子可能被共享,清扫没有交易可依据)。复活(resurrection)重新挂接与所记录承诺一致的子树。机制本身留给独立 EIP。
五、向后兼容性(Backwards Compatibility)
主要破坏性变更:树结构变化会破坏链上(in-EVM)对 MPT 状态证明的验证。分叉后的状态根承诺新树,因此验证这些根的合约必须采用新树的证明格式。
- 本 EIP不改变 gas 计划。
- 对 EVM不可见:合约通过
SLOAD/SSTORE按 256 位槽号寻址存储,永远看不到树键。键派生发生在客户端内部、EVM 之下,正如 MPT 已对槽键与地址做哈希一样。无需修改任何合约、Solidity 或 Yul 代码。 EXTCODEHASH不受影响:账户的 code hash 无论树的 Merkle 化哈希如何,都是 Keccak 哈希,存于code_hash叶子中;对委托账户,则由委托叶子计算得出。
六、测试向量(Test Cases)
哈希函数未定,摘要无法固定。下面给出派生过程中确定性部分的向量。H(x)是x的完整 32 字节摘要。
账户头,地址A的BASIC_DATA:
key = 0x00 || H(A) || 0x00 length = 1 + 32 + 1 = 34 bytes地址A对目标T的委托:
sub_idx = DELEGATION_LEAF_KEY = 2 (0x02) key = 0x00 || H(A) || 0x02 length = 34 bytes value = 0xef0100 || T || 0x00 * 9地址A的存储槽storage_key = 5(在 header 中,因 5 < 64):
sub_idx = HEADER_STORAGE_OFFSET + 5 = 69 (0x45) key = 0x00 || H(A) || 0x45 length = 34 bytes存储槽storage_key = 1000(在存储 zone,因 1000 >= 64):
tree_index = 1000 // 256 = 3 sub_idx = 1000 % 256 = 232 (0xE8) key = 0xFF || H(A) || H(A || 3) || 0xE8 length = 1 + 32 + 32 + 1 = 66 bytes哈希为C的字节码的代码块chunk_id = 5:
tree_index = 5 // 256 = 0 sub_idx = 5 % 256 = 5 (0x05) key = 0x01 || H(C || 0) || 0x05 length = 34 bytes同一字节码的代码块chunk_id = 300:
tree_index = 300 // 256 = 1 sub_idx = 300 % 256 = 44 (0x2C) key = 0x01 || H(C || 1) || 0x2C length = 34 bytes其中A || 3与C || 1表示A(分别地C)与整数的 32 字节大端编码拼接。
七、安全考虑(Security Considerations)
碰撞意味着两个不同条目派生出同一个键。键包含三个哈希派生组件:
key_hash(address):既作账户 stem 又作存储桶;key_hash(address || tree_index):存储后缀;key_hash(code_hash || tree_index):代码 stem。
每个都是完整 256 位摘要,任何碰撞约需2^128次生日攻击工作量,远超现实可达。不同 zone 的键首字节不同,完全不可能碰撞。
- 内容寻址代码:字节码相同的两个合约共享代码区叶子是设计内的去重,不是碰撞。两个不同字节码映射到同一 stem 需要 256 位碰撞——发生在 Keccak 的
code_hash或key_hash(code_hash || tree_index)上,均不可行。 - 子索引:子索引是直接映射而非哈希:header 中为
HEADER_STORAGE_OFFSET + storage_key,存储 zone 为storage_key % STEM_SUBTREE_WIDTH,代码 zone 为chunk_id % STEM_SUBTREE_WIDTH。两个不同键共享子索引当且仅当它们也共享 stem,而共享 stem 意味着它们是同一条目,故不同条目间不可能碰撞。 - 磨削(Grinding):
key_hash(address || tree_index)把存储组放进其账户的桶,tree_index来自槽号。攻击者可自由选择槽:直接在自己的合约中,或通过任何把映射键哈希成槽的合约。为共享k个前导位的摘要磨削会加深树:无压缩时约2^(k/2)工作量买到k节点链。压缩把位串折叠进一个约k/8字节的BranchNode前缀(见 4.6),而d个真实额外节点约需2^d工作量。摘要中的地址阻止跨合约复用,因此为一个合约磨削出的槽集合在其余任何合约中都是随机的。 - 原像(Preimage):每个节点哈希的原像以单字节标签(
LEAF_TAG或BRANCH_TAG)开头以区分两类节点,BranchNode前缀带显式比特计数。这使逻辑节点到原像的映射是单射的:任何叶子与分支原像都不能重合,任何不同比特长度的前缀都不会打包成相同字节。
八、仓库内相关资源
- 本 EIP 的树结构示意图:assets/eip-8297/diagram.png
- 离线状态迁移到 PBT 的完整流程(锚定块、快照、双校验、shadow-commitment):EIPS/eip-8347.md
- 本 EIP 的直接前身"统一二叉树":EIPS/eip-7864.md
- 委托指示符语义(本 EIP 的 Delegation 依赖):EIPS/eip-7702.md
- 合约创建前检查非空存储(本 EIP 存储判定替代的对象):EIPS/eip-7610.md
- 64 KiB 代码大小上限(动机计算中的常数):EIPS/eip-7954.md
- 冷/热访问成本与访问列表地址成本(动机计算中的 gas 常数):EIPS/eip-2929.md、EIPS/eip-2930.md
- 状态清理与
SELFDESTRUCT限制: EIPS/eip-161.md、EIPS/eip-6780.md - 代码前缀
0xef禁部署规则:EIPS/eip-3541.md
注:本 EIP 目前处于 Draft 状态,其中哈希函数(BLAKE3/Keccak/Poseidon2)与迁移参数(如 EIPS/eip-8347.md 中的
ANCHOR_BLOCK、PBT_ACTIVATION_FORK)尚未定稿,本文所有细节均以仓库中当前草案为准。
【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考