EIP-7939与History Expiry是以太坊长期扩容路线中两个紧密相关的技术方向。前者聚焦二进制Merkle树与无状态执行,后者的核心目标是让执行层节点不再永久存储全部历史区块数据。对于运行在浏览器里的React DApp来说,这意味着过去那种通过公共RPC节点随意回溯任意历史区块、任意历史日志的做法将不再可靠。本文围绕迁移过程中的数据获取层改造展开,分析受影响的调用场景,并给出可落地的React实现方案。

一、History Expiry到底改变了什么,哪些RPC调用会受影响
History Expiry的核心思想是:执行层节点只需要保留最近约MINDAOFORKS规定窗口内的区块数据(默认规划为数个epoch的链上数据),更早的区块体、交易收据(receipt)和日志数据可以安全删除,因为信标链的Checkpoint已经对这些历史区块头达成了最终性共识。节点删除历史数据后,账本的共识安全性不受影响,受影响的只是数据可用性——你还能不能从这台节点上查到那笔三年前的交易。
对React前端来说,受影响最直接的是这几类RPC调用:eth_getBlockByNumber携带完整交易详情请求较早区块时会返回null或抛出limit错误;eth_getTransactionReceipt查询历史交易时可能查不到收据;eth_getLogs设置过大的历史区块范围时,部分轻量节点会直接拒绝请求;eth_getStorageAt配合archive节点查询历史状态也会因为节点不再归档而失败。
需要特别澄清一个常见误区:History Expiry删除的是区块体和收据,区块头通过信标链Checkpoint的方式保留轻量的承诺,状态过期(State Expiry)则是另一个独立机制。所以你的应用不应该假设所有历史数据都消失了,而是应该假设:标准全节点只保证最近一段窗口内的数据可查,窗口之外的数据需要通过专门的归档服务、索引协议或者Portal Network等分布式检索渠道获取。
二、迁移策略:分层重构数据获取架构
迁移的第一步是对现有React应用做一次数据依赖审计。把所有链上数据请求分成三类:第一类是实时性强的当前状态读取,例如余额、当前合约状态,这类请求完全不受影响,继续走标准RPC即可。第二类是有限窗口内的近期历史,比如最近一天的交易记录,这类请求只需控制回溯范围在节点保留窗口内。第三类是无限回溯的历史查询,例如用户全部历史交易、合约全部历史事件,这类是迁移的重点,必须引入外部数据源。
对于第三类数据,目前业内主流有三种方案。第一种是使用归档节点服务,例如自建Erigon归档节点或购买第三方Archive RPC,优点是接口零改动,缺点是成本高且不符合History Expiry的去中心化方向。第二种是使用索引协议,例如The Graph的subgraph或者Hyperledger等事件索引服务,把历史日志预先索引成GraphQL可查询的数据,这是长期最推荐的方式。第三种是接入Portal Network这类分布式历史数据检索网络,它专门为History Expiry设计,节点通过内容寻址获取历史区块,但对浏览器环境来说目前集成成本还比较高。
推荐的实际落地架构是混合模式:近期数据走标准RPC,历史数据走索引服务,同时在前端做一层缓存降级。下面是React中的核心抽象,我们把数据源做成可切换的Provider,避免业务组件直接依赖某个具体RPC:
import { useEffect, useState, useCallback } from 'react';
// 判断请求的区块号是否在节点的保留窗口内
const RETENTION_WINDOW = 100000; // 示例:约保留10万个区块
export function useHistoryData(provider, indexer, targetBlock) {
const [data, setData] = useState(null);
const [source, setSource] = useState('unknown');
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
const latest = await provider.getBlockNumber();
const isRecent = latest - targetBlock < RETENTION_WINDOW;
if (isRecent) {
// 窗口内:直接走标准RPC
try {
const block = await provider.getBlock(targetBlock, true);
setData(block);
setSource('rpc');
return;
} catch (e) {
// 降级:RPC失败时回退到索引服务
}
}
// 窗口外或RPC失败:走索引服务查询历史数据
try {
const result = await indexer.queryHistory(targetBlock);
setData(result);
setSource('indexer');
} catch (e) {
setError('历史数据不可用:' + e.message);
}
}, [provider, indexer, targetBlock]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, source, error, refetch: fetchData };
}这个Hook的关键设计在于透明降级:组件层不需要关心数据到底来自RPC还是索引服务,同时通过source字段暴露数据来源,方便调试和埋点统计。实际项目中建议把RETENTION_WINDOW做成从节点动态探测的值,因为不同客户端实现的保留策略存在差异。
三、分页查询改造与用户体验处理
History Expiry之后,eth_getLogs一次性大范围扫描的用法基本要废弃。正确做法是把历史查询切成固定大小的分块,逐块请求,并且在到达保留窗口边界时切换数据源。下面是一个分页拉取历史事件的实现示例:
const CHUNK_SIZE = 2000; // 每次扫描2000个区块
async function fetchEventsChunked(contract, filter, fromBlock, toBlock, retentionEdge) {
const allEvents = [];
let cursor = fromBlock;
while (cursor <= toBlock) {
const end = Math.min(cursor + CHUNK_SIZE - 1, toBlock);
const isBeyondRetention = cursor < retentionEdge;
if (!isBeyondRetention) {
// 窗口内走RPC
const logs = await contract.queryFilter(filter, cursor, end);
allEvents.push(...logs);
} else {
// 窗口外走索引服务,这里以GraphQL风格接口为例
const indexed = await indexerClient.fetchLogs(filter, cursor, end);
allEvents.push(...indexed);
}
cursor = end + 1;
// 每块之间稍作等待,避免对公共节点造成压力
await new Promise(r => setTimeout(r, 200));
}
return allEvents;
}在用户体验层面,历史数据从链上直查变成索引服务间接查询后,必然引入索引延迟。建议在界面上明确区分两类状态:链上已确认但索引尚未同步的记录,可以用pending标记展示;完全超出查询能力的老数据,提供清晰的提示文案而不是让页面无限转圈。同时可以在前端用IndexedDB做本地持久化缓存,用户第一次查过的历史记录落盘,第二次访问直接读本地,既提升体验又减少对索引服务的依赖。
四、迁移过程中的验证与灰度发布建议
迁移完成后不要直接全量上线,建议先在测试环境模拟历史过期场景:自建一个开启了pruning的Geth或Nethermind节点,手动配置较短的区块保留窗口,然后把应用的RPC指向它,观察所有涉及历史查询的功能是否正确降级到索引服务。这个环节很容易暴露出隐藏的硬依赖,例如某些组件隐式假设了eth_getTransactionReceipt永远可用。
灰度发布阶段可以按用户比例逐步切换数据源,同时在错误监控中专门跟踪source字段的分布。如果发现大量请求从indexer回退后仍然失败,说明索引服务的覆盖范围不够,需要针对性补齐subgraph的数据源映射。另外要关注EIP-7939后续与Verkle树相关的Gas成本变化,历史日志解析逻辑中对SLOAD密集型合约的假设可能需要重新校准。
总的来说,History Expiry不是要取消历史数据,而是把历史数据的存储从每个全节点的义务,转变为可选择的、专业化的服务。React应用迁移的本质就是把数据层从单一RPC依赖,重构为实时RPC加历史索引的混合架构。尽早完成这次改造,你的应用在后续状态过期全面落地时也能从容应对。
EIP-7939History ExpiryReact DApp修改时间:2026-09-06 10:24:44