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

Bittensor Subnet Tempo, Epochs, and Emission over RPC

Read a Bittensor subnet's clock — tempo, epoch index, blocks since last step, and emission — directly from a Subtensor node over JSON-RPC.

TL;DR

Bittensor's subnet clock is not a wall-clock timer: a Subtensor chain advances one global block height, while each subnet independently counts blocks since its last tempo step. When that counter reaches the subnet's tempo hyperparameter, the subnet's epoch index increments and alpha emission is distributed to that subnet's emissions pool. Reading this over RPC means separating three clocks — chain block height, per-subnet tempo counter and epoch index, and any wall-clock estimate derived from them. The concrete read path is Substrate JSON-RPC state_getStorage with a twox128(twox128(prefix) + twox128(item) + optional blake2_128_concat(map_key)) key, plus chain_getHeader for block height. This guide shows the typed @polkadot/api accessors, a runnable Node.js example, a block-time sampling method, and a troubleshooting playbook for null returns, wrong netuids, SCALE decode failures, and pruned historical storage.

The Bittensor subnet clock: tempo as a block interval, not a duration

Bittensor's Subtensor chain advances a single global chain block number on every block. Independently, each subnet counts blocks since its own last tempo step. When that per-subnet counter reaches the subnet's tempo hyperparameter, the subnet's epoch index increments and alpha emission is distributed to that subnet's emissions pool. The Bittensor documentation describes tempo as a per-subnet hyperparameter measured in blocks, and an epoch as the interval between two tempo steps; the Subtensor pallet source (opentensor/subtensor, pallets/subtensor/src/lib.rs) is the source of truth for the SubnetEmission and Epoch storage items that hold this state.

Because tempo is a block interval, the wall-clock length of an epoch is not fixed. If block time varies, the same tempo value produces different real-world epoch durations. This is the single most important distinction for anyone building dashboards, alerting, or emission accounting on top of Subtensor: you are reading a block-counting machine, and any seconds value you display is a derived estimate.

The three clocks a reader must keep separate are: (1) the chain block height — one global clock for the whole chain; (2) the tempo counter and epoch index — one clock per subnet, keyed by netuid; and (3) any wall-clock estimate derived from those two. Conflating them is the most common operational error, and it usually shows up as an epoch countdown that drifts, or an emission projection that assumes a constant block time.

  • Chain block height: global, monotonic, read via chain_getHeader.
  • Tempo counter and epoch index: per-subnet, keyed by netuid, read from Subtensor pallet storage.
  • Wall-clock estimate: derived, backward-looking, and only as good as your sampled block time.
  • Tempo is documented / varies by subnet — do not hard-code a single global constant.

Epoch advancement and the emission pool split

When a subnet's tempo counter reaches its tempo, the epoch index increments and alpha emission is distributed to that subnet's emissions pool. The Bittensor documentation's Emissions page describes the emission pool split, and the 'The V440 Upgrade - The Emission Gate' material describes how the emission gate changes distribution. Critically, these parameters change how emission is distributed, not when the clock ticks. The clock is tempo; the split is policy.

This separation matters for RPC readers because it means you can read the clock (tempo, epoch index, blocks since last step) without needing to model the emission split, and you can read emission state without needing to model the clock. The two surfaces are related but independently readable. For per-UID weights, dividends, and emission reads, see Reading Bittensor metagraph state: weights and emission.

The Subtensor pallet stores this state under storage items such as SubnetEmission and Epoch. The exact item names and map keys are defined in the pallet source; treat the Rust source as authoritative and the documentation as explanatory. Third-party glossaries such as the Taostats documentation glossary and hyperparameter descriptions are useful cross-checks, but they are not primary sources.

  • Tempo controls when the epoch advances.
  • Emission split parameters (including the emission gate and V440 changes) control how emission is distributed.
  • Documented / varies by subnet: tempo values differ across subnets and can be governor-controlled.

The Substrate JSON-RPC read path for tempo and epoch state

Subtensor exposes Substrate JSON-RPC methods. The two you need for the clock are state_getStorage and chain_getHeader. state_getStorage takes a storage key and returns SCALE-encoded bytes; chain_getHeader returns the current block header, from which you read the block number. The Substrate JSON-RPC specification (paritytech.github.io/json-rpc and docs.polkadot.com JSON-RPC APIs) is the authoritative reference for these method semantics.

A storage key is constructed as twox128(storage_prefix) + twox128(storage_item) + optional blake2_128_concat(map_key). For a per-subnet value keyed by netuid, the map key is the SCALE-encoded netuid, hashed with blake2_128_concat. Constructing this by hand is error-prone; the practical approach is to use @polkadot/api's typed accessors, which build the key and decode the result for you. If you need to verify the raw key, use state_getKeys with a prefix to discover the exact key layout on your target runtime.

Decoding matters because state_getStorage returns raw SCALE bytes. If you use typed accessors, decoding is handled. If you call state_getStorage directly, you must decode the returned bytes according to the storage item's type as defined in the pallet source. A mismatch between the runtime version and your decoder is a common source of SCALE decode failures; see Substrate state_getMetadata and runtime versions for how to pin your decoder to the runtime.

  • state_getStorage: read a storage item by key, returns SCALE bytes.
  • state_getKeys: discover keys under a prefix, useful for verifying layout.
  • chain_getHeader: read the current block height.
  • system_chain: confirm you are connected to the expected chain.

Runnable Node.js example: reading tempo, epoch index, and blocks since last step

The example below connects to a Subtensor WSS endpoint, reads the chain head, reads tempo, epoch index, and blocks since last step for a given netuid, and derives blocks remaining in the epoch plus a wall-clock estimate using an average block time measured over a sampled window. Replace the endpoint with your own Subtensor WSS endpoint; the Bittensor Finney network page describes the network, and the Bittensor RPC guide (RPC Assistant) covers endpoint selection.

The accessor names used here (api.query.subtensorModule.*) are the typed accessors generated from the Subtensor runtime metadata. If your runtime version exposes different item names, inspect api.query.subtensorModule at runtime or consult the pallet source. The block-time sampling reads chain_getHeader for the last N blocks and divides, so the wall-clock estimate is measured rather than assumed.

Blocks since last step is a per-subnet counter maintained by the Subtensor runtime; it is not derivable from the global chain block number, because tempo steps are scheduled per subnet and can be offset from the global block phase. The example below therefore reads the counter from storage rather than computing head % tempo, and it prints the available subtensorModule accessor names filtered by /step|tempo|epoch/i so you can discover the exact item name on your runtime.

// npm i @polkadot/api
import { ApiPromise, WsProvider } from '@polkadot/api';

const ENDPOINT = 'wss://your-subtensor-endpoint';
const NETUID = 1;            // subnet you are reading
const SAMPLE_BLOCKS = 100;   // block-time sampling window

async function main() {
  const api = await ApiPromise.create({ provider: new WsProvider(ENDPOINT) });
  console.log('chain:', (await api.rpc.system.chain()).toString());

  // 1. Global clock: the chain head block number.
  const header = await api.rpc.chain.getHeader();
  const head = header.number.toNumber();
  console.log('head block:', head);

  // 2. Per-netuid clock. These two accessors are the stable part of the read path.
  const tempo = await api.query.subtensorModule.tempo(NETUID);
  const epoch = await api.query.subtensorModule.epoch(NETUID);
  const tempoBlocks = tempo.toNumber();
  const epochIndex = epoch.toNumber();
  console.log('tempo (blocks):', tempoBlocks);
  console.log('epoch index:', epochIndex);

  // 3. Blocks since the last tempo step is NOT derivable as head % tempo. The counter is
  //    per-subnet and its phase is independent of the global block number. Discover the
  //    accessor on the runtime you are connected to instead of hard-coding a guess:
  const stepAccessors = Object.keys(api.query.subtensorModule).filter((k) =>
    /step|epoch|block/i.test(k)
  );
  console.log('candidate accessors on this runtime:', stepAccessors);
  // Cross-check the printed list against the pallet storage items in the Subtensor source
  // (SubnetEmission / Epoch) before selecting one.

  // 4. Measure block time from real timestamps at the window boundaries.
  const fromNumber = Math.max(1, head - SAMPLE_BLOCKS);
  const fromHash = await api.rpc.chain.getBlockHash(fromNumber);
  const fromBlockNumber = (await api.rpc.chain.getHeader(fromHash)).number.toNumber();
  const blockDelta = head - fromBlockNumber;

  const tsNow = (await api.query.timestamp.now.at(await api.rpc.chain.getBlockHash(head))).toNumber();
  const tsFrom = (await api.query.timestamp.now.at(fromHash)).toNumber();
  const elapsedSeconds = (tsNow - tsFrom) / 1000;
  const avgBlockTime = elapsedSeconds / blockDelta;
  console.log('sampled window (blocks):', blockDelta);
  console.log('avg block time (s):', avgBlockTime.toFixed(3));

  // 5. Derive the epoch ETA once you have a verified counter value.
  //    Example wiring once blocksSinceLastStep is known:
  //      const blocksRemaining = Math.max(0, tempoBlocks - blocksSinceLastStep);
  //      const etaSeconds = blocksRemaining * avgBlockTime;
  console.log('tempo is a block interval; the seconds value above is a backward-looking estimate');

  await api.disconnect();
}

main().catch((e) => { console.error(e); process.exit(1); });

Results table: measure against your own endpoint

Fill in the table below against your own Subtensor endpoint. Do not copy values from this article; the point is to produce a reproducible measurement you can re-run. Record the chain name, head block, netuid, tempo, epoch index, blocks since last step, blocks remaining, sampled average block time, and estimated seconds to next epoch. Re-run at two different times and compare the epoch index delta to confirm the clock is advancing as expected.

If the epoch index does not change across a window longer than tempo times your sampled block time, treat that as a signal to investigate: check that you are reading the correct netuid, that your endpoint is not serving stale or pruned state, and that your decoder matches the runtime version.

  • chain: system_chain result
  • head block: chain_getHeader number
  • netuid: the subnet you are reading
  • tempo (blocks): subtensorModule.tempo(netuid)
  • epoch index: subtensorModule.epoch(netuid)
  • blocks since last step: read from the per-subnet counter in subtensorModule storage
  • blocks remaining in epoch: tempo minus blocks since last step
  • sampled average block time (s): measured over your window
  • estimated seconds to next epoch: blocks remaining times sampled block time

Block-time sampling method for wall-clock estimates

The wall-clock estimate is only as good as the block time you feed it. The method is simple: read chain_getHeader for the last N blocks, take the block number delta, and divide by the timestamp delta. On Substrate chains, the timestamp is available via the timestamp pallet (api.query.timestamp.now) at a given block hash, or from the block's inherent extrinsics. Use a window large enough to smooth out jitter but small enough to reflect current conditions.

Because this is a backward-looking average, it will lag regime changes in block production. If block time shifts, your estimate will be wrong until the window rolls over. State this limitation explicitly in any dashboard you build. For reading storage changes across blocks rather than a single point, see Polkadot state_queryStorageAt: reading storage changes at a block.

A practical pattern is to sample block time on a schedule, cache it, and recompute the epoch ETA from the cached value plus the current blocks remaining. This avoids hammering the endpoint with header reads on every request while keeping the estimate fresh.

  • Sample window: last N blocks, N chosen for your latency tolerance.
  • Read timestamps at the window boundaries, not per block, to reduce calls.
  • Cache the average and refresh on a schedule.
  • Document that the estimate is backward-looking and will lag regime changes.

Troubleshooting: null returns, wrong netuid, decode failures, and pruned state

Null storage returns are the most common symptom. A null from state_getStorage usually means the key is wrong, the netuid does not exist, or the storage item is not present at that block. Verify the storage prefix and item name against the pallet source, and use state_getKeys with a prefix to discover the actual key layout on your target runtime. If you are using typed accessors, a null often means the netuid is out of range.

Wrong netuid is a frequent cause of confusing results. Tempo and epoch are per-subnet; reading netuid 0 when you meant netuid 1 will return a different subnet's clock. Confirm the netuid against the metagraph or the subnet's registration state before trusting the values.

SCALE decode failures typically come from a decoder that does not match the runtime version. Pin your decoder to the runtime metadata and re-check after runtime upgrades. Pruned historical storage on non-archive nodes is another trap: if you query state at an old block on a node that does not retain it, you will get null or an error. Use an archive node for historical reads, or query at the latest block. Finally, some endpoints reject state_* methods entirely; if state_getStorage fails with a method-not-found error, switch to an endpoint that exposes the full Substrate JSON-RPC surface. For timeout-related failures, see Bittensor RPC timeout errors on Subtensor.

  • Null return: wrong key, nonexistent netuid, or item absent at that block.
  • Wrong netuid: tempo and epoch are per-subnet; verify the netuid.
  • SCALE decode failure: decoder does not match runtime version.
  • Pruned historical storage: use an archive node for old blocks.
  • Method rejected: endpoint does not expose state_* methods.

Limitations and tradeoffs of reading the subnet clock over RPC

Tempo is governor-controlled and can change. A subnet's tempo is a hyperparameter, documented / varies by subnet, and not a global constant. Any code that hard-codes a tempo value will break when the governor changes it. Read tempo from storage on every evaluation, or cache it with a short TTL.

Some subnets run at very short tempos, which means the epoch index can advance quickly and your sampling interval must be short enough to observe it. Conversely, long tempos mean a single missed sample can look like a stalled clock. Emission split parameters such as the emission gate and the V440 upgrade change the distribution, not the clock; do not infer clock behavior from emission changes. Finally, the block-time estimate is a backward-looking average and will lag regime changes in block production.

  • Tempo is governor-controlled: documented / varies by subnet.
  • Short tempos require short sampling intervals.
  • Emission gate and V440 change distribution, not the clock.
  • Block-time estimate is backward-looking and lags regime changes.

Next steps: separating the three Bittensor read surfaces

This page covers the tempo/epoch timing surface. The other two Bittensor read surfaces are cleanly separated: per-UID weights, dividends, and emission reads are covered in Reading Bittensor metagraph state: weights and emission, and delegation, alpha-stake, and pool state are covered in Bittensor staking state over RPC. Keeping these surfaces distinct prevents the common mistake of mixing clock reads with emission accounting.

For endpoint selection and general Subtensor RPC usage, start with the Bittensor RPC guide (RPC Assistant). For infrastructure that exposes the full Substrate JSON-RPC surface, see the Bittensor Finney network page, the API service, and RPC pricing. More protocol deep-dives are indexed in the OnFinality Learn hub.

  • Timing surface: this page — tempo, epoch, blocks since last step.
  • Metagraph surface: weights, dividends, per-UID emission.
  • Staking surface: delegation, alpha-stake, pool state.
  • Endpoint and pricing: Bittensor Finney, API service, RPC pricing.

Never Worry about Infrastructure Again

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

Get Started