Bittensor 的子网时钟并非墙钟计时器:Subtensor 链推进单一的全局区块高度,而每个子网独立计算自上次 tempo 步进以来的区块数。当该计数器达到子网的 tempo 超参数时,子网的 epoch 索引递增,alpha emission 被分配到该子网的 emissions 池。通过 RPC 读取这意味着要区分三个时钟——链区块高度、每个子网的 tempo 计数器与 epoch 索引,以及由它们推导出的任何墙钟估算。具体的读取路径是 Substrate JSON-RPC 的 state_getStorage,使用 twox128(twox128(prefix) + twox128(item) + 可选的 blake2_128_concat(map_key)) 键,外加 chain_getHeader 获取区块高度。本指南展示类型化的 @polkadot/api 访问器、可运行的 Node.js 示例、区块时间采样方法,以及针对 null 返回、错误 netuid、SCALE 解码失败和已修剪历史存储的故障排查手册。
Bittensor 子网时钟:tempo 是区块间隔,而非时间长度
Bittensor 的 Subtensor 链在每个区块上推进单一的全局链区块号。与此同时,每个子网独立计算自其上次 tempo 步进以来的区块数。当该子网计数器达到子网的 tempo 超参数时,子网的 epoch 索引递增,alpha emission 被分配到该子网的 emissions 池。Bittensor 文档将 tempo 描述为以区块为单位的子网超参数,将 epoch 描述为两次 tempo 步进之间的间隔;Subtensor pallet 源码(opentensor/subtensor,pallets/subtensor/src/lib.rs)是保存此状态的 SubnetEmission 和 Epoch 存储项的权威来源。
由于 tempo 是区块间隔,epoch 的墙钟长度并不固定。如果区块时间变化,相同的 tempo 值会产生不同的实际 epoch 时长。对于任何在 Subtensor 之上构建仪表盘、告警或 emission 核算的人来说,这是最重要的区别:你读取的是一台计块机器,你显示的任何秒数都是推导出的估算值。
读者必须区分三个时钟:(1) 链区块高度——整条链的单一全局时钟;(2) tempo 计数器与 epoch 索引——每个子网一个时钟,以 netuid 为键;(3) 由前两者推导出的任何墙钟估算。混淆它们是常见的操作错误,通常表现为 epoch 倒计时漂移,或假设区块时间恒定的 emission 预测。
- 链区块高度:全局、单调递增,通过 chain_getHeader 读取。
- Tempo 计数器与 epoch 索引:每个子网独立,以 netuid 为键,从 Subtensor pallet 存储读取。
- 墙钟估算:推导得出、向后看,其准确性取决于你采样的区块时间。
- Tempo 有文档说明 / 因不同子网而异——不要硬编码单一的全局常量。
Epoch 推进与 emission 池分配
当子网的 tempo 计数器达到其 tempo 时,epoch 索引递增,alpha emission 被分配到该子网的 emissions 池。Bittensor 文档的 Emissions 页面描述了 emission 池分配,而“The V440 Upgrade - The Emission Gate”材料描述了 emission gate 如何改变分配。关键在于,这些参数改变的是 emission 的分配方式,而非时钟何时跳动。时钟是 tempo;分配是策略。
这种分离对 RPC 读者很重要,因为它意味着你可以读取时钟(tempo、epoch 索引、距上次步进的区块数)而无需建模 emission 分配,也可以读取 emission 状态而无需建模时钟。这两个表面相关但可独立读取。关于每个 UID 的权重、分红和 emission 读取,请参阅读取 Bittensor metagraph 状态:权重与 emission。
Subtensor pallet 将这些状态存储在诸如 SubnetEmission 和 Epoch 的存储项下。确切的项名称和映射键定义在 pallet 源码中;将 Rust 源码视为权威,文档视为解释。第三方词汇表如 Taostats 文档词汇表与超参数描述是有用的交叉核对,但它们不是一手来源。
- Tempo 控制 epoch 何时推进。
- Emission 分配参数(包括 emission gate 和 V440 变更)控制 emission 如何分配。
- 有文档说明 / 因不同子网而异:tempo 值在不同子网间不同,且可能受治理控制。
读取 tempo 与 epoch 状态的 Substrate JSON-RPC 路径
Subtensor 暴露 Substrate JSON-RPC 方法。时钟所需的两个方法是 state_getStorage 和 chain_getHeader。state_getStorage 接受存储键并返回 SCALE 编码的字节;chain_getHeader 返回当前区块头,从中读取区块号。Substrate JSON-RPC 规范(paritytech.github.io/json-rpc 和 docs.polkadot.com JSON-RPC APIs)是这些方法语义的权威参考。
存储键的构造为 twox128(storage_prefix) + twox128(storage_item) + 可选的 blake2_128_concat(map_key)。对于以 netuid 为键的每个子网值,映射键是 SCALE 编码的 netuid,用 blake2_128_concat 哈希。手动构造容易出错;实用方法是使用 @polkadot/api 的类型化访问器,它会为你构建键并解码结果。如果你需要验证原始键,使用带前缀的 state_getKeys 来发现目标运行时上的确切键布局。
解码很重要,因为 state_getStorage 返回原始 SCALE 字节。如果使用类型化访问器,解码会被处理。如果直接调用 state_getStorage,你必须根据 pallet 源码中定义的存储项类型解码返回的字节。运行时版本与解码器不匹配是 SCALE 解码失败的常见原因;关于如何将解码器固定到运行时,请参阅 Substrate state_getMetadata 与运行时版本。
- state_getStorage:按键读取存储项,返回 SCALE 字节。
- state_getKeys:发现前缀下的键,用于验证布局。
- chain_getHeader:读取当前区块高度。
- system_chain:确认你连接到了预期的链。
可运行的 Node.js 示例:读取 tempo、epoch 索引和距上次步进的区块数
下面的示例连接到 Subtensor WSS 端点,读取链头,读取给定 netuid 的 tempo、epoch 索引和距上次步进的区块数,并使用在采样窗口上测得的平均区块时间推导 epoch 剩余区块数以及墙钟估算。将端点替换为你自己的 Subtensor WSS 端点;Bittensor Finney 网络页面描述了该网络,Bittensor RPC 指南(RPC Assistant)涵盖了端点选择。
此处使用的访问器名称(api.query.subtensorModule.*)是从 Subtensor 运行时元数据生成的类型化访问器。如果你的运行时版本暴露不同的项名称,请在运行时检查 api.query.subtensorModule 或查阅 pallet 源码。区块时间采样读取最近 N 个区块的 chain_getHeader 并相除,因此墙钟估算是测量得出的而非假设的。
距上次步进的区块数是 Subtensor 运行时维护的每个子网计数器;它不能从全局链区块号推导,因为 tempo 步进是按子网调度的,可能与全局区块相位有偏移。因此下面的示例从存储读取计数器,而不是计算 head % tempo,并打印按 /step|tempo|epoch/i 过滤的可用 subtensorModule 访问器名称,以便你在自己的运行时上发现确切的项名称。
// 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); });结果表:针对你自己的端点进行测量
针对你自己的 Subtensor 端点填写下表。不要复制本文中的值;重点是产生可重复运行的测量结果。记录链名称、链头区块、netuid、tempo、epoch 索引、距上次步进的区块数、剩余区块数、采样的平均区块时间以及到下一个 epoch 的估算秒数。在两个不同时间重新运行,并比较 epoch 索引增量,以确认时钟按预期推进。
如果在一个长于 tempo 乘以采样区块时间的窗口内 epoch 索引没有变化,请将其视为需要调查的信号:检查你读取的 netuid 是否正确、你的端点是否提供陈旧或已修剪的状态,以及你的解码器是否与运行时版本匹配。
- chain:system_chain 结果
- head block:chain_getHeader 的 number
- netuid:你正在读取的子网
- tempo(区块):subtensorModule.tempo(netuid)
- epoch 索引:subtensorModule.epoch(netuid)
- 距上次步进的区块数:从 subtensorModule 存储中的每个子网计数器读取
- epoch 剩余区块数:tempo 减去距上次步进的区块数
- 采样的平均区块时间(秒):在你的窗口上测量
- 到下一个 epoch 的估算秒数:剩余区块数乘以采样区块时间
用于墙钟估算的区块时间采样方法
墙钟估算的准确性取决于你输入的区块时间。方法很简单:读取最近 N 个区块的 chain_getHeader,取区块号增量,除以时间戳增量。在 Substrate 链上,时间戳可通过 timestamp pallet(api.query.timestamp.now)在给定区块哈希处获取,或从区块的固有外部交易中获取。使用足够大的窗口以平滑抖动,但足够小以反映当前状况。
由于这是向后看的平均值,它会滞后于区块生产的机制变化。如果区块时间发生变化,你的估算在窗口滚动之前将是错误的。在你构建的任何仪表盘中明确说明这一限制。关于跨区块读取存储变化而非单点读取,请参阅 Polkadot state_queryStorageAt:在某个区块读取存储变化。
一个实用的模式是按计划采样区块时间,将其缓存,并根据缓存值加上当前剩余区块数重新计算 epoch ETA。这避免了在每个请求上都用区块头读取冲击端点,同时保持估算新鲜。
- 采样窗口:最近 N 个区块,N 根据你的延迟容忍度选择。
- 在窗口边界读取时间戳,而非逐块读取,以减少调用。
- 缓存平均值并按计划刷新。
- 记录该估算是向后看的,会滞后于机制变化。
故障排查:null 返回、错误 netuid、解码失败和已修剪状态
Null 存储返回是最常见的症状。state_getStorage 返回 null 通常意味着键错误、netuid 不存在,或该区块上不存在该存储项。对照 pallet 源码验证存储前缀和项名称,并使用带前缀的 state_getKeys 来发现目标运行时上的实际键布局。如果你使用类型化访问器,null 通常意味着 netuid 超出范围。
错误的 netuid 是导致结果混乱的常见原因。Tempo 和 epoch 是按子网独立的;当你想要 netuid 1 时读取 netuid 0 会返回不同子网的时钟。在信任这些值之前,对照 metagraph 或子网的注册状态确认 netuid。
SCALE 解码失败通常来自与运行时版本不匹配的解码器。将解码器固定到运行时元数据,并在运行时升级后重新检查。非归档节点上已修剪的历史存储是另一个陷阱:如果你在不保留该状态的节点上查询旧区块的状态,你会得到 null 或错误。使用归档节点进行历史读取,或在最新区块查询。最后,某些端点完全拒绝 state_* 方法;如果 state_getStorage 失败并报方法未找到错误,请切换到暴露完整 Substrate JSON-RPC 表面的端点。关于超时相关的失败,请参阅 Subtensor 上的 Bittensor RPC 超时错误。
- Null 返回:键错误、netuid 不存在,或该区块上项不存在。
- 错误 netuid:tempo 和 epoch 是按子网独立的;验证 netuid。
- SCALE 解码失败:解码器与运行时版本不匹配。
- 已修剪的历史存储:对旧区块使用归档节点。
- 方法被拒绝:端点未暴露 state_* 方法。
通过 RPC 读取子网时钟的局限性与权衡
Tempo 受治理控制且可能变化。子网的 tempo 是超参数,有文档说明 / 因不同子网而异,并非全局常量。任何硬编码 tempo 值的代码在治理更改它时都会失效。在每次评估时从存储读取 tempo,或使用短 TTL 缓存它。
某些子网以非常短的 tempo 运行,这意味着 epoch 索引可能快速推进,你的采样间隔必须足够短才能观察到它。相反,长 tempo 意味着单个错过的采样可能看起来像时钟停滞。Emission 分配参数如 emission gate 和 V440 升级改变的是分配,而非时钟;不要从 emission 变化推断时钟行为。最后,区块时间估算是向后看的平均值,会滞后于区块生产的机制变化。
- Tempo 受治理控制:有文档说明 / 因不同子网而异。
- 短 tempo 需要短采样间隔。
- Emission gate 和 V440 改变分配,而非时钟。
- 区块时间估算是向后看的,会滞后于机制变化。
下一步:区分三个 Bittensor 读取表面
本页涵盖 tempo/epoch 计时表面。另外两个 Bittensor 读取表面被清晰分离:每个 UID 的权重、分红和 emission 读取在读取 Bittensor metagraph 状态:权重与 emission中涵盖,委托、alpha-stake 和池状态在通过 RPC 读取 Bittensor 质押状态中涵盖。保持这些表面分离可防止将时钟读取与 emission 核算混淆的常见错误。
关于端点选择和一般 Subtensor RPC 使用,请从 Bittensor RPC 指南(RPC Assistant)开始。关于暴露完整 Substrate JSON-RPC 表面的基础设施,请参阅 Bittensor Finney 网络页面、API 服务和 RPC 定价。更多协议深度解析索引在 OnFinality Learn 中心。
- 计时表面:本页——tempo、epoch、距上次步进的区块数。
- Metagraph 表面:权重、分红、每个 UID 的 emission。
- 质押表面:委托、alpha-stake、池状态。
- 端点与定价:Bittensor Finney、API 服务、RPC 定价。