在特定区块读取 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_queryStorageAt。at 参数至关重要:没有它,节点读取当前链头,结果不可复现。将 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_getStorage 或 state_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 中心探索更多教程。