Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
RPC 故障排查阅读约 14 分钟

eth_getTransactionReceipt 返回 null:收据轮询模式

一份生产级指南,讲解如何解读 null 收据、对交易状态进行分类,并构建一个能正确终止的有界收据轮询器。

TL;DR

只要某个交易哈希没有可用的收据,eth_getTransactionReceipt 就会返回 null,这包括待处理、未知以及被丢弃或被替换等状态。以太坊 JSON-RPC 规范规定,收据只有在交易被打包进区块后才存在,因此 null 响应并不是错误,而且如果不进行第二次查询,就无法将其与节点滞后区分开来。正确的轮询器会同时请求 eth_getTransactionByHash 和 eth_getTransactionReceipt,对这对结果进行分类,并在有界间隔、最大经过时间以及基于交易 nonce 推导出的区块截止时间上终止。本文涵盖四种交易状态、收据 status 字段的权威性、重组处理,以及一个可运行的 Node.js 轮询器,并附有结果表,便于与你自己的端点进行对比测量。

在以太坊 JSON-RPC 规范下 null 收据意味着什么

以太坊 JSON-RPC 规范将 eth_getTransactionReceipt 定义为返回某个交易哈希对应的收据对象,或在没有可用收据时返回 null。收据只有在交易被打包进区块并执行后才会创建,因此对于任何尚未达到该状态的交易,null 响应都是预期结果。这是有文档记录的协议行为,而不是提供商的缺陷,它同样适用于以太坊主网以及兼容 EVM 的链。

配套方法 eth_getTransactionByHash 返回交易对象本身。当交易仅存在于内存池中时,返回的对象其 blockNumber 为 null,并且不存在收据。JSON-RPC 2.0 规范约束请求和响应信封,因此 null 结果是一个值为 null 的成功响应,而不是 JSON-RPC 错误。将其视为错误是轮询循环失效的最常见原因。

  • 收据只有在打包并执行后才存在。
  • null 是有效且成功的响应值。
  • eth_getTransactionByHash 返回 blockNumber 为 null 的待处理对象。
  • JSON-RPC 错误与 null 结果是两种不同的情况。

四种交易状态以及用于区分它们的方法组合

客户端观察到的交易哈希可能处于四种状态之一:未知、已知且待处理、已打包并执行,或被丢弃或被替换。每种状态都会从 eth_getTransactionByHash 和 eth_getTransactionReceipt 产生不同的返回值组合。对这对结果进行分类是判断你处于哪种状态的唯一可靠方式,因为仅凭 null 收据是有歧义的。

在未知状态下,两个方法都返回 null。在待处理状态下,eth_getTransactionByHash 返回 blockNumber 为 null 的交易对象,而收据仍为 null。在已打包并执行状态下,两个方法都返回对象,且收据带有 status 字段。在被丢弃或被替换状态下,交易对象可能完全消失,而收据永远保持 null。正是最后这种状态导致朴素的轮询循环无限运行。

  • 未知哈希:两个方法都返回 null。
  • 已知且待处理:交易对象 blockNumber 为 null,收据为 null。
  • 已打包并执行:两个对象都存在,收据带有 status。
  • 被丢弃或被替换:交易对象可能消失,收据保持 null。

为什么收据才是成功与否的权威依据,而不是交易对象

eth_getTransactionByHash 返回的交易对象并不能告诉你执行是否成功。status 字段位于收据中,其中 0x1 表示成功,0x0 表示回滚。已打包但 status 为 0x0 的交易仍然消耗 gas,因此收据的存在意味着交易被打包,而不是交易成功。将收据存在视为成功的应用会静默接受失败的交易。

这一区别对于任何根据交易结果采取行动的集成都很重要。收据还带有 gasUsed、logs 和有效 gas 价格,这些是记账和事件处理所需的字段。若要在整个区块范围内批量获取,eth_getBlockReceipts:一次调用批量获取收据可在单个请求中返回相同的收据对象。

  • status 0x1 表示成功;status 0x0 表示回滚。
  • 回滚的交易仍然消耗 gas。
  • 收据存在意味着被打包,而不是成功。
  • 收据带有 gasUsed、logs 和有效 gas 价格。

为什么不做第二次查询就无法将 null 与滞后节点区分开来

null 收据可能意味着交易确实处于待处理状态,也可能意味着你查询的节点尚未看到包含该交易的区块。单次 eth_getTransactionReceipt 调用无法区分这两种情况。以太坊 JSON-RPC 规范并不要求节点在处理包含该交易的区块之前返回收据,而不同提供商在节点跟随链头的速度上表现各异。

正确的做法是同时查询两个方法并对结果进行分类。如果 eth_getTransactionByHash 返回的交易对象其 blockNumber 非 null,而收据为 null,则说明节点已看到打包但尚未生成收据,这是滞后情况而非待处理交易。如果两者都为 null,则该哈希要么未知,要么交易已被丢弃。这种基于成对结果的分类是正确轮询器的基础。

  • 单次收据查询无法区分待处理与滞后。
  • 同时查询 eth_getTransactionByHash 和 eth_getTransactionReceipt。
  • blockNumber 非 null 而收据为 null 表示节点滞后。
  • 两者都为 null 表示未知或被丢弃。

安全轮询循环的超时与预算设计

轮询循环需要三个独立的边界才能正确终止:有界的轮询间隔、最大经过时间以及基于区块的截止时间。轮询间隔可防止请求洪泛,应根据目标链的预期出块时间来选择。最大经过时间限制总的墙钟预算,使调用方不会无限期挂起。基于区块的截止时间是最可靠的信号,因为它源自链上状态而非本地时间。

基于区块的截止时间使用交易 nonce。使用 latest 区块标签查询 eth_getTransactionCount,以查看 nonce 是否已被消耗。如果当前链头已超过 nonce 被消耗所在区块的可配置区块数,且不存在收据,则交易很可能已被丢弃或被替换。该模式与使用 eth_getTransactionCount 进行 EVM nonce 管理密切相关,后者详细介绍了 nonce 缺口和替换。

  • 有界轮询间隔与预期出块时间匹配。
  • 最大经过时间作为墙钟预算。
  • 基于区块的截止时间源自 nonce 消耗。
  • 通过 eth_getTransactionCount('latest') 探测 nonce。

用 nonce 探测揭示被丢弃或被替换的情况

当 eth_getTransactionByHash 返回 null 且收据也为 null 时,交易可能已从内存池中被丢弃,或被另一个具有相同 nonce 的交易替换。nonce 探测可以区分这些情况。如果 eth_getTransactionCount('latest') 显示 nonce 已被消耗,但原始哈希没有收据,则很可能有替换交易取代了它。替换交易定价过低错误是替换尝试被拒绝的常见信号。

如果 nonce 未被消耗且链头已远超预期的打包窗口,则交易很可能因费用条件或内存池驱逐而被丢弃。在这两种情况下,轮询器都应停止并报告最终分类,而不是继续轮询。继续轮询已被丢弃的交易正是生产环境中产生无限循环的失效模式。

  • nonce 已消耗但无收据,提示发生了替换。
  • nonce 未消耗且链头已推进,提示被丢弃。
  • 这两种情况对轮询器而言都是终态。
  • 报告分类结果,而不是永远轮询下去。

受重组影响的收据与未知收据

收据可能在链重组后消失。已打包进某个区块的交易可能被移动到另一个区块,或被退回内存池,而原区块的收据将不再可用。生产级轮询器应将收据消失视为需要针对更深区块标签重新验证的信号,而不是永久性失败。以太坊 JSON-RPC 规范允许使用 finalized 和 safe 等区块标签,但提供商对这些标签的支持各不相同。

实际做法是记录收据中的区块号,并且对于高价值操作,在确认深度过后重新查询收据。如果收据仍在同一区块号处存在,则打包是稳定的。如果它已移动,轮询器应更新其记录。这与逐块 EVM 索引器对账中使用的对账原则相同。

  • 收据可能在重组后消失。
  • 针对更深的区块标签重新验证。
  • 记录收据区块号以进行稳定性检查。
  • 对账逻辑属于索引器和高价值流程。

可运行的 Node.js 收据轮询器,带每次尝试分类

以下 Node.js 轮询器在每次尝试时查询两个方法,打印分类结果,并在收到收据、被丢弃、被替换或预算耗尽时终止。它使用有界间隔、最大经过时间以及基于区块的截止时间。将端点 URL 替换为你自己的 RPC 端点,并针对一笔测试交易运行它。

该轮询器有意做到无依赖,因此可以放入任何 Node.js 项目。它使用 Node.js 18 及更高版本中可用的全局 fetch API。分类函数是核心逻辑,可以在更大的服务中复用。

const RPC_URL = 'https://your-endpoint.example';
const TX_HASH = '0x...';
const POLL_INTERVAL_MS = 4000;
const MAX_ELAPSED_MS = 180000;
const MAX_BLOCKS_PAST_NONCE = 20;

async function rpc(method, params) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  const json = await res.json();
  if (json.error) throw new Error(json.error.message);
  return json.result;
}

function hexToInt(hex) {
  return hex === null || hex === undefined ? null : parseInt(hex, 16);
}

async function classify(txHash) {
  const [tx, receipt, headHex, nonceHex] = await Promise.all([
    rpc('eth_getTransactionByHash', [txHash]),
    rpc('eth_getTransactionReceipt', [txHash]),
    rpc('eth_blockNumber', []),
    rpc('eth_getTransactionCount', ['latest'])
  ]);
  const head = hexToInt(headHex);
  const nonce = hexToInt(nonceHex);
  if (receipt) {
    return { state: 'included', receipt, head, nonce };
  }
  if (tx && tx.blockNumber !== null) {
    return { state: 'lagging', tx, head, nonce };
  }
  if (tx && tx.blockNumber === null) {
    return { state: 'pending', tx, head, nonce };
  }
  return { state: 'unknown-or-dropped', head, nonce };
}

async function poll(txHash) {
  const start = Date.now();
  let lastNonceBlock = null;
  while (Date.now() - start < MAX_ELAPSED_MS) {
    const result = await classify(txHash);
    console.log(new Date().toISOString(), result.state, 'head=' + result.head, 'nonce=' + result.nonce);
    if (result.state === 'included') {
      return result.receipt;
    }
    if (result.state === 'unknown-or-dropped') {
      if (lastNonceBlock !== null && result.head - lastNonceBlock > MAX_BLOCKS_PAST_NONCE) {
        console.log('terminal: dropped or replaced');
        return null;
      }
      lastNonceBlock = result.head;
    }
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
  }
  console.log('terminal: budget exhausted');
  return null;
}

poll(TX_HASH).then((receipt) => {
  if (receipt) {
    console.log('status', receipt.status, 'block', receipt.blockNumber);
  }
});

用于与你自己的端点进行对比测量的结果表

由于延迟和打包时间因链、提供商和网络状况而异,唯一有意义的测量是针对你自己的端点进行的测量。针对一笔已知交易运行上面的轮询器,并记录从首次轮询到收到收据的经过时间、尝试次数以及分类序列。用你自己的观察结果填写下表。

在未控制出块时间和网络负载的情况下,不要跨提供商比较这些数字。该表的目的是为你自己的集成建立基线,以便在更改端点或轮询参数时检测回归。

  • 尝试次数:轮询迭代的顺序计数。
  • 经过毫秒数:自首次轮询以来的毫秒数。
  • 分类:pending、lagging、included 或 unknown-or-dropped。
  • 链头区块:该次尝试时的 eth_blockNumber。
  • Nonce:该次尝试时的 eth_getTransactionCount('latest')。

与订阅和批量收据获取的权衡

轮询并非唯一选择。WebSocket 订阅可以以更低延迟推送新的链头或日志,但它们需要持久连接,且提供商支持情况各不相同。对于处理整个区块的索引器,eth_getBlockReceipts:一次调用批量获取收据可在单个请求中获取区块内的所有收据,这比按交易轮询高效得多。

当客户端生命周期较短、环境不支持 WebSocket,或跟踪的交易数量较少时,轮询仍是正确选择。RPC 端点指南(RPC Assistant)介绍了端点选择和传输选项。有关定价和吞吐量规划,请参阅 RPC 定价

  • 订阅延迟更低,但需要持久连接。
  • eth_getBlockReceipts 对整块索引器更高效。
  • 轮询适合短生命周期客户端和小规模交易集。
  • 传输和端点选择会影响可靠性。

排查持续返回 null 的收据

如果收据在远超预期打包窗口后仍为 null,请先检查交易对象。blockNumber 非 null 而收据为 null 指向节点滞后,因此应针对其他端点重试或等待节点跟上。交易对象为 null 且 nonce 未被消耗指向被丢弃,必须使用更新后的费用重新提交交易。

如果 nonce 已被消耗但原始哈希没有收据,则很可能有替换交易使用了相同的 nonce。查询替换哈希,或检查 nonce 消耗点处的区块。有关替换和 nonce 缺口的更深入讨论,请参阅替换交易定价过低使用 eth_getTransactionCount 进行 EVM nonce 管理

  • blockNumber 非 null 而收据为 null:节点滞后,换端点重试。
  • 交易对象为 null 且 nonce 未消耗:被丢弃,重新提交。
  • nonce 已消耗但无收据:检查是否有替换。
  • 重组:针对更深的区块标签重新验证。

生产环境收据处理的后续步骤

从单个轮询器转向一个小型状态机,随时间记录每笔交易的分类。持久化收据区块号和 status,以便下游消费者无需重新查询即可根据成功或回滚采取行动。在将收据视为最终结果之前增加确认深度,并在重组窗口后重新验证。

有关更广泛的集成模式,请从 OnFinality Learn 中心开始,并查阅 API 服务文档以了解端点行为。RPC 端点指南(RPC Assistant)说明了如何为生产工作负载选择和配置端点。

  • 持久化分类结果和收据区块号。
  • 在最终确认前增加确认深度。
  • 在重组窗口后重新验证。
  • 为你的链选择行为有文档记录的端点。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始