Reading Polkadot storage at a specific block requires pinning the `at` parameter to a block hash; without it, the node returns current-head state, which is not reproducible. The `state_queryStorageAt` method accepts an array of storage keys and an optional `at` block hash, returning results grouped per block, but its documented block range is a single block. To detect what changed across a range, you must walk block by block, comparing value hashes with `state_getStorageHash` rather than transferring every value. Storage keys are derived from metadata using twox128 hashes of pallet and item names plus SCALE-encoded key parts, so a wrong key returns null silently. An archive or historical-state-capable endpoint is a prerequisite for any historical read.
Storage Keys as Derived Identifiers
A Substrate storage key is not a human-readable string you copy from documentation. It is a deterministic derivation: a twox128 hash of the pallet name, concatenated with a twox128 hash of the storage item name, followed by the SCALE-encoded key parts for maps and double maps. The Substrate runtime storage documentation describes this composition in detail. Because the key is derived, any change to the pallet or item name in a runtime upgrade invalidates previously computed keys.
This derivation model means you should treat a storage key as a computed value, not a constant. If you hard-code a key and the runtime renames a pallet, your query will return null rather than an error. That silent failure is the most common source of confusion when reading historical storage. Always source keys from metadata at the block you are querying, or at least verify the key against a known-good block before trusting a null result.
- Pallet prefix: twox128 hash of the pallet name (e.g., 'System').
- Item prefix: twox128 hash of the storage item name (e.g., 'Account').
- Key parts: SCALE-encoded map keys appended after the prefixes.
- A wrong key returns null, not an error — always disambiguate with a known-good block.
state_getStorage vs state_getStorageHash vs state_queryStorageAt
The polkadot.js JSON-RPC reference for state methods documents three distinct queries. state_getStorage returns the current value for a single key. state_getStorageHash returns the hash of the value at a given block, which is the cheap way to detect a change without transferring the full value. state_queryStorageAt accepts an array of keys and an optional at block hash, returning results grouped per block.
Each method answers a different question. Use state_getStorage when you need the actual value. Use state_getStorageHash when you only need to know whether a value changed. Use state_queryStorageAt when you need multiple keys at a specific block in one request. The at parameter is critical: without it, the node reads the current head, which is not reproducible. Pinning at to a block hash makes the read replayable and is the only way to compare two observations honestly.
- state_getStorage: current value for one key.
- state_getStorageHash: hash of the value at a block — cheap change detection.
- state_queryStorageAt: multiple keys at one block, results grouped per block.
The Single-Block Limitation of state_queryStorageAt
The state_queryStorageAt method takes an at block hash and returns results grouped per block. Its documented block range is a single block. This is not a bug; it reflects the method's design as a multi-key query at one point in time. If you need to know what changed across a range of blocks, you cannot issue one wide query. You must walk the range block by block.
This limitation shapes your change-detection design into an explicit per-block walk. The efficient pattern is to read the key set once with state_queryStorageAt at the range's start block, then walk forward block by block reading only the value hashes with state_getStorageHash. Report the first block at which a hash differs. This is cheaper than transferring every value and precise about when the change happened.
- state_queryStorageAt returns results for a single block, not a range.
- To detect changes across a range, walk block by block.
- Compare hashes first; fetch values only when a hash differs.
Pinning the at Parameter for Reproducible Reads
Every historical read must pin the at parameter to a block hash. Reading without an at block means reading the current head, which changes with every new block. A read at the current head is not reproducible: the same query executed twice may return different results. Pinning at to a specific block hash makes the read replayable and is the only way to compare two observations honestly.
When you compare two blocks, always use the same key set and the same at semantics. If you read block A without at and block B with at, you are comparing current head to a historical block, which is meaningless. The Polkadot archive node historical state guide explains why archive endpoints are required for this pattern.
- Always pin
atto a block hash for historical reads. - Without
at, you read the current head — not reproducible. - Compare blocks using identical key sets and
atsemantics.
Chunked Comparison Workflow for Change Detection
The chunked comparison workflow turns 'what changed' into evidence. Start by reading the key set once with state_queryStorageAt at the range's start block. Then walk forward block by block, reading only the value hashes with state_getStorageHash. Report the first block at which a hash differs. This approach is both cheaper than transferring every value and precise about when the change happened.
The walk is O(blocks) requests, which makes the chunk size a real budget decision. If you are scanning a large range, you may need to batch requests or use a provider with generous rate limits. The RPC pricing page and API service describe how OnFinality structures request budgets. For a worked example of reading extrinsics and events at a single block, see the Polkadot extrinsics and events at a block tutorial.
- Read key set once at start block with state_queryStorageAt.
- Walk forward block by block with state_getStorageHash.
- Report first block where hash differs — that is your change point.
- Chunk size is a budget decision: O(blocks) requests.
Runnable Node.js Script for Storage Change Detection
The following script takes a storage key and a block range, reads the value at the start block, then walks the range comparing hashes. It prints the block hash where the value first changed together with the before and after values. Replace the endpoint URL with your own archive node endpoint. The script uses the @polkadot/api library, which handles SCALE decoding automatically.
Note that the script assumes the key is already derived. In practice, you should derive the key from metadata at the start block. The next section covers how to source the key from metadata rather than hard-coding it.
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);Sourcing Storage Keys from Metadata
Hard-coding a storage key is fragile. Pallet and item names are the inputs to the twox128 prefixes, so a runtime upgrade that renames a pallet invalidates the key. You must re-derive the key from metadata at the block you are querying. The Substrate state_getMetadata and runtime versions tutorial explains how to fetch and decode metadata.
To derive a key, you need the pallet name, the storage item name, and the SCALE-encoded key parts for maps. The @polkadot/api library provides helpers like api.query.system.account.key(accountId) that compute the full key for you. If you are working at the raw RPC level, you must compute the twox128 hashes yourself and concatenate the SCALE-encoded key parts. Always verify the derived key against a known-good block before trusting a null result.
- Derive keys from metadata at the target block, not from hard-coded strings.
- Pallet and item names are inputs to twox128 prefixes — renames invalidate keys.
- Use api.query.*.key() helpers or compute twox128 hashes manually.
- Verify derived keys against a known-good block before trusting null.
Archive Node Prerequisites for Historical Reads
An archive or historical-state-capable endpoint is a prerequisite for any historical storage read. A pruned node cannot serve state at an arbitrary past block and will answer with an error or an unavailable response rather than a wrong value. This is a hard constraint: if your endpoint does not retain historical state, no amount of retry logic will help.
OnFinality provides archive endpoints for Polkadot and other Substrate chains. The Polkadot network page lists available endpoints, and the Polkadot RPC endpoints guide explains how to select the right endpoint for your use case. For a deeper discussion of archive node requirements, see the Polkadot archive node historical state article.
- Pruned nodes cannot serve state at arbitrary past blocks.
- Archive endpoints retain historical state for reproducible reads.
- Check endpoint capabilities before building a historical read workflow.
Failure Modes and Troubleshooting
A null value from state_getStorage or state_queryStorageAt means either 'absent at this block' or 'bad key'. You disambiguate by first confirming the same key returns a value at a known-good block. If it returns a value at block X but null at block Y, the key is valid and the value is genuinely absent at block Y. If it returns null at both, the key is likely wrong.
SCALE decoding is required before the value is meaningful. A hex value is the intermediate representation, not the answer. If you are using raw RPC calls, you must decode the hex according to the storage item's type. The @polkadot/api library handles this automatically when you use typed queries. Another common failure is a block-range walk that exceeds rate limits: O(blocks) requests can be expensive, so batch or throttle as needed.
- Null means absent or bad key — disambiguate with a known-good block.
- Hex values require SCALE decoding before they are meaningful.
- Block-range walks are O(blocks) requests — watch rate limits.
- Runtime upgrades can invalidate keys — re-derive from metadata.
Results Table for Endpoint Verification
Because provider-specific latency and throughput vary, you should measure against your own endpoint. The following table is a template you can fill in with your own measurements. Run the script from the previous section against your endpoint and record the results. This gives you a reproducible baseline for your specific setup.
Do not rely on published benchmark numbers from any provider, including OnFinality. Measure your own. The table below is a starting point for your own verification.
- Endpoint URL: your archive node WebSocket endpoint.
- Block range: start and end blocks you are scanning.
- Number of requests: total RPC calls made during the walk.
- Time elapsed: wall-clock time for the full scan.
- Change detected at block: the block number where the hash first differed.
- Before value: hex value before the change.
- After value: hex value after the change.
Limitations and Tradeoffs
The chunked comparison workflow is precise but not free. Walking a large block range is O(blocks) requests, which can be slow and expensive. If you need to scan millions of blocks, you may need a different approach, such as subscribing to storage change events or using an indexer. The state_queryStorageAt method's single-block limitation means you cannot avoid the walk if you are working at the raw RPC level.
Another tradeoff is that hash comparison only tells you that a value changed, not what changed. If you need the actual diff, you must fetch and decode both values. For large values, this can be expensive. Finally, archive endpoints are not free: they require more storage and are often priced differently. The RPC pricing page describes how OnFinality structures costs for archive access.
- O(blocks) requests can be slow and expensive for large ranges.
- Hash comparison detects change but not the nature of the change.
- Fetching and decoding values adds cost for large storage items.
- Archive endpoints are a prerequisite and may be priced differently.
Next Steps and Further Reading
Now that you understand the storage-change read path, you can extend it. Combine it with the Polkadot extrinsics and events at a block tutorial to correlate storage changes with the extrinsics that caused them. Use the Substrate state_getMetadata and runtime versions tutorial to derive keys dynamically. For finality-related reads, see the Polkadot finality and GRANDPA justifications article.
To get started with a reliable archive endpoint, visit the Polkadot network page or explore the OnFinality Learn hub for more tutorials. The Polkadot RPC endpoints guide helps you choose the right endpoint for your workload.
- Correlate storage changes with extrinsics and events.
- Derive keys dynamically from metadata.
- Choose an archive endpoint that supports historical state.
- Explore more tutorials on the OnFinality Learn hub.