// 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();