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

Polkadot state_queryStorageAt:在指定区块读取存储变更

了解如何在特定区块读取 Substrate 存储,为什么 state_queryStorageAt 仅限于单个区块,以及如何构建分块式的变更检测工作流。

TL;DR

在特定区块读取 Polkadot 存储需要将 `at` 参数固定为区块哈希;否则节点返回当前链头状态,结果不可复现。`state_queryStorageAt` 方法接受存储键数组和可选的 `at` 区块哈希,返回按区块分组的结果,但其文档说明的区块范围仅为单个区块。要检测某个范围内的变更,必须逐块遍历,使用 `state_getStorageHash` 比较值哈希,而不是传输每个值。存储键通过元数据派生,由 pallet 和条目名称的 twox128 哈希加上 SCALE 编码的键部分构成,因此错误的键会静默返回 null。归档节点或支持历史状态的端点是一切历史读取的前提条件。

存储键作为派生标识符

Substrate 存储键不是你可以从文档中复制的可读字符串。它是一个确定性的派生结果:pallet 名称的 twox128 哈希,与存储条目名称的 twox128 哈希拼接,再跟上映射和双重映射的 SCALE 编码键部分。Substrate 运行时存储文档详细描述了这种构成方式。由于键是派生的,运行时升级中对 pallet 或条目名称的任何更改都会使之前计算的键失效。

这种派生模型意味着你应该将存储键视为计算值,而非常量。如果你硬编码一个键,而运行时重命名了某个 pallet,你的查询将返回 null 而不是错误。这种静默失败是读取历史存储时最常见的困惑来源。始终从你查询区块的元数据中获取键,或者至少在信任 null 结果之前,用已知良好的区块验证该键。

  • Pallet 前缀:pallet 名称的 twox128 哈希(例如 'System')。
  • 条目前缀:存储条目名称的 twox128 哈希(例如 'Account')。
  • 键部分:附加在前缀之后的 SCALE 编码映射键。
  • 错误的键返回 null 而非错误——始终用已知良好的区块进行区分。

state_getStorage 与 state_getStorageHash 与 state_queryStorageAt 的对比

polkadot.js 的 state 方法 JSON-RPC 参考记录了三种不同的查询。state_getStorage 返回单个键的当前值。state_getStorageHash 返回给定区块上值的哈希,这是检测变更而不传输完整值的廉价方式。state_queryStorageAt 接受键数组和可选的 at 区块哈希,返回按区块分组的结果。

每种方法回答不同的问题。当你需要实际值时使用 state_getStorage。当你只需要知道值是否变化时使用 state_getStorageHash。当你需要在一次请求中获取特定区块的多个键时使用 state_queryStorageAtat 参数至关重要:没有它,节点读取当前链头,结果不可复现。将 at 固定为区块哈希使读取可重放,也是诚实比较两次观测的唯一方式。

  • state_getStorage:单个键的当前值。
  • state_getStorageHash:区块上值的哈希——廉价的变更检测。
  • state_queryStorageAt:单个区块上的多个键,结果按区块分组。

state_queryStorageAt 的单区块限制

state_queryStorageAt 方法接受一个 at 区块哈希,并返回按区块分组的结果。其文档说明的区块范围是单个区块。这不是缺陷;它反映了该方法作为某一时间点多键查询的设计。如果你需要知道某个区块范围内的变更,你不能发出一个宽泛的查询。你必须逐块遍历该范围。

这一限制将你的变更检测设计塑造为显式的逐块遍历。高效的模式是在范围起始区块使用 state_queryStorageAt 读取一次键集,然后逐块向前遍历,仅使用 state_getStorageHash 读取值哈希。报告哈希首次出现差异的区块。这比传输每个值更廉价,并且能精确指出变更发生的时间。

  • state_queryStorageAt 返回单个区块的结果,而非范围。
  • 要检测范围内的变更,需逐块遍历。
  • 先比较哈希;仅当哈希不同时才获取值。

固定 at 参数以实现可复现读取

每次历史读取都必须将 at 参数固定为区块哈希。不带 at 区块的读取意味着读取当前链头,而链头会随每个新区块变化。在当前链头读取不可复现:同一查询执行两次可能返回不同结果。将 at 固定为特定区块哈希使读取可重放,也是诚实比较两次观测的唯一方式。

当你比较两个区块时,始终使用相同的键集和相同的 at 语义。如果你不带 at 读取区块 A,而带 at 读取区块 B,你是在将当前链头与历史区块比较,这毫无意义。Polkadot 归档节点历史状态指南解释了为什么此模式需要归档端点。

  • 历史读取始终将 at 固定为区块哈希。
  • 没有 at,你读取的是当前链头——不可复现。
  • 使用相同的键集和 at 语义比较区块。

用于变更检测的分块比较工作流

分块比较工作流将“什么变了”转化为证据。首先在范围起始区块使用 state_queryStorageAt 读取一次键集。然后逐块向前遍历,仅使用 state_getStorageHash 读取值哈希。报告哈希首次出现差异的区块。这种方法既比传输每个值更廉价,又能精确指出变更发生的时间。

遍历是 O(blocks) 次请求,这使得分块大小成为真正的预算决策。如果你在扫描大范围,可能需要批量请求或使用具有宽松速率限制的提供商。RPC 定价页面API 服务描述了 OnFinality 如何构建请求预算。关于在单个区块读取外部交易和事件的完整示例,请参阅 Polkadot 区块外部交易和事件教程

  • 在起始区块使用 state_queryStorageAt 读取一次键集。
  • 使用 state_getStorageHash 逐块向前遍历。
  • 报告哈希首次不同的区块——那就是你的变更点。
  • 分块大小是预算决策:O(blocks) 次请求。

用于存储变更检测的可运行 Node.js 脚本

以下脚本接受一个存储键和一个区块范围,读取起始区块的值,然后遍历范围比较哈希。它打印值首次变化的区块哈希以及变化前后的值。将端点 URL 替换为你自己的归档节点端点。该脚本使用 @polkadot/api 库,它会自动处理 SCALE 解码。

注意,该脚本假设键已经派生。在实践中,你应该从起始区块的元数据派生键。下一节介绍如何从元数据获取键,而不是硬编码它。

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

async function findStorageChange(wsEndpoint, storageKey, startBlock, endBlock) {
  const provider = new WsProvider(wsEndpoint);
  const api = await ApiPromise.create({ provider });

  // Get block hashes for the range
  const startHash = (await api.rpc.chain.getBlockHash(startBlock)).toString();
  const endHash = (await api.rpc.chain.getBlockHash(endBlock)).toString();

  // Read initial value at start block
  const initialValue = await api.rpc.state.getStorage(storageKey, startHash);
  const initialHash = await api.rpc.state.getStorageHash(storageKey, startHash);
  console.log(`Start block ${startBlock} hash: ${startHash}`);
  console.log(`Initial value: ${initialValue.toHex()}`);
  console.log(`Initial hash: ${initialHash.toHex()}`);

  let previousHash = initialHash.toHex();
  let previousValue = initialValue.toHex();

  // Walk forward block by block
  for (let block = startBlock + 1; block <= endBlock; block++) {
    const blockHash = (await api.rpc.chain.getBlockHash(block)).toString();
    const currentHash = await api.rpc.state.getStorageHash(storageKey, blockHash);

    if (currentHash.toHex() !== previousHash) {
      const currentValue = await api.rpc.state.getStorage(storageKey, blockHash);
      console.log(`\nChange detected at block ${block}`);
      console.log(`Block hash: ${blockHash}`);
      console.log(`Before: ${previousValue}`);
      console.log(`After:  ${currentValue.toHex()}`);
      return { block, blockHash, before: previousValue, after: currentValue.toHex() };
    }

    previousHash = currentHash.toHex();
    previousValue = (await api.rpc.state.getStorage(storageKey, blockHash)).toHex();
  }

  console.log('No change detected in range.');
  return null;
}

// Example usage
findStorageChange(
  'wss://your-archive-endpoint.example.com',
  '0x26aa394eea5630e07c48ae0c9558cef7b99d880ec681799c0cf30e8886371da9', // System.Account key example
  1000000,
  1000100
).catch(console.error);

从元数据获取存储键

硬编码存储键很脆弱。Pallet 和条目名称是 twox128 前缀的输入,因此重命名 pallet 的运行时升级会使键失效。你必须从你查询区块的元数据重新派生键。Substrate state_getMetadata 和运行时版本教程解释了如何获取和解码元数据。

要派生键,你需要 pallet 名称、存储条目名称以及映射的 SCALE 编码键部分。@polkadot/api 库提供了诸如 api.query.system.account.key(accountId) 的辅助方法,为你计算完整键。如果你在原始 RPC 层面工作,你必须自己计算 twox128 哈希并拼接 SCALE 编码的键部分。在信任 null 结果之前,始终用已知良好的区块验证派生的键。

  • 从目标区块的元数据派生键,而不是硬编码字符串。
  • Pallet 和条目名称是 twox128 前缀的输入——重命名会使键失效。
  • 使用 api.query.*.key() 辅助方法或手动计算 twox128 哈希。
  • 在信任 null 之前,用已知良好的区块验证派生的键。

历史读取的归档节点前提条件

归档节点或支持历史状态的端点是一切历史存储读取的前提条件。修剪节点无法提供任意过去区块的状态,会以错误或不可用响应作答,而不是错误的值。这是一个硬性约束:如果你的端点不保留历史状态,再多的重试逻辑也无济于事。

OnFinality 为 Polkadot 和其他 Substrate 链提供归档端点。Polkadot 网络页面列出了可用端点,Polkadot RPC 端点指南解释了如何为你的用例选择正确的端点。关于归档节点要求的更深入讨论,请参阅 Polkadot 归档节点历史状态文章

  • 修剪节点无法提供任意过去区块的状态。
  • 归档端点保留历史状态以实现可复现读取。
  • 在构建历史读取工作流之前,检查端点能力。

故障模式与故障排除

state_getStoragestate_queryStorageAt 返回 null 值意味着“此区块不存在”或“键错误”。你通过首先确认同一键在已知良好的区块返回一个值来区分。如果它在区块 X 返回值但在区块 Y 返回 null,则键有效,值在区块 Y 确实不存在。如果它在两者都返回 null,则键很可能错误。

在值有意义之前需要 SCALE 解码。十六进制值是中间表示,不是答案。如果你使用原始 RPC 调用,必须根据存储条目的类型解码十六进制。当你使用类型化查询时,@polkadot/api 库会自动处理这一点。另一个常见故障是区块范围遍历超出速率限制:O(blocks) 次请求可能很昂贵,因此按需批量或限流。

  • Null 意味着不存在或键错误——用已知良好的区块区分。
  • 十六进制值在变得有意义之前需要 SCALE 解码。
  • 区块范围遍历是 O(blocks) 次请求——注意速率限制。
  • 运行时升级可能使键失效——从元数据重新派生。

端点验证结果表

由于提供商特定的延迟和吞吐量各不相同,你应该针对自己的端点进行测量。下表是一个模板,你可以填入自己的测量结果。针对你的端点运行上一节的脚本并记录结果。这为你的特定设置提供了可复现的基线。

不要依赖任何提供商(包括 OnFinality)发布的基准数字。测量你自己的。下表是你自己验证的起点。

  • 端点 URL:你的归档节点 WebSocket 端点。
  • 区块范围:你正在扫描的起始和结束区块。
  • 请求数量:遍历期间发出的 RPC 调用总数。
  • 耗时:完整扫描的挂钟时间。
  • 检测到变更的区块:哈希首次不同的区块编号。
  • 变更前的值:变更前的十六进制值。
  • 变更后的值:变更后的十六进制值。

局限性与权衡

分块比较工作流精确但并非免费。遍历大区块范围是 O(blocks) 次请求,可能缓慢且昂贵。如果你需要扫描数百万个区块,可能需要不同的方法,例如订阅存储变更事件或使用索引器。state_queryStorageAt 方法的单区块限制意味着如果你在原始 RPC 层面工作,就无法避免遍历。

另一个权衡是哈希比较只告诉你值发生了变化,而不是什么发生了变化。如果你需要实际差异,必须获取并解码两个值。对于大值,这可能很昂贵。最后,归档端点并非免费:它们需要更多存储,且通常定价不同。RPC 定价页面描述了 OnFinality 如何构建归档访问的成本。

  • 对于大范围,O(blocks) 次请求可能缓慢且昂贵。
  • 哈希比较检测到变化,但不检测变化的性质。
  • 获取和解码值会增加大存储条目的成本。
  • 归档端点是前提条件,且可能定价不同。

后续步骤与进一步阅读

现在你已了解存储变更读取路径,可以扩展它。将其与 Polkadot 区块外部交易和事件教程结合,将存储变更与导致它们的外部交易关联起来。使用 Substrate state_getMetadata 和运行时版本教程动态派生键。对于最终性相关的读取,请参阅 Polkadot 最终性与 GRANDPA 证明文章

要开始使用可靠的归档端点,请访问 Polkadot 网络页面或浏览 OnFinality Learn 中心获取更多教程。Polkadot RPC 端点指南帮助你为工作负载选择正确的端点。

  • 将存储变更与外部交易和事件关联。
  • 从元数据动态派生键。
  • 选择支持历史状态的归档端点。
  • 在 OnFinality Learn 中心探索更多教程。

永远不用担心基础设施

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

开始