igneum/site/api/stats.mjs
igneum-labs 87a329438c Explorer: /proof/<hash> verifies a block's shard proof in the browser against the chain's record; the native SP1 verdict beside it; P17 state words on the block and explorer pages; /api/stats names the live program class (C46)
What a stranger sees: paste a chain block hash on /proof, the page downloads the captured proof bytes
(1,272,897 bytes), hashes them in the tab against the proof_hash the signed record carries, parses the
328-byte public values out of the SP1 container and checks keccak against the record's statement and the
decoded fields against the block (site/lib/proof.mjs, no library). The STARK is verified by this site's
node (the observer runs igneum-prove-host --mode verify with the pinned key on each capture: 29 ms verify,
197 ms key setup on the fixture proof); the page says so and labels the in-browser STARK verifier as coming.
docs/plans/explorer.md section 8 carries the size and time numbers and the two routes (Groth16 wrap plus
sp1-verifier in wasm, or the compressed verifier ported to wasm32).

Observer: a sample of pool proofs captured through igneum_getProofBytes while the node holds them
(PROOF_CAPTURE_EVERY_MS, PROOF_BYTES_KEEP), checked and verified, written to live_proof_bytes; every
live_proofs row carries the record (key_hash, payout, statement, proof_hash); getBlockTemplate.powEpoch
read every 10 s into live_state.pow_epoch. RPC load: wrpc 230 to 248 per minute against 222 to 224 before,
evm unchanged.

P17: the node release 0.3.13 (bb43e9a8) does not carry the state field (it is on ledger-fixes-0311
fbb0082a), so the explorer cuts the one word from the observer's tables by the design 2.4 rule and takes the
node's word per transaction when the fork answers one. A block that left the selected chain reads included
with a note, never reorged out.

C46: /api/stats algorithm reads "class v3 / generator 3 (epoch 55; ...)" from the node's epoch line, v4
when the node reports 4, "unknown" before the observer has read it; new lottery field.

Tests: site/lib/proof.test.mjs (the real tail of block 59199's proof reproduces the host's statement),
site/api/verify.test.mjs, tools/observer/proof-capture.test.mjs (the native verifier refusing a pre-pin
proof), site/api/public-stats.test.mjs. Dry run on the fixture proof of block 56 through the local preview:
VERIFIED, 5.8 ms of checks and 139 ms of download in the browser, STARK 29 ms on the node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:15:44 +00:00

105 lines
8.2 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';
import { lotteryLine } from '../lib/proof.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', 'lottery', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source'];
export const LOTTERY_FIELDS = ['program_class', 'generator', 'next_program_class', 'epoch_index', 'epoch_seed', 'epoch_blocks', 'boundary_daa', 'class_v3_activation_daa', 'era_index', 'dataset_log2', 'read_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,
};
}
// The epoch line as the observer read it from getBlockTemplate.powEpoch (live_state.pow_epoch): the program class of
// the running epoch as a generator version (2, 3, later 4), the next epoch's class, the epoch index and seed.
export function lotteryOf(pe) {
if (!pe) return { program_class: null, generator: null, next_program_class: null, epoch_index: null, epoch_seed: null, epoch_blocks: null, boundary_daa: null, class_v3_activation_daa: null, era_index: null, dataset_log2: null, read_at: null, source: 'the observer has not read the node\'s epoch line yet (getBlockTemplate.powEpoch)' };
return {
program_class: num(pe.program_class), generator: num(pe.program_class), next_program_class: num(pe.next_program_class), epoch_index: num(pe.epoch_index), epoch_seed: pe.epoch_seed || null,
epoch_blocks: num(pe.epoch_blocks), boundary_daa: num(pe.boundary_daa), class_v3_activation_daa: num(pe.class_v3_activation_daa), era_index: num(pe.era_index), dataset_log2: num(pe.dataset_log2),
read_at: pe.read_at || null, source: 'getBlockTemplate.powEpoch read by the observer every 10 s; class v2 = generator 2, v3 = generator 3 (docs/spec/01-lottery-hash.md 1.4.6)',
};
}
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,
// the live class and generator from the node's epoch line (reviewer C46: never a fixed "generator v2"); class v4 reads as v4 when the node reports it
algorithm: lotteryLine(s && s.pow_epoch),
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,
lottery: lotteryOf(s && s.pow_epoch),
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();