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

Monad eth_getLogs:无效区块范围限制与日志分页

为什么 Monad 对 eth_getLogs 返回“invalid block range”,其文档化的范围保护与以太坊有何不同,以及一个可证明完整性的有界分页算法。

TL;DR

Monad 在其 JSON-RPC Limits 部分记录了 eth_getLogs 的区块范围限制,该限制与以太坊主网的实际限制不同,超出限制会返回错误而非部分结果。文档化的补救措施是对范围进行分页并合并结果。由于保护机制在扫描前就拒绝请求,你会收到“invalid block range”而不是空数组,这就是为什么简单的重试和更长的超时无济于事。本文区分了文档化的协议行为与提供商特定行为,然后给出了一个有界分页算法,该算法通过减半来发现有效的每请求范围,用 nextFrom = lastTo + 1 断言连续覆盖,并检查 blockNumber 单调排序。文中包含一个可运行的 Node.js 分页器,带有抖动重试,一个针对你自己的 Monad 端点填写的结果表,以及关于日志索引保留和归档节点与全节点可用性的限制部分。

Monad eth_getLogs 范围保护的错误语义

当客户端发送的 eth_getLogs 请求的 fromBlock/toBlock 跨度超过节点配置的上限时,Monad 会返回一个 JSON-RPC 错误对象,消息类似于“invalid block range”,而不是空结果数组。这是一个保护机制,而非扫描:节点在接触日志索引之前会验证请求的跨度,因此对于给定的范围和端点配置,失败是确定性的。JSON-RPC 2.0 规范将这种形式定义为带有数字代码和消息的错误响应,这就是为什么行为良好的客户端应该根据错误进行分支,而不是将其视为“未找到日志”。

这种区别很重要,因为空数组是一个有效的成功答案,意味着“此范围内没有匹配的日志”。错误意味着“此请求被拒绝”。如果你的索引器将两者都视为空,它将静默跳过历史记录。Monad 文档的 Limits 部分是确认当前上限的权威位置,它明确指出该限制与以太坊主网不同。关于分块的一般机制,请参阅 eth_getLogs 区块范围限制与安全分块。

  • 错误响应:节点拒绝了请求;未扫描任何日志。
  • 空数组:节点扫描了范围,未找到匹配项。
  • 在索引器中,切勿将两者合并到同一代码路径。
  • 该上限是 Monad 上的文档化行为,与以太坊主网并不完全相同。

Monad 文档化限制与以太坊主网实际限制的对比

以太坊的 eth_getLogs JSON-RPC 规范 定义了过滤器对象及其 fromBlock/toBlock 语义,但并未强制规定最大跨度;实际上,公共以太坊提供商施加了自己的上限,实际上限通常由响应大小和超时驱动,而非固定的区块数量。Monad 的 JSON-RPC 文档 在其 Limits 部分列出了明确的区块范围限制,同一文档还描述了 Monad 的行为与以太坊的不同之处。

由于上限是文档化的,但提供商部署可能不同,请将该数字视为“文档化/因提供商而异”。不要硬编码你从博客文章中复制的单一常量。相反,针对你自己的端点发现有效的可接受范围,然后缓存它。Monad RPC 端点(RPC Assistant) 页面是确认你实际查询的是哪个端点的正确位置,然后再进行测量。如果你运行在专用的 Monad 主网 端点上,你观察到的上限就是你的分页器必须遵守的上限。

  • 以太坊规范:定义了过滤器语义;未强制规定通用最大跨度。
  • Monad 文档:在 Limits 下明确记录了 eth_getLogs 范围限制。
  • 文档化的补救措施:对范围进行分页并合并结果。
  • 提供商部署可能不同;发现并缓存有效上限。

为什么减半法无需猜测就能发现有效范围

与其假设一个上限,不如从一个你知道过大的跨度开始,然后减半直到节点接受请求。这是一个有界搜索:每次拒绝都会消除剩余跨度的一半,因此发现过程在 O(log n) 次探测内完成。第一个被接受的跨度是一个安全的上限;然后你可以直接使用它,或者稍微缩小以留出余量。这种方法对任何端点都是可复现的,并且不依赖于未文档化的常量。

同样的减半逻辑处理单个区块超过上限的边界情况。如果 fromBlock 等于 toBlock 且节点仍然拒绝,那么问题不是跨度宽度,而是区块本身,通常是因为该区块包含的日志数量超过节点在一次响应中返回的数量。在这种情况下,你必须按地址或主题缩小范围,或者回退到基于跟踪或收据的策略。记录结果,以便区分“范围太宽”和“单个区块太重”。

async function discoverRange(rpcUrl, probeBlock) {
  let span = 1024;
  while (span >= 1) {
    const fromBlock = '0x' + (probeBlock - span + 1).toString(16);
    const toBlock = '0x' + probeBlock.toString(16);
    const body = {
      jsonrpc: '2.0', id: 1, method: 'eth_getLogs',
      params: [{ fromBlock, toBlock }]
    };
    const res = await fetch(rpcUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body)
    });
    const json = await res.json();
    if (!json.error) return span;
    if (!/invalid block range/i.test(json.error.message || '')) {
      throw new Error('unexpected error: ' + JSON.stringify(json.error));
    }
    span = Math.floor(span / 2);
  }
  throw new Error('single block rejected; narrow by address or topic');
}

连续性与单调性作为完整性证明

分页只有在页面的并集等于请求的范围且没有间隙和重叠时才是正确的。用两个不变量来强制执行这一点。首先,连续性:下一页的 fromBlock 必须等于上一页的 toBlock 加一,即 nextFrom = lastTo + 1。其次,单调性:在每个页面内,日志的 blockNumber 值必须非递减,并且跨页面时,第 N+1 页的第一个 blockNumber 必须大于或等于第 N 页的最后一个 blockNumber。如果任一不变量失败,则停止并暴露异常,而不是静默合并。

这些检查能捕获导致索引器状态错误的故障模式:瞬时错误后丢失的页面、循环中的差一错误,或提供商返回乱序日志。它们还使分页器可审计,因为你可以记录接受的范围和页面数量,并将其与请求的范围进行比较。同样的原则适用于 Monad 交易生命周期与收据状态 中描述的基于收据的流程,其中顺序假设同样重要。

  • 连续性:对于第一页之后的每一页,nextFrom = lastTo + 1。
  • 单调性:blockNumber 在页面内和跨页面非递减。
  • 违反时:停止,记录页面边界,不要合并。
  • 记录请求范围、接受范围和页面数量以供审计。

一个可运行的 Node.js 分页器,带抖动重试

下面的分页器发现有效范围,然后以可接受大小的页面遍历请求窗口。它使用指数退避加抖动重试瞬时故障,但不会盲目重试“invalid block range”;相反,它将页面大小减半并重试一次,从而快速收敛。它在返回前断言连续性和单调性,因此成功返回意味着合并集对于请求窗口是可证明完整的。

针对你自己的端点运行它,并捕获请求范围、接受范围、页面数量和墙上时钟毫秒数。这四个数字是描述你的端点的唯一可靠方式,因为它们取决于你的提供商、过滤器选择性和网络条件。不要用别人的数字代替你自己的测量。

async function rpc(rpcUrl, method, params, attempt = 0) {
  const res = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: attempt + 1, method, params })
  });
  if (res.status === 429 || res.status >= 500) {
    if (attempt >= 5) throw new Error('retries exhausted: HTTP ' + res.status);
    const base = Math.min(2000, 100 * 2 ** attempt);
    const jitter = Math.floor(Math.random() * 100);
    await new Promise(r => setTimeout(r, base + jitter));
    return rpc(rpcUrl, method, params, attempt + 1);
  }
  return res.json();
}

async function pageLogs(rpcUrl, fromBlock, toBlock, filter = {}) {
  let pageSize = await discoverRange(rpcUrl, toBlock);
  const logs = [];
  let cursor = fromBlock;
  let lastBlock = -1;
  let pages = 0;
  while (cursor <= toBlock) {
    const end = Math.min(cursor + pageSize - 1, toBlock);
    const params = [{
      ...filter,
      fromBlock: '0x' + cursor.toString(16),
      toBlock: '0x' + end.toString(16)
    }];
    const json = await rpc(rpcUrl, 'eth_getLogs', params);
    if (json.error) {
      if (/invalid block range/i.test(json.error.message || '') && pageSize > 1) {
        pageSize = Math.floor(pageSize / 2);
        continue;
      }
      throw new Error('eth_getLogs failed: ' + JSON.stringify(json.error));
    }
    const page = json.result || [];
    for (const log of page) {
      const bn = parseInt(log.blockNumber, 16);
      if (bn < lastBlock) throw new Error('monotonicity violated at block ' + bn);
      lastBlock = bn;
    }
    logs.push(...page);
    pages += 1;
    cursor = end + 1;
  }
  if (cursor !== toBlock + 1) throw new Error('contiguity violated');
  return { logs, pages, pageSize };
}

端点特定测量的结果表

针对你自己的 Monad 端点填写此表。使用固定的请求范围和固定的过滤器运行分页器,重复几次,并记录发现步骤返回的接受范围、页面数量和墙上时钟毫秒数。由于如果提供商调整限制,接受范围可能会改变,因此在任何端点更改或提供商迁移后重新测量。

将该表视为本地基线,而非基准声明。如果你需要比较提供商,请保持请求范围、过滤器和客户端机器不变,并记录每行的端点 URL 和时间戳。RPC 定价 页面可帮助你映射测量的请求量到成本,API 服务 页面描述了托管访问,如果你不想自己操作分页器。

  • 请求范围:你请求的 fromBlock..toBlock。
  • 接受范围:发现步骤确认的跨度。
  • 页面数量:成功的 eth_getLogs 调用次数。
  • 墙上毫秒:完整合并的经过时间。
  • 端点和时间戳:使行可比较所必需。

排查持续出现的无效区块范围错误

如果减半达到单个区块而节点仍然拒绝,则该区块本身对于一次响应来说太重。按合约地址或 topic0 缩小过滤器,或按地址集拆分。如果错误仅对历史范围持续存在,你可能正在查询不保留旧日志的全节点;切换到归档端点,如 Monad 归档节点与历史 RPC 中所述。如果请求挂起而不是报错,那是不同的故障类别,在 Monad RPC 超时 中有所涵盖。

还要验证你没有混合区块标签。Monad 的 JSON-RPC 概述记录了区块标签处理,并指出了与以太坊的差异;在分页历史窗口时将 'latest' 作为 toBlock 传递可能会产生令人困惑的结果。分页时,将 fromBlock 和 toBlock 都固定为十六进制数量。最后,确认你认为正在调用的端点就是实际应答的端点,因为配置中过时的 RPC URL 是“昨天还能用”报告的常见原因。

  • 单区块拒绝:按地址或主题缩小范围。
  • 仅历史失败:可能是没有旧日志保留的全节点。
  • 挂起而非报错:单独调查超时。
  • 将 fromBlock 和 toBlock 固定为十六进制数量;避免混合标签。
  • 验证配置的 RPC URL 与你测量的端点匹配。

日志索引保留与归档节点和全节点的可用性

日志可用性受节点保留内容的限制。全节点可能修剪或不索引旧日志,因此即使范围在上限内,对历史窗口的 eth_getLogs 也可能失败或返回不完整数据。归档节点保留历史状态和日志索引,这是回填和重组安全重建索引所需要的。这是保留属性,而非范围限制属性,在调试时两者经常被混淆。

实际后果是分页器的正确性取决于端点的保留窗口。如果你分页的范围早于保留期,你可能会得到错误或实际上并非空的空结果。在回填之前与你的提供商确认保留期,对于任何早于节点文档化保留期的窗口,优先使用归档端点。Monad 归档节点与历史 RPC 文章更详细地介绍了权衡。

  • 全节点:可能不保留或索引旧日志。
  • 归档节点:保留历史状态和日志索引。
  • 保留限制与 eth_getLogs 范围上限是分开的。
  • 回填应默认针对归档端点。

页面大小、延迟和请求量之间的权衡

较小的页面降低触及范围保护的可能性并降低每请求响应大小,但会增加请求数量和总墙上时间。较大的页面分摊往返延迟,但有拒绝和更大负载的风险。最优值是端点特定的,这就是发现步骤和结果表存在的原因:它们让你选择一个被一致接受的页面大小,而不会过度碎片化。

还有一个成本维度。在大多数提供商上,每个页面都是可计费请求,因此过度碎片化的分页器即使成功也会增加成本。根据提供商的定价模型平衡页面大小,并缓存发现的接受范围,以免每次运行都重新探测。有关托管访问和定价背景,请参阅 RPC 定价 和 API 服务 概述。

  • 小页面:更安全,请求更多,总延迟更高。
  • 大页面:请求更少,拒绝风险更高。
  • 在运行之间缓存发现的接受范围。
  • 选择页面大小时考虑按请求计费。

Monad 生产日志索引的后续步骤

将分页器移入你的索引器,放在检查点存储之后:持久化最后完全合并的 toBlock,并在重启时从检查点 + 1 恢复。这使连续性不变量在进程重启后持久化。添加对小型尾部窗口的定期重新扫描以处理重组,并在生产中如果单调性或连续性失败时发出警报。

对于端点选择和故障转移,至少配置两个 RPC URL,并在使用前用发现步骤验证每个。Monad RPC 端点(RPC Assistant) 页面列出了选项,OnFinality Learn 中心 收集了相关的深度文章。如果你还在跟踪原生余额语义,Monad 储备余额 解释了影响 eth_getBalance 和 reserveBalance 读取的储备模型。

  • 持久化最后完全合并区块的检查点。
  • 重新扫描尾部窗口以确保重组安全。
  • 在连续性或单调性违规时发出警报。
  • 用发现步骤验证每个配置的端点。

永远不用担心基础设施

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

开始