igneum/site/api/stats.mjs
igneum-labs 48d987cfb4 Public stats API: /api/stats and /api/supply, documented with live examples, contract test and a live check
The numbers profitability sites and pool software read (WhatToMine's form: explorer or pool with an API, the halving
schedule, a source for total coins). Reward and supply from the emission rule at the node's DAA score; the halving table
(33 rows), the 30-day ramp and the observer's coinbase check. Cached 10 s, CORS open. FIELDS in each handler is the
contract; public-stats.test.mjs checks it from a fixture, tools/ci/public-api-check.mjs checks a deployment.

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

90 lines
6.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 } 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', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source'];
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,
};
}
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 },
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');
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
};
}
export default createHandler();