Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides14 min read

Reading Polkadot Block Data: Extrinsics, Events, and Storage at a Specific Block

A mechanism-level guide to reading a specific Substrate block over RPC and correctly decoding its extrinsics, events, and storage state.

TL;DR

Substrate nodes return a block through chain_getBlock(<hash>) as a SignedBlock whose block.extrinsics array holds compact-encoded SCALE bytes, not decoded calls. To interpret those bytes you must decode them against the runtime metadata for the specVersion active at that block, because pallet_index and call_index pairs are only resolvable through metadata and those indices drift across runtime upgrades. Events are not part of the block body: they are the System.Events storage item written during execution and readable at a block hash via state_getStorage or surfaced by the polkadot.js API. A block subscription only sees new blocks, so reading a specific historical block requires chain_getBlock at its hash, and full historical state requires an archive node.

What chain_getBlock actually returns for a specific block

Substrate exposes block data through two JSON-RPC methods. chain_getBlockHash takes an optional block number and returns the hash; chain_getBlockHash(null) returns the current best (head) hash, while chain_getBlockHash(<number>) resolves a specific height. chain_getBlock then takes that hash and returns a SignedBlock envelope. The authoritative shape is documented in the Substrate JSON-RPC specification and the polkadot.js API JSON-RPC reference.

The envelope contains block.header, block.extrinsics, and justifications. The header carries parentHash, number, stateRoot, extrinsicsRoot, and digest (including consensus log entries). The extrinsics array is the important part: each element is a hex string of compact-encoded SCALE bytes, not a decoded call. If your indexer treats that hex as JSON or as a named call, it will mis-decode every block.

  • chain_getBlockHash(null) → current best hash; chain_getBlockHash(n) → hash at height n.
  • chain_getBlock(hash) → { block: { header, extrinsics }, justifications }.
  • block.extrinsics[i] is SCALE hex; decoding requires runtime metadata.
  • justifications carry GRANDPA finality proofs; see GRANDPA justifications and the finalized head.

Why extrinsics are opaque bytes and how metadata resolves them

The first byte of an extrinsic encodes its version and format (for example, whether it is signed, bare, or unsigned). After that, a signed extrinsic contains the signer account, a signature, and the era/nonce/tip fields, followed by the call itself. The call is addressed as a pallet_index plus call_index pair. Those small integers are meaningless without the runtime metadata that maps them to pallet and call names.

This is why state_getMetadata is mandatory for decoding. The metadata describes every pallet, its calls, its events, and its storage items for one specVersion. Because runtime upgrades can renumber pallets and calls, an indexer must key metadata by specVersion and use state_getRuntimeVersion at the block to know which metadata applies. Historical metadata is served by archive RPC or fetched from a metadata cache. See Reading runtime metadata with state_getMetadata.

  • Byte 0: extrinsic version/format.
  • Signed extrinsics: account, signature, era, nonce, tip, then call.
  • Call = pallet_index + call_index, resolvable only via metadata.
  • Key metadata by specVersion; indices drift across upgrades.

Where events live: System.Events, not the block body

Events are not stored in block.extrinsics. They are the System.Events storage item, written by the runtime during block execution. You read them at a specific block with state_getStorage using the key twox128('System') ++ twox128('Events') and the block hash, or you let the polkadot.js API surface them from the block. Each event carries a phase: Initialization, ApplyExtrinsic(index), or Finalization. The ApplyExtrinsic phase links an event to the extrinsic that produced it.

This phase linkage is the difference between the on-chain call and its effects. A single extrinsic can emit many events, and some events (like fees or treasury deposits) are emitted by the system rather than the caller. Reading events without their phase will make an explorer attribute effects to the wrong extrinsic.

  • Events are a storage item, not part of the SignedBlock body.
  • Read via state_getStorage(twox128('System')++twox128('Events'), blockHash).
  • Phase: Initialization | ApplyExtrinsic(index) | Finalization.
  • Use phase to map events back to the originating extrinsic.

Reading a specific block with @polkadot/api

The polkadot.js API wraps the raw RPC and handles metadata resolution for you. Use api.rpc.chain.getBlock(hash) to fetch the SignedBlock, then api.at(blockHash) to get a context pinned to that block for queries and events. api.query.system.events.at(blockHash) returns the events for that block, and api.rpc.state.getStorage reads a specific storage key at that hash.

The example below fetches a block by number, decodes a signed extrinsic's signer and nonce, and reads the events at that block. It is runnable against any Substrate endpoint that serves the block; replace the endpoint and block number.

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);

Raw fetch fallback: chain_getBlock plus metadata decoding

When you cannot use the API wrapper, fetch chain_getBlock and state_getMetadata directly, then decode with @polkadot/types against the metadata. The metadata must match the specVersion at the block. Use state_getRuntimeVersion at the block hash to confirm which metadata applies before decoding.

The raw path is useful for indexers that batch many blocks and want to avoid per-block API overhead. It also makes the metadata dependency explicit, which is exactly the failure point most explorers hit.

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 };
}

Storage changes at a block and why subscriptions do not backfill

Storage changes at a block are observed either by reading state_getStorage at that block hash or by using tracing where the node supports it. A plain block subscription (chain_subscribeNewHeads or finalized heads) only sees new blocks and never backfills a specific historical block. If you need a past block, you must call chain_getBlock at its hash. Full historical state requires an archive node; a pruned full node will return null for old storage keys.

This distinction matters for indexers. Subscriptions are for streaming new blocks; historical reads are for backfilling and auditing. Mixing them up produces empty events and null storage, which look like decoding bugs but are actually data-availability limits. See Polkadot and Substrate archive nodes for historical state.

  • state_getStorage(key, blockHash) reads a storage item at a block.
  • Subscriptions stream new blocks only; they do not backfill.
  • Historical state requires an archive node.
  • Pruned full nodes return null for old keys.

Results table: measure your own endpoint's block-data behavior

Endpoint behavior varies by provider and node type. Fill in the table below against your own endpoint to document what it actually serves. Do not assume a value; measure it. For provider-specific numbers, treat them as documented / varies by provider.

Run each check at a recent block and at an old block (for example, one from a previous runtime era) to expose pruning and metadata drift.

  • chain_getBlock at a recent hash → returns SignedBlock? (yes/no)
  • chain_getBlock at an old hash → returns SignedBlock? (yes/no)
  • state_getStorage(System.Events) at an old hash → returns data? (yes/no)
  • state_getRuntimeVersion at the block → specVersion value
  • state_getMetadata at the block → returns metadata? (yes/no)
  • Node type: archive or full? (archive/full)
  • Endpoint URL and provider: (fill in)

Common failures and how to diagnose them

Most block-data bugs fall into a few categories. 'Unable to decode' or an unknown pallet index almost always means the metadata version does not match the block's specVersion. Empty events usually mean you queried the latest state instead of the block. chain_getBlock returning null means the hash is unknown or the block is not yet finalized. Pruned state on a full node returns null for old storage keys. Runtime upgrade index drift means the same pallet_index maps to different pallets across specVersions.

Diagnose by checking the specVersion at the block, confirming the node type, and verifying you passed the block hash to every read. If you are submitting extrinsics and seeing dispatch errors, that is a different path; see Decoding Polkadot extrinsic submission errors.

  • Decode error → metadata/specVersion mismatch.
  • Empty events → queried latest, not the block.
  • chain_getBlock null → unknown or unfinalized hash.
  • Null storage → pruned state on a full node.
  • Index drift → runtime upgrade changed pallet/call indices.

Limitations and tradeoffs

Reading block data at a specific block is not free of constraints. Historical state is only available on archive nodes, and even then, metadata for old specVersions must be fetched or cached. Decoding raw SCALE requires the correct metadata, so an indexer must store metadata per specVersion. Tracing methods are not universally supported and may be disabled on public endpoints.

Subscriptions are efficient for new blocks but useless for backfill. Batch reads of many blocks increase payload size and may hit rate limits, so plan for pagination and retries. For endpoint selection and limits, see Polkadot RPC endpoints and providers (RPC Assistant) and RPC pricing.

  • Archive node required for historical state.
  • Metadata must be keyed by specVersion.
  • Tracing support varies by node.
  • Batch reads may hit rate limits.

Troubleshooting checklist

Work through this checklist before assuming a decoding bug. It separates data-availability problems from metadata problems and from query mistakes.

If a step fails, fix it before moving on; later steps depend on earlier ones.

  • Confirm the block hash with chain_getBlockHash(number).
  • Fetch chain_getBlock(hash) and verify block.extrinsics is non-empty.
  • Call state_getRuntimeVersion(hash) and record specVersion.
  • Fetch state_getMetadata(hash) and confirm it matches specVersion.
  • Decode extrinsics with that metadata; check pallet_index/call_index.
  • Read System.Events at the same hash; verify phases.
  • Check node type (archive vs full) for old blocks.
  • If null, try an archive endpoint or a different provider.

Next steps and where to go deeper

Once you can read a specific block correctly, the next step is to build a pipeline that keys metadata by specVersion and backfills from an archive node. For network-specific endpoints, start with Polkadot. For managed access and historical state, see the API service and Polkadot and Substrate archive nodes for historical state.

For broader context on RPC behavior, explore the OnFinality Learn hub. If you are comparing providers, the Polkadot RPC endpoints and providers (RPC Assistant) page lists options, and RPC pricing covers plan limits.

  • Key metadata by specVersion in your indexer.
  • Backfill from an archive node, stream new blocks via subscription.
  • Validate decoding against a known block explorer.
  • Monitor specVersion changes to catch index drift early.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started