Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
网络与协议指南阅读约 14 分钟

读取 Polkadot 特定区块的区块数据:外部交易、事件与存储

一份机制层面的指南,讲解如何通过 RPC 读取特定的 Substrate 区块,并正确解码其外部交易、事件与存储状态。

TL;DR

Substrate 节点通过 chain_getBlock(<hash>) 返回一个 SignedBlock,其 block.extrinsics 数组存放的是紧凑编码的 SCALE 字节,而非已解码的调用。要解释这些字节,必须根据该区块所处 specVersion 的运行时元数据进行解码,因为 pallet_index 与 call_index 对只能通过元数据解析,而这些索引会随运行时升级而变动。事件不属于区块主体:它们是执行期间写入的 System.Events 存储项,可通过 state_getStorage 在某个区块哈希处读取,或由 polkadot.js API 呈现。区块订阅只能看到新区块,因此读取特定历史区块需要在其哈希处调用 chain_getBlock,而完整的历史状态则需要归档节点。

chain_getBlock 对特定区块实际返回什么

Substrate 通过两个 JSON-RPC 方法暴露区块数据。chain_getBlockHash 接受一个可选的区块号并返回哈希;chain_getBlockHash(null) 返回当前最佳(头部)哈希,而 chain_getBlockHash(<number>) 解析特定高度。chain_getBlock 随后接受该哈希并返回一个 SignedBlock 信封。权威结构记录在 Substrate JSON-RPC 规范和 polkadot.js API JSON-RPC 参考中。

该信封包含 block.header、block.extrinsics 和 justifications。头部携带 parentHash、number、stateRoot、extrinsicsRoot 和 digest(包括共识日志条目)。extrinsics 数组是关键部分:每个元素都是紧凑编码 SCALE 字节的十六进制字符串,而非已解码的调用。如果你的索引器将该十六进制当作 JSON 或具名调用来处理,就会错误解码每一个区块。

  • chain_getBlockHash(null) → 当前最佳哈希;chain_getBlockHash(n) → 高度 n 处的哈希。
  • chain_getBlock(hash) → { block: { header, extrinsics }, justifications }。
  • block.extrinsics[i] 是 SCALE 十六进制;解码需要运行时元数据。
  • justifications 携带 GRANDPA 最终性证明;参见 GRANDPA 证明与已最终确定的头部

为什么外部交易是不透明字节,以及元数据如何解析它们

外部交易的第一个字节编码其版本和格式(例如,它是已签名、裸交易还是未签名)。之后,已签名外部交易包含签名者账户、签名以及 era/nonce/tip 字段,随后是调用本身。调用以 pallet_index 加 call_index 对的形式寻址。如果没有将这些小整数映射到 pallet 和调用名称的运行时元数据,它们就毫无意义。

这就是为什么 state_getMetadata 对于解码是必需的。元数据描述了一个 specVersion 下的每个 pallet、其调用、事件和存储项。由于运行时升级可能重新编号 pallet 和调用,索引器必须按 specVersion 为元数据建立键,并使用区块处的 state_getRuntimeVersion 来确定适用哪个元数据。历史元数据由归档 RPC 提供,或从元数据缓存中获取。参见 使用 state_getMetadata 读取运行时元数据

  • 字节 0:外部交易版本/格式。
  • 已签名外部交易:账户、签名、era、nonce、tip,然后是调用。
  • 调用 = pallet_index + call_index,只能通过元数据解析。
  • 按 specVersion 为元数据建立键;索引会随升级而变动。

事件在哪里:System.Events,而非区块主体

事件不存储在 block.extrinsics 中。它们是 System.Events 存储项,由运行时在区块执行期间写入。你可以使用键 twox128('System') ++ twox128('Events') 和区块哈希,通过 state_getStorage 在特定区块读取它们,或者让 polkadot.js API 从区块中呈现它们。每个事件携带一个阶段:Initialization、ApplyExtrinsic(index) 或 Finalization。ApplyExtrinsic 阶段将事件链接到产生它的外部交易。

这种阶段关联是链上调用与其效果之间的区别。单个外部交易可以发出许多事件,而某些事件(如费用或国库存款)是由系统而非调用者发出的。不结合阶段读取事件,会让浏览器将效果归因到错误的外部交易。

  • 事件是存储项,不属于 SignedBlock 主体。
  • 通过 state_getStorage(twox128('System')++twox128('Events'), blockHash) 读取。
  • 阶段:Initialization | ApplyExtrinsic(index) | Finalization。
  • 使用阶段将事件映射回其来源外部交易。

使用 @polkadot/api 读取特定区块

polkadot.js API 封装了原始 RPC 并为你处理元数据解析。使用 api.rpc.chain.getBlock(hash) 获取 SignedBlock,然后使用 api.at(blockHash) 获取固定到该区块的上下文,用于查询和事件。api.query.system.events.at(blockHash) 返回该区块的事件,api.rpc.state.getStorage 在该哈希处读取特定存储键。

下面的示例按编号获取区块,解码已签名外部交易的签名者和 nonce,并读取该区块的事件。它可以在任何提供该区块的 Substrate 端点上运行;请替换端点和区块号。

const { ApiPromise, WsProvider } = require('@polkadot/api');

async function main() {
  const api = await ApiPromise.create({ provider: new WsProvider('wss://your-endpoint') });
  const blockNumber = 20000000;

  const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
  const signedBlock = await api.rpc.chain.getBlock(blockHash);

  console.log('hash', blockHash.toHex());
  console.log('parent', signedBlock.block.header.parentHash.toHex());
  console.log('extrinsics', signedBlock.block.extrinsics.length);

  signedBlock.block.extrinsics.forEach((ex, i) => {
    const { isSigned, signer, nonce, method } = ex;
    console.log(i, isSigned ? signer.toString() : 'unsigned',
      isSigned ? nonce.toString() : '-', method.section + '.' + method.method);
  });

  const apiAt = await api.at(blockHash);
  const events = await apiAt.query.system.events();
  events.forEach(({ phase, event }) => {
    console.log(phase.toString(), event.section + '.' + event.method);
  });

  await api.disconnect();
}

main().catch(console.error);

原始获取回退方案:chain_getBlock 加元数据解码

当你无法使用 API 封装时,直接获取 chain_getBlock 和 state_getMetadata,然后使用 @polkadot/types 根据元数据进行解码。元数据必须与区块处的 specVersion 匹配。在解码前,使用区块哈希处的 state_getRuntimeVersion 确认适用哪个元数据。

原始路径对于批量处理许多区块并希望避免逐区块 API 开销的索引器很有用。它还使元数据依赖变得明确,而这正是大多数浏览器遇到的故障点。

const { WsProvider } = require('@polkadot/api');
const { TypeRegistry } = require('@polkadot/types');

async function raw(provider, blockHash) {
  const block = await provider.send('chain_getBlock', [blockHash]);
  const metaHex = await provider.send('state_getMetadata', [blockHash]);
  const version = await provider.send('state_getRuntimeVersion', [blockHash]);

  const registry = new TypeRegistry();
  registry.setMetadata(new (require('@polkadot/types').Metadata)(registry, metaHex));

  const extrinsics = block.block.extrinsics.map((hex) =>
    registry.createType('Extrinsic', hex, { version: version.specVersion }));

  return { version, extrinsics };
}

区块处的存储变化,以及为什么订阅不会回填

区块处的存储变化可以通过在该区块哈希处读取 state_getStorage 来观察,或者在节点支持的情况下使用 tracing。普通的区块订阅(chain_subscribeNewHeads 或 finalized heads)只能看到新区块,永远不会回填特定的历史区块。如果你需要过去的区块,必须在其哈希处调用 chain_getBlock。完整的历史状态需要归档节点;被修剪的全节点会对旧存储键返回 null。

这一区别对索引器很重要。订阅用于流式传输新区块;历史读取用于回填和审计。将它们混为一谈会产生空事件和 null 存储,看起来像解码错误,但实际上是数据可用性限制。参见 用于历史状态的 Polkadot 和 Substrate 归档节点

  • state_getStorage(key, blockHash) 在某个区块读取存储项。
  • 订阅仅流式传输新区块;它们不会回填。
  • 历史状态需要归档节点。
  • 被修剪的全节点对旧键返回 null。

结果表:测量你自己端点的区块数据行为

端点行为因提供商和节点类型而异。针对你自己的端点填写下表,以记录它实际提供的内容。不要假设某个值;要测量它。对于特定提供商的数字,将其视为已记录 / 因提供商而异。

在最近区块和一个旧区块(例如来自先前运行时时代的区块)分别运行每项检查,以暴露修剪和元数据漂移。

  • chain_getBlock 在最近哈希处 → 返回 SignedBlock?(是/否)
  • chain_getBlock 在旧哈希处 → 返回 SignedBlock?(是/否)
  • state_getStorage(System.Events) 在旧哈希处 → 返回数据?(是/否)
  • state_getRuntimeVersion 在区块处 → specVersion 值
  • state_getMetadata 在区块处 → 返回元数据?(是/否)
  • 节点类型:归档还是全节点?(归档/全节点)
  • 端点 URL 和提供商:(填写)

常见故障及其诊断方法

大多数区块数据错误可归为几类。“Unable to decode”或未知 pallet 索引几乎总是意味着元数据版本与区块的 specVersion 不匹配。空事件通常意味着你查询的是最新状态而非该区块。chain_getBlock 返回 null 意味着哈希未知或区块尚未最终确定。全节点上的被修剪状态对旧存储键返回 null。运行时升级索引漂移意味着同一个 pallet_index 在不同 specVersion 下映射到不同的 pallet。

通过检查区块处的 specVersion、确认节点类型,并验证你为每次读取都传入了区块哈希来进行诊断。如果你在提交外部交易并看到调度错误,那是另一条路径;参见 解码 Polkadot 外部交易提交错误

  • 解码错误 → 元数据/specVersion 不匹配。
  • 空事件 → 查询的是最新状态,而非该区块。
  • chain_getBlock 为 null → 哈希未知或未最终确定。
  • 存储为 null → 全节点上的被修剪状态。
  • 索引漂移 → 运行时升级改变了 pallet/调用索引。

限制与权衡

在特定区块读取区块数据并非没有约束。历史状态仅在归档节点上可用,即便如此,旧 specVersion 的元数据也必须获取或缓存。解码原始 SCALE 需要正确的元数据,因此索引器必须按 specVersion 存储元数据。Tracing 方法并非普遍支持,在公共端点上可能被禁用。

订阅对新区块很高效,但对回填无用。批量读取许多区块会增加负载大小,并可能触及速率限制,因此要规划分页和重试。关于端点选择和限制,参见 Polkadot RPC 端点与提供商(RPC Assistant)RPC 定价

  • 历史状态需要归档节点。
  • 元数据必须按 specVersion 建立键。
  • Tracing 支持因节点而异。
  • 批量读取可能触及速率限制。

故障排除清单

在假设存在解码错误之前,请先完成此清单。它将数据可用性问题与元数据问题以及查询错误区分开来。

如果某一步失败,请先修复它再继续;后续步骤依赖前面的步骤。

  • 使用 chain_getBlockHash(number) 确认区块哈希。
  • 获取 chain_getBlock(hash) 并验证 block.extrinsics 非空。
  • 调用 state_getRuntimeVersion(hash) 并记录 specVersion。
  • 获取 state_getMetadata(hash) 并确认它与 specVersion 匹配。
  • 使用该元数据解码外部交易;检查 pallet_index/call_index。
  • 在同一哈希处读取 System.Events;验证阶段。
  • 对于旧区块,检查节点类型(归档还是全节点)。
  • 如果为 null,尝试归档端点或其他提供商。

后续步骤与深入方向

一旦你能正确读取特定区块,下一步就是构建一个按 specVersion 为元数据建立键并从归档节点回填的管道。对于特定网络的端点,请从 Polkadot 开始。对于托管访问和历史状态,参见 API 服务用于历史状态的 Polkadot 和 Substrate 归档节点

关于 RPC 行为的更广泛背景,请浏览 OnFinality Learn 中心。如果你在比较提供商,Polkadot RPC 端点与提供商(RPC Assistant) 页面列出了选项,RPC 定价 涵盖了套餐限制。

  • 在索引器中按 specVersion 为元数据建立键。
  • 从归档节点回填,通过订阅流式传输新区块。
  • 对照已知区块浏览器验证解码。
  • 监控 specVersion 变化,及早发现索引漂移。

永远不用担心基础设施

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

开始