igneum/site/api/stats.mjs
igneum-labs 303038a75d /api/stats gains proving (coverage_10m, provers, lag, verifier) from live_state.proving; the live page says whose verifier is off (reviewer C33)
A third party reading the public API could not see that proving coverage on the devnet is 2.7% of chain blocks with
one prover (13 of 476 in ten minutes at 22:33 UTC) while the homepage says every block is proven. The object is the one
/api/live reads, with coverage_10m = blocks_fully_proven_10m / blocks_10m and a note that the observer's node reads
the chain's payouts with its own verifier off. Documented with the live figures, in the contract test and the live check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 22:35:21 +00:00

111 lines
8.6 KiB
JavaScript

// Igneum public network stats. GET /api/stats: the numbers profitability sites and pool software read (WhatToMine's
// listing form asks for an explorer or pool with an API and a source for the block reward and the block time).
// Read from what tools/observer wrote to Neon (live_state, newest live_blocks row); the reward is computed from the
// emission rule (site/lib/emission.mjs, spec 2.5) at the node's DAA score. Cached 10 s at the edge. Documented with
// example responses in docs/api/public-stats.md; FIELDS below is the contract the CI check asserts.
import { neon, num, tablePrefix, chainIdOf, schemaGap } from './_neon.mjs';
import { blockSubsidy, rampFactor, HALVING_INTERVAL_SECONDS, LAUNCH_RAMP_SECONDS, PROVING_POOL_SHARE_PERCENT, sompiToIgn } from '../lib/emission.mjs';
export const STALE_AFTER_S = 30;
export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'node_version', 'algorithm', 'stale', 'age_s', 'height', 'block_count', 'header_count', 'daa', 'blue_score',
'difficulty', 'hashrate', 'hashrate_unit', 'hashrate_source', 'block_time_target_s', 'block_time_measured_s', 'blocks_per_day_target', 'blocks_per_day_measured',
'block_reward', 'last_block', 'finality', 'proving', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source'];
export const PROVING_FIELDS = ['supported', 'active', 'activation_daa', 'blocks_10m', 'blocks_fully_proven_10m', 'shards_proven_10m', 'shards_paid_10m', 'provers_10m', 'median_proof_lag_s', 'coverage_10m', 'verifier', 'pool_pending', 'note'];
export const REWARD_FIELDS = ['sompi', 'ign', 'miner_ign', 'proving_pool_ign', 'split', 'daa_used', 'ramp_factor', 'halving_period', 'next_halving_daa', 'next_halving_in_s'];
export const LAST_BLOCK_FIELDS = ['hash', 'time', 'ts_ms', 'age_s', 'blue_score', 'daa', 'miner', 'miner_id', 'tx_count', 'chain'];
export function rewardAt(daa) {
const sompi = blockSubsidy(daa, 1);
const pool = sompi * PROVING_POOL_SHARE_PERCENT / 100n;
const period = Math.floor(daa / Number(HALVING_INTERVAL_SECONDS));
const nextHalving = (period + 1) * Number(HALVING_INTERVAL_SECONDS);
return {
sompi: sompi.toString(), ign: sompiToIgn(sompi), miner_ign: sompiToIgn(sompi - pool), proving_pool_ign: sompiToIgn(pool),
split: '80% block producer, 20% proving pool', daa_used: daa, ramp_factor: Math.round(rampFactor(daa) * 1e6) / 1e6,
halving_period: period, next_halving_daa: nextHalving, next_halving_in_s: nextHalving - daa,
};
}
// Proving v0 as the observer reports it (live_state.proving, the same object /api/live and /live read): the last ten
// minutes of chain blocks, how many had every shard proven and paid, and by how many provers. coverage_10m is
// blocks_fully_proven_10m / blocks_10m. The observer's node runs with its SP1 verifier off: it reads the chain's
// payouts (a shard counts as paid when a later chain block paid it), not its own verification.
export function provingOf(pv) {
if (!pv || !pv.supported) return { supported: false, active: false, activation_daa: null, blocks_10m: null, blocks_fully_proven_10m: null, shards_proven_10m: null, shards_paid_10m: null, provers_10m: null, median_proof_lag_s: null, coverage_10m: null, verifier: null, pool_pending: null, note: pv && pv.reason ? pv.reason : 'the observer has not reported the proving layer' };
const blocks = num(pv.blocks_10m), full = num(pv.blocks_fully_proven_10m);
return {
supported: true, active: !!pv.active, activation_daa: num(pv.activation_daa),
blocks_10m: blocks, blocks_fully_proven_10m: full, shards_proven_10m: num(pv.shards_proven_10m), shards_paid_10m: num(pv.shards_paid_10m), provers_10m: num(pv.provers_10m),
median_proof_lag_s: num(pv.median_proof_lag_s),
coverage_10m: blocks ? Math.round(full / blocks * 10000) / 10000 : null,
verifier: pv.verifier || null, pool_pending: pv.pool ? num(pv.pool.pending) : null,
note: 'chain blocks of the last 10 minutes and how many had every shard proven and paid; the observer\'s node reads the chain\'s payouts with its own verifier off, so a paid shard means a carrying block paid it, not that this node verified it',
};
}
export function shape({ now, state: s, last, miners10m }) {
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
const age = updated === null ? null : Math.max(0, (now - updated) / 1000);
const stale = age === null || age > STALE_AFTER_S;
const daa = s ? num(s.daa_score ?? (last && last.daa_score)) : null;
const bpm = (s && s.blocks_per_minute) || [];
const lastHour = bpm.reduce((a, [, n]) => a + (n || 0), 0);
const b60 = s ? num(s.blocks_60s) : null;
return {
ok: true, now: new Date(now).toISOString(),
network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, node_version: s ? s.node_version : null,
algorithm: 'Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)',
stale, age_s: age === null ? null : Math.round(age * 10) / 10,
// height is the chain block number (the EVM block number); block_count counts every DAG block the node holds
height: last && last.number !== null && last.number !== undefined ? num(last.number) : null,
block_count: s ? num(s.block_count) : null, header_count: s ? num(s.header_count) : null,
daa, blue_score: s ? num(s.blue_score) : null, difficulty: s ? num(s.difficulty) : null,
hashrate: s ? num(s.hashes_per_second_estimate) : null, hashrate_unit: 'H/s',
hashrate_source: 'the node\'s estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window',
block_time_target_s: 1,
block_time_measured_s: b60 ? Math.round(60 / b60 * 1000) / 1000 : null,
blocks_per_day_target: 86400,
blocks_per_day_measured: bpm.length >= 60 ? lastHour * 24 : null,
block_reward: daa === null ? null : rewardAt(daa),
last_block: last ? {
hash: last.hash, time: new Date(num(last.timestamp_ms)).toISOString(), ts_ms: num(last.timestamp_ms), age_s: Math.round((now - num(last.timestamp_ms)) / 100) / 10,
blue_score: num(last.blue_score), daa: num(last.daa_score), miner: last.miner_address || null, miner_id: last.vote_key_hash ? String(last.vote_key_hash).slice(0, 8) : null,
tx_count: num(last.tx_count), chain: !!last.is_chain_block,
} : null,
finality: s && s.finality ? { active: !!s.finality.finality_active, latest_locked_index: num(s.finality.latest_locked_index), latest_locked_blue_score: num(s.finality.latest_locked_blue_score), chain_id: s.finality.chain_id || null } : { active: false, latest_locked_index: null, latest_locked_blue_score: null, chain_id: null },
proving: provingOf(s && s.proving),
peers: s ? num(s.peers) : null, mempool: s ? num(s.mempool) : null, miners_10m: miners10m,
observer_updated_at: s ? s.updated_at : null,
source: 'tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node\'s DAA score',
};
}
export function createHandler({ env = process.env, sql } = {}) {
return async function handler(req, res) {
res.setHeader('Cache-Control', 'public, max-age=10, s-maxage=10');
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
try {
sql = sql || neon(env.DATABASE_URL);
const T = tablePrefix();
const [head, lastRows, minerRows] = await Promise.all([
sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`),
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, miner_address, vote_key_hash, tx_count, is_chain_block,
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS number
FROM ${T}live_blocks ORDER BY received_at DESC LIMIT 1`),
sql(`SELECT count(DISTINCT vote_key_hash)::int AS n FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes' AND vote_key_hash IS NOT NULL`),
]);
const now = new Date(head[0].now).getTime();
const s = head[0].state || null;
const last = lastRows[0] || null;
if (s && last && (s.daa_score === undefined)) s.daa_score = last.daa_score; // live_state has no DAA column; the newest block's is the node's
return res.status(200).json(shape({ now, state: s, last, miners10m: minerRows[0] ? minerRows[0].n : 0 }));
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
const gap = schemaGap(e);
if (gap) return res.status(503).json({ ok: false, error: gap });
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
};
}
export default createHandler();