EIP-7736 详解:基于 Verkle 树的叶子级状态过期(Leaf-Level State Expiry)方案
2026/9/15 22:28:03 网站建设 项目流程

EIP-7736 详解:基于 Verkle 树的叶子级状态过期(Leaf-Level State Expiry)方案

【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs

导读

EIP-7736(Leaf-level state expiry in verkle trees)提出了一种简单、非穷尽式的以太坊状态过期方案:不再像既往提案那样引入地址空间扩展(ASE)、每 epoch 独立多棵树等重型结构改造,而是只对 Verkle 树中"扩展与后缀树"(extension-and-suffix tree)这一叶子级单位进行过期与复活。本文以 EIPS/eip-7736.md 为骨架,结合 EIPS/eip-6800.md(Verkle 树统一状态树)与 EIPS/eip-4762.md(无状态化 gas 成本改革)展开讲解。读完本文,你将掌握:该方案如何通过last_epoch字段标记冷数据、如何用check_epoch_end触发整棵子树的过期、如何用复活交易(resurrection transaction)按 EIP-4762 的 witness 成本定价并恢复被删除的数据,以及它相比既有状态过期提案的取舍与向后兼容性考量。

注意:本 EIP 当前状态为Stagnant(停滞),FORK_TIMERESURRECT_TX_TYPE均为 TBD,测试用例与参考实现部分标注 TODO。本文以仓库中该文档的既有内容为准进行解读。


背景:为什么需要叶子级状态过期

状态无限增长的痛点

以太坊全节点需要永久保存所有历史状态(账户、存储、合约代码)。随着使用量增长,状态膨胀成为长期问题,学界与社区提出了多种"状态过期"(state expiry)设想。EIP-7736 在 Motivation 中明确指出:既往实现状态过期的尝试因复杂度迅速攀升而停滞——它们要求对以太坊结构做出重大改动(地址空间扩展 address space extension、oil、多棵树 multiple trees 等)。

EIP-7736 的作者(Guillaume Ballet、Wei Han Ng)主张一种更简单但非穷尽式的路径:

只删除叶子节点,让树的其余部分保持完整。

这样做可以去掉那些对用户与开发者体验有害的方法。其核心收益是"简单":不要求 ASE、不要求每 epoch 建多棵树、复活证明更小、gas 成本清晰、只过期"冷"数据而让"热"数据集保持活跃,同时保持前向兼容(未来仍可叠加 ASE 或多棵树方案)。

与 EIP-6800 Verkle 树的承接关系

该方案建立在 EIP-6800 定义的 Verkle 状态树之上。EIP-6800 用统一的 Verkle 树替代 MPT,将账户头部、合约代码、存储槽全部嵌入一棵key: value树中,核心单元正是extension-and-suffix tree

def extension_and_suffix_tree(stem: bytes31, values: Dict[byte, bytes32]) -> int: sub_leaves = [0] * 512 for suffix, value in values.items(): sub_leaves[2 * suffix] = int.from_bytes(value[:16], 'little') + 2**128 sub_leaves[2 * suffix + 1] = int.from_bytes(value[16:], 'little') C1 = compute_commitment_root(sub_leaves[:256]) C2 = compute_commitment_root(sub_leaves[256:]) return compute_commitment_root([1, # Extension marker int.from_bytes(stem, "little"), group_to_scalar_field(C1), group_to_scalar_field(C2)] + [0] * 252)

在 EIP-6800 中,256 个共享同一stem前缀的叶子构成一棵后缀树(suffix tree),后缀树的承诺(C1C2)连同stem一起被打包进扩展节点(extension node)的承诺中。EIP-7736 正是以"扩展节点 + 其后缀节点"为最小过期单元——这是理解全文的关键前提。EIP-6800 文档中附带的结构示意图 assets/eip-6800/tree_structure.png 直观展示了从 Root → Stem Tree → Extension Tree → Suffix Tree → Data 的嵌套关系:


规范总览与常量定义

EIP-7736 的规范可归纳为三大部分:

  1. 树结构变更:给扩展节点新增last_epoch字段,并引入全局current_epoch计数器;
  2. 过期机制:在每个区块处理开始时检查是否到 epoch 边界,到期则调度删除"扩展与后缀树";
  3. 复活机制:新增一种交易类型,携带简单的 Verkle 证明,付费后重建被删除的扩展与后缀树并更新 epoch 计数。

规范遵循 RFC 2119 / RFC 8174 的 MUST / MUST NOT / REQUIRED / SHALL / SHALL NOT / SHOULD / SHOULD NOT / RECOMMENDED / NOT RECOMMENDED / MAY / OPTIONAL 语义。

常量表

名称描述
FORK_TIME分叉激活时间TBD
EPOCH_LENGTH一个 epoch 的持续时间(秒)15778800(6 个月)
INITIAL_EPOCH_COUNTERFORK_TIME时间戳处结束的那个 epoch0
NUM_ACTIVE_EPOCHS同时未过期的 epoch 数量2
RESURRECT_TX_TYPE复活交易的类型 IDTBD

其中EPOCH_LENGTH = 15778800秒即 6 个月(365.25 天 / 2),NUM_ACTIVE_EPOCHS = 2意味着当前 epoch 与上一个 epoch 的数据保持活跃,只有更早的 epoch 数据会被过期。


树结构变更:给扩展节点加上last_epoch

全局计数器current_epoch

在分叉前初始化一个整型变量current_epoch = INITIAL_EPOCH_COUNTER(即 0),分叉后它保存当前 epoch 编号。分叉激活时刻(FORK_TIME)恰好是 epoch 0 的结束点,之后每经过EPOCH_LENGTH秒递增一次。

扩展节点新增last_epoch字段

扩展节点的承诺计算被修改,在原有[1, stem, C1, C2]之后追加一个last_epoch求值点(原先该位置填0,共 252 个零,现在改为 251 个零):

def extension_and_suffix_tree(stem: bytes31, values: Dict[byte, bytes32], last_epoch: int) -> int: sub_leaves = [0] * 512 for suffix, value in values.items(): sub_leaves[2 * suffix] = int.from_bytes(value[:16], 'little') + 2**128 sub_leaves[2 * suffix + 1] = int.from_bytes(value[16:], 'little') C1 = compute_commitment_root(sub_leaves[:256]) C2 = compute_commitment_root(sub_leaves[256:]) return compute_commitment_root([1, # Extension marker int.from_bytes(stem, "little"), group_to_scalar_field(C1), group_to_scalar_field(C2), last_epoch] + # Added in this EIP [0] * 251)

对比 EIP-6800 中的原始版本(末尾为[0] * 252),EIP-7736 将第 4 个(下标从 0 起)求值点从0改为承载last_epoch。这正是向后兼容性的来源——详见"向后兼容性"一节。

读写操作的规则

对树的每次读或写事件,都必须先检查:

current_epoch < last_epoch + NUM_ACTIVE_EPOCHS
  • 若成立,则正常执行读/写;
  • 否则revert(回滚)。

同时,每当该扩展节点处理一次写事件,就用current_epoch刷新last_epoch。换句话说:last_epoch记录的是该节点"最后一次被写入"所在的那个 epoch。

为什么只有写才刷新last_epoch

Rationale 一节专门解释了这一设计取舍:任何对last_epoch的更新在效果上都等价于一次写操作。如果读也刷新last_epoch,则要么:

  • 把读的成本抬高到写成本(进一步推高 EIP-4762 已经引入的 gas 成本);
  • 或者实际上"以读的价格执行写",这既会削弱状态过期的效果,还可能引入DoS 攻击向量(攻击者可以廉价地持续刷新大量冷数据的 epoch 计数,阻止它们过期)。

因此,只有写事件刷新计数器,读保持廉价。


过期机制:check_epoch_end与 keepsake

区块级检查

在区块处理开始时、执行交易之前,运行check_epoch_end

def check_epoch_end(block): if block.timestamp >= FORK_TIME + current_epoch * EPOCH_LENGTH: current_epoch = current_epoch + 1 schedule_expiry(current_epoch-NUM_ACTIVE_EPOCHS)

当区块时间戳越过当前 epoch 的终点(FORK_TIME + current_epoch * EPOCH_LENGTH)时:

  1. current_epoch递增;
  2. 调用schedule_expiry(current_epoch - NUM_ACTIVE_EPOCHS)调度"两个 epoch 之前"的那个 epoch 的数据过期。

schedule_expiry的具体行为留给客户端实现者决定(例如:懒删除、后台批量删除、与数据库压缩结合等)。规范对此不做强制要求。

需要保留的数据:keepsake

为了让过期后的子树能够被可靠地重建(尤其要为兄弟节点提供插入锚点),每个被删除的扩展与后缀节点必须保留两份数据,合称为该节点的keepsake

  • stem:用于定位树中的位置,以便插入兄弟节点;
  • 节点承诺C:用于校验。

重要注记:实际的删除动作不应早于该 epoch 第一个区块被 finalize(最终确认),除非客户端有办法在重组(reorg)时恢复区块。换言之,删除必须等到最终确定性保障之后才能物理执行,否则重组可能让已删除的数据无法恢复。

过期粒度:删除"大部分"而非"全部"

EIP-7736 删除的是值与子承诺(subcommitments),也就是扩展与后缀树内部的叶子数据与两层后缀承诺C1C2,同时保留stem与节点承诺C以便轻松插入兄弟节点。Rationale 强调:

虽然没有删除_所有_数据,但它删除了_大部分_数据——即 values 与 subcommitments,同时保留了轻松插入兄弟节点的能力。

这也解释了为什么该方案比"复活单个叶子"更贵——那是为换取简化所付出的代价。


复活机制:复活交易(Resurrection Transaction)

交易格式

复活交易定义为:

RESURRECT_TX_TYPE|ssz(Vector[stem,last_epoch,values])

即交易 payload 是 SSZ 序列化的三元组列表,其中:

  • stem:用于在树中定位,以便重建节点;
  • last_epochvalues:正是当初被删除的条目(keepsake 之外被删掉的部分)。

Gas 定价:基于 EIP-4762 的 witness 成本

在验证开始前,先按 EIP-4762 定义的常量收费:

def resurrect_gas_cost(values) -> int: return WITNESS_BRANCH_COST + SUBTREE_EDIT_COST + sum(WITNESS_CHUNK_COST + CHUNK_EDIT_COST + CHUNK_FILL_COST for i in values)

EIP-4762 中定义的相关常量及其值(见 EIPS/eip-4762.md):

常量
WITNESS_BRANCH_COST1900
WITNESS_CHUNK_COST200
SUBTREE_EDIT_COST3000
CHUNK_EDIT_COST500
CHUNK_FILL_COST6200

定价逻辑的含义:复活一棵子树 = 读取一条 witness 分支(WITNESS_BRANCH_COST)+ 编辑一棵子树(SUBTREE_EDIT_COST)+ 对每个被恢复的叶子分别收取 chunk 见证(WITNESS_CHUNK_COST)、chunk 编辑(CHUNK_EDIT_COST)与填充成本(CHUNK_FILL_COST)。这与 EIP-4762 的 witness/访问事件定价模型保持一致:所有"重新填充"的数据都要按其真实见证与编辑成本付费,杜绝廉价复活导致的滥用。

验证流程

付费完成后进入验证。顶层函数遍历交易中的所有子树:

def validate_subtrees(tree, tx, current_epoch) -> bool: # The tx is a SSZ payload subtrees = deserialize_ssz(tx[1:]) if subtrees == None: return false # Process all subtrees in the transaction for subtree in subtrees: ok = validate_subtree(tree, subtree.stem, subtree.values, subtree.last_epoch, current_epoch) if not ok: return false return true

其中tx[1:]去掉类型字节后反序列化出 SSZ payload(tx首个字节即RESURRECT_TX_TYPE)。对每棵子树,validate_subtree完成两步核心校验:

def validate_subtree(tree, stem, values, last_epoch, current_epoch) -> bool: # Compute the commitment to the expired # tree, get the expired_C = extension_and_suffix_tree(stem, values, last_epoch) expired = tree.get_keepsake(stem) if keepsake.C != expired_C: return false # Replace the keepsake with the resurrected # extension-and-suffix tree. new_C = extension_and_suffix_tree(stem, values, current_epoch) return tree.resurrect_subtree(stem, new_C, values, current_epoch) == None

验证逻辑要点:

  1. 用交易提供的(stem, values, last_epoch)重新计算过期子树的承诺expired_C
  2. 从树中取出该stem的 keepsake,比对keepsake.C != expired_C——承诺不匹配则直接拒绝,这保证了复活者必须精确提供当初被删除的数据,无法伪造或篡改;
  3. 通过校验后,用current_epoch作为新的last_epoch重新计算承诺new_C,调用tree.resurrect_subtree(stem, new_C, values, current_epoch)将子树写回树中;
  4. resurrect_subtree成功时返回None,失败则返回错误(此时验证返回false)。

注意第 3 步中new_C使用了current_epoch而非旧的last_epoch——复活本身是一次写事件,因此新节点立即带着当前 epoch 标记重新进入"活跃"生命周期,再经过NUM_ACTIVE_EPOCHS个 epoch 后才会再次过期。

复活证明更小的原因

Rationale 指出,相比其他状态过期提案,本方案的复活证明更小:只需要提供数据本身即可复活values+last_epoch),无需提供大量 Merkle/Verkle 路径证明——因为 keepsake 中保留的节点承诺C直接充当了完整性锚点,验证者只需重算承诺并与 keepsake 比对。


Rationale:设计取舍一览

EIP-7736 相对既往状态过期提案的核心优势(原文 Rationale 总结):

  • 无需 Address Space Extension(ASE):不改变地址语义;
  • 只使用一棵树:不需要每 epoch 建多棵独立树;
  • 复活证明更小:仅提供数据即可复活;
  • gas 成本清晰:定价直接复用 EIP-4762 的 witness 成本常量;
  • 只过期"冷"数据:热数据集保持活跃,对用户体验冲击小;
  • 前向兼容:未来仍可叠加 ASE 或多棵树方案;
  • epoch 幂运算/加法成本摊薄current_epoch相关的指数/加法计算每个 epoch 只需支付一次,很快被摊薄。

同时它也坦诚代价:比复活单个叶子更贵(换取简化);并非删除全部数据,而是删除"大部分"(values 与子承诺)。


向后兼容性

EIP-7736 对 Verkle 树是向后兼容的。关键依据(见 EIPS/eip-7736.md):

默认情况下,EIP-6800 中第 4 个(下标从 0 起)求值点的值被设为0,而这正是INITIAL_EPOCH_COUNTER的值。

也就是说:在分叉前,所有既存扩展节点第 4 个求值点恰好等于INITIAL_EPOCH_COUNTER = 0,因此分叉后这些节点的承诺无需任何改写即与新的extension_and_suffix_tree(stem, values, last_epoch=0)定义一致。既存 Verkle 树可以直接无缝接入本方案。


测试用例与参考实现现状

EIP-7736 文档中:

  • Test Cases:标注为TODO,尚无正式测试向量;
  • Reference Implementation:标注为TODO,尚无参考实现。

这两部分有待社区后续补充。作为背景参照,其依赖的 EIP-6800 在文档中列出了参考实现分支(geth 的beverly-hills-just-after-pbss分支、Nethermind 的verkle/tree分支),但 EIP-7736 本身尚未有对应实现落地。


安全考量与开放性讨论

文档的 Security Considerations 一节目前仅有一句:

Needs discussion.

结合全文可以归纳出需要继续讨论的安全与工程问题:

  1. 删除时机的最终确定性:规范明确"实际删除不得早于该 epoch 首个区块 finalize",否则重组会丢失数据;schedule_expiry的具体行为留给客户端实现,这本身就是需要统一的安全边界;
  2. 复活交易的 DoS 面:复活按 witness 成本全额收费,理论上限制了廉价刷复活;但复活交易的大批量(Vector[...])语义、以及复活后的数据再次过期与再复活的循环,仍需评估其 gas 上限与区块级 DoS 风险;
  3. keepsake 的存储开销:每个过期子树保留stem+ 承诺C,累积的 keepsake 集本身会增长,如何压缩、何时清理是工程问题;
  4. 读/写检查对共识实现的要求current_epoch < last_epoch + NUM_ACTIVE_EPOCHS的检查必须在所有客户端严格一致,否则会出现分叉。

小结

EIP-7736 提供了一条以"扩展与后缀树"为过期单元的低复杂度状态过期路径:给扩展节点新增last_epoch字段,用区块级check_epoch_end在 epoch 边界调度过期,通过 keepsake(stem+ 承诺C)保留重建锚点,并以一种携带 SSZ payload 的新交易类型、按 EIP-4762 witness 成本定价完成复活。它不追求删光所有数据,而是"删除大部分、保留锚点",以此换取无需 ASE、单棵树、小证明、清晰定价的简洁性,并与 EIP-6800 的 Verkle 树承诺结构天然向后兼容。对于研究状态过期、Verkle 树共识改造与无状态客户端路线图的开发者,这是一份值得反复研读的核心设计文档;其测试用例、参考实现与安全讨论仍是开放的 TODO,也是社区后续可以贡献的方向。


本文基于当前仓库中的 EIPS/eip-7736.md、EIPS/eip-6800.md、EIPS/eip-4762.md 编写,相关结构示意图见 assets/eip-6800/tree_structure.png。

【免费下载链接】EIPsThe Ethereum Improvement Proposal repository项目地址: https://gitcode.com/GitHub_Trending/ei/EIPs

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询