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>
111 lines
8.6 KiB
JavaScript
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();
|