diff --git a/docs/api/public-stats.md b/docs/api/public-stats.md new file mode 100644 index 00000000..d41fb08a --- /dev/null +++ b/docs/api/public-stats.md @@ -0,0 +1,227 @@ +# Public stats API + +5 October 2026. Two JSON endpoints on the site for profitability sites, pool software and anyone who wants the +network numbers without running a node: `/api/stats` and `/api/supply`. WhatToMine's listing form asks for an +explorer or pool with an API, the reward halving schedule and a source to fetch total coins from; this is that source. +A third endpoint, `/api/explorer`, feeds the explorer pages (docs/plans/explorer.md). + +Both are served by Vercel functions (`site/api/stats.mjs`, `site/api/supply.mjs`) that read what the devnet observer +(`tools/observer/observer.mjs`) wrote to Neon; no secret is involved beyond the database connection the site already +holds. Cached 10 s at the edge (`Cache-Control: public, max-age=10, s-maxage=10`), CORS open (`Access-Control-Allow-Origin: *`), +GET only. A failed read answers 500 with `{ok: false, error}` and `Cache-Control: no-store`. + +The contract is the `FIELDS` list exported by each handler. `site/api/public-stats.test.mjs` checks a fixture against +it without a database, and `tools/ci/public-api-check.mjs ` checks a deployment (CI runs it against +https://igneum.network on master). + +## /api/stats + +| Field | Meaning | Source | +|---|---|---| +| `network`, `chain_id`, `node_version` | Network name, EVM chain id (4461 mainnet, 4462 testnet, 4463 devnet, design 8.1), the node's version | observer `live_state` | +| `algorithm` | The lottery hash, named | fixed text | +| `stale`, `age_s`, `observer_updated_at` | `stale` when the observer has not written for 30 s; treat every number as last known then | observer | +| `height` | The chain block number (the EVM block number): the number of the newest chain block the observer has a shard plan for | `live_blocks.number` | +| `block_count`, `header_count` | Every DAG block the node holds | `getBlockDagInfo` | +| `daa`, `blue_score` | DAA score and blue score of the newest block | `live_blocks`, `getSinkBlueScore` | +| `difficulty` | The node's difficulty (target per block) | `getBlockDagInfo` | +| `hashrate`, `hashrate_unit`, `hashrate_source` | H/s: the node's `estimateNetworkHashesPerSecond` over 1,000 blocks, else blue work added per second over 10 min | observer | +| `block_time_target_s`, `block_time_measured_s` | 1 s by design (spec 2.1); 60 / DAG blocks in the last 60 s | observer | +| `blocks_per_day_target`, `blocks_per_day_measured` | 86,400; the last hour's DAG blocks x 24 (null until the observer has an hour) | observer | +| `block_reward` | `E(daa)` of spec 2.5 at the newest DAA score: `sompi` (8 decimals), `ign`, the 80% `miner_ign` and 20% `proving_pool_ign`, `ramp_factor`, `halving_period`, `next_halving_daa`, `next_halving_in_s` | `site/lib/emission.mjs` | +| `last_block` | Hash, time, age, blue score, DAA, coinbase address, vote key id, EVM transaction count, whether it is a chain block | `live_blocks` | +| `finality` | Finality v2: active, the latest locked checkpoint index and blue score | observer | +| `peers`, `mempool`, `miners_10m` | Connected peers, mempool size, distinct vote keys in 10 min (a card runs several) | observer | + +Note for a profitability calculator: `block_reward` is per blue block merged; at the 1 block per second target that is +also the reward per DAA second. The 20% proving-pool part is paid to provers, not to the miner of the block, so a +miner's expected income per block is `miner_ign`. During the 30-day launch ramp the reward climbs from 10% to 100% +of the schedule (`ramp_factor`); `/api/supply` shows where the ramp stands. + +Example, the devnet on 5 October 2026 (through the local preview against a test observer, so `block_time_measured_s` +reflects a one-minute window): + +``` +{ + "ok": true, + "now": "2026-10-05T19:32:09.321Z", + "network": "igneum-devnet", + "chain_id": 4463, + "node_version": "2.1.0", + "algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)", + "stale": false, + "age_s": 0.9, + "height": 82145, + "block_count": 126358, + "header_count": 126358, + "daa": 126357, + "blue_score": 123504, + "difficulty": 125543017.00697394, + "hashrate": 258756880, + "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": 0.952, + "blocks_per_day_target": 86400, + "blocks_per_day_measured": 14040, + "block_reward": { + "sompi": "455909062", + "ign": "4.55909062", + "miner_ign": "3.6472725", + "proving_pool_ign": "0.91181812", + "split": "80% block producer, 20% proving pool", + "daa_used": 126357, + "ramp_factor": 0.143874, + "halving_period": 0, + "next_halving_daa": 63115200, + "next_halving_in_s": 62988843 + }, + "last_block": { + "hash": "bbec3139d3f38e3517716374707998e77f536a67813edfb619c5bed93a5779ab", + "time": "2026-10-05T19:32:06.157Z", + "ts_ms": 1791228726157, + "age_s": 3.2, + "blue_score": 123504, + "daa": 126357, + "miner": "igneumdev:qrt8nzgrghr2a2xuc7lstzclcnt3n632d2chhc946rphkc6flcyswvkrym8s4", + "miner_id": "7b8ef6fd", + "tx_count": 0, + "chain": true + }, + "finality": { + "active": true, + "latest_locked_index": 4116, + "latest_locked_blue_score": 123480, + "chain_id": "igneum-devnet" + }, + "peers": 4, + "mempool": 0, + "miners_10m": 21, + "observer_updated_at": "2026-10-05T19:32:08.394726+00:00", + "source": "tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node's DAA score" +} +``` + +## /api/supply + +| Field | Meaning | +|---|---| +| `unit` | IGN; the coinbase pays in 8-decimal units (open item O-2.6), the EVM shows 18 | +| `daa` | The newest DAA score the observer stored | +| `max_supply_ign` | 4,000,000,000, the hard cap (spec 2.5, no tail emission: spec 5.10) | +| `circulating_ign`, `circulating_sompi` | Minted so far by the rule: `E(t)` summed over every DAA second from 0 to `daa`, exact (floor sum, `mintedByRule`) | +| `minted_at_end_ign`, `never_minted_ign` | What the schedule reaches when the per-second rate hits 0 (period 32), and the part of the cap the ramp and the floors never mint | +| `emission_per_second_ign`, `block_reward_ign` | `E(daa)` now | +| `halving` | Interval 63,115,200 DAA s (two years), current period, the next halving's DAA score, seconds to it, a date estimate at one DAA second per second | +| `ramp` | 10% at genesis to 100% at DAA 2,592,000 (30 days), the factor now, whether it is complete | +| `schedule` | 33 rows: period, start and end DAA, years from genesis, IGN per second, IGN per block at 1 BPS, minted by the end of the period, share of the cap | +| `check` | The observer's hourly comparison of the chain against the rule over its newest 500 blocks: `rule_match` counts blocks whose coinbase payload declares exactly `E(daa)`; `sum_match` counts blocks whose coinbase outputs equal the declared subsidies of the blocks they merge; `examples` names mismatches | +| `rule`, `source`, `note` | The formula, where it lives, and the caveat: the chain pays per block merged, so a block rate above target mints above the schedule for as long as it lasts | + +Example (schedule cut to five rows here): + +``` +{ + "ok": true, + "now": "2026-10-05T19:32:09.374Z", + "network": "igneum-devnet", + "chain_id": 4463, + "unit": { + "symbol": "IGN", + "decimals_consensus": 8, + "decimals_evm": 18, + "note": "the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals" + }, + "daa": 126357, + "max_supply_ign": "4000000000", + "circulating_ign": "488236.39686436", + "circulating_sompi": "48823639686436", + "minted_at_end_ign": "3963038988.86765648", + "never_minted_ign": "36961011.13234352", + "emission_per_second_ign": "4.55909062", + "block_reward_ign": "4.55909062", + "halving": { + "interval_daa_s": 63115200, + "interval_years": 2, + "period": 0, + "next_halving_daa": 63115200, + "next_halving_in_s": 62988843, + "next_halving_estimate": "2028-10-03T20:26:12.374Z", + "estimate_note": "the estimate assumes one DAA second per wall-clock second from now" + }, + "ramp": { + "start_percent": 10, + "length_daa_s": 2592000, + "length_days": 30, + "factor_now": 0.143874, + "complete": false, + "remaining_s": 2465643, + "withheld_ign": "36961011.13234352", + "withheld_note": "the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)" + }, + "schedule": [ + { + "period": 0, + "start_daa": 0, + "end_daa": 63115200, + "years_from_genesis": "0 to 2", + "per_second_ign": "31.68808781", + "per_block_ign_at_1bps": "31.68808781", + "minted_by_end_ign": "1963038999.85152848", + "share_of_cap_by_end": 49.0759 + }, + { + "period": 1, + "start_daa": 63115200, + "end_daa": 126230400, + "years_from_genesis": "2 to 4", + "per_second_ign": "15.8440439", + "per_block_ign_at_1bps": "15.8440439", + "minted_by_end_ign": "2963038999.40880848", + "share_of_cap_by_end": 74.0759 + }, + { + "period": 2, + "start_daa": 126230400, + "end_daa": 189345600, + "years_from_genesis": "4 to 6", + "per_second_ign": "7.92202195", + "per_block_ign_at_1bps": "7.92202195", + "minted_by_end_ign": "3463038999.18744848", + "share_of_cap_by_end": 86.5759 + }, + "... 29 more rows ...", + { + "period": 32, + "start_daa": 2019686400, + "end_daa": 2082801600, + "years_from_genesis": "64 to 66", + "per_second_ign": "0", + "per_block_ign_at_1bps": "0", + "minted_by_end_ign": "3963038988.86765648", + "share_of_cap_by_end": 99.0759 + } + ], + "check": { + "bps": 1, + "sampled": 470, + "examples": [], + "sum_match": 466, + "checked_at": "2026-10-05T19:30:28.385Z", + "rule_match": 470, + "sum_skipped": 4, + "sum_mismatch": 0, + "rule_mismatch": 0 + }, + "rule": "E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1", + "source": "docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node's DAA score (site/lib/emission.mjs mintedByRule, exact)", + "note": "circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks." +} +``` + +## /api/explorer + +The explorer's own feed, cached 5 s: `?blocks=N[&before=ms]` (latest blocks), `?block=hash`, `?height=N`, +`?address=0x..|igneumdev:..`, `?search=q`. Shapes are in `site/api/explorer.mjs`; the pages are the reference client. +Balances need `EXPLORER_EVM_RPC` on the deployment (a public EVM JSON-RPC); without it `balance.available` is false +with the reason. diff --git a/site/api/_neon.mjs b/site/api/_neon.mjs new file mode 100644 index 00000000..d6b05aa5 --- /dev/null +++ b/site/api/_neon.mjs @@ -0,0 +1,26 @@ +// Shared by the public read endpoints (stats, supply, explorer): Neon's HTTP SQL endpoint over Node's fetch, the +// same pattern as live.mjs. Returns rows. A file starting with an underscore is not deployed as a function by Vercel. +export function neon(url = process.env.DATABASE_URL) { + if (!url) throw new Error('DATABASE_URL is not set'); + const host = new URL(url).hostname.replace('-pooler', ''); + return async (query, params = []) => { + const r = await fetch(`https://${host}/sql`, { + method: 'POST', + headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, params }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.message || JSON.stringify(j)); + return j.rows || []; + }; +} +export const num = v => (v === null || v === undefined ? null : Number(v)); +export const tablePrefix = () => (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, ''); +/** EVM chain id from the network name (docs/design/execution-layer.md 8.1: 4461 mainnet, 4462 testnet, 4463 devnet). */ +export function chainIdOf(network) { + const n = String(network || ''); + if (n.includes('devnet')) return 4463; + if (n.includes('testnet')) return 4462; + if (n.includes('simnet')) return null; + return 4461; +} diff --git a/site/api/public-stats.test.mjs b/site/api/public-stats.test.mjs new file mode 100644 index 00000000..c7853f41 --- /dev/null +++ b/site/api/public-stats.test.mjs @@ -0,0 +1,73 @@ +// The public stats contract: /api/stats and /api/supply answer with every field docs/api/public-stats.md documents +// (FIELDS in each handler), computed from a fixture of what the observer writes. Runs without a database. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHandler as stats, FIELDS as STATS_FIELDS, REWARD_FIELDS, LAST_BLOCK_FIELDS, rewardAt } from './stats.mjs'; +import { createHandler as supply, FIELDS as SUPPLY_FIELDS, SCHEDULE_FIELDS } from './supply.mjs'; + +const NOW = '2026-10-05T19:21:56.000Z'; +const state = { + id: 1, block_count: 125066, header_count: 125066, blue_score: 122211, difficulty: 7748714.75, hashes_per_second_estimate: 27759288, peers: 7, mempool: 0, + node_version: '2.1.0', network: 'igneum-devnet', blocks_60s: 58, blocks_per_minute: Array.from({ length: 60 }, (_, i) => [1791227000000 + i * 60000, 60]), + observer_started_at: '2026-10-05T19:01:00.000Z', updated_at: '2026-10-05T19:21:55.000Z', + finality: { chain_id: 'igneum-devnet', finality_active: true, latest_locked_index: 4073, latest_locked_blue_score: 122190 }, + supply_check: { checked_at: NOW, sampled: 500, rule_match: 500, rule_mismatch: 0, sum_match: 480, sum_mismatch: 0, sum_skipped: 20, examples: [], bps: 1 }, +}; +const last = { hash: '2622db7698c6825b370bbffba03ea11bba250792040296971d2b790a954203ee', blue_score: 122211, daa_score: 125064, timestamp_ms: 1791227608731, + miner_address: 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e', vote_key_hash: '1c3f1190b777365e419f02fd46ce365f464377d0fdbdf1332da6600d09a75a0e', tx_count: 0, is_chain_block: true, number: 81263 }; +function fakeSql(rows) { return async (query) => { if (/row_to_json/.test(query) && /max\(daa_score\)/.test(query)) return [{ now: NOW, state, daa: 125064 }]; if (/row_to_json/.test(query)) return [{ now: NOW, state }]; if (/ORDER BY received_at DESC LIMIT 1/.test(query)) return [last]; if (/count\(DISTINCT vote_key_hash\)/.test(query)) return [{ n: 25 }]; return rows || []; }; } +function fakeRes() { const r = { code: 0, headers: {}, body: null, status(c) { r.code = c; return r; }, setHeader(k, v) { r.headers[k] = v; }, json(o) { r.body = o; return r; } }; return r; } + +test('/api/stats carries every documented field and the reward of spec 2.5', async () => { + const res = fakeRes(); + await stats({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/stats' }, res); + assert.equal(res.code, 200); + for (const f of STATS_FIELDS) assert.ok(f in res.body, `missing ${f}`); + for (const f of REWARD_FIELDS) assert.ok(f in res.body.block_reward, `missing block_reward.${f}`); + for (const f of LAST_BLOCK_FIELDS) assert.ok(f in res.body.last_block, `missing last_block.${f}`); + assert.equal(res.body.chain_id, 4463); + assert.equal(res.body.height, 81263); + assert.equal(res.body.block_reward.sompi, '454486399'); // E(125,064), the payload of block 2622db76 + assert.equal(res.body.block_reward.ign, '4.54486399'); + assert.equal(res.body.block_reward.miner_ign, '3.6358912'); // 80%: 454486399 - 90897279 + assert.equal(res.body.block_reward.proving_pool_ign, '0.90897279'); + assert.equal(res.body.block_time_measured_s, 1.034); + assert.equal(res.body.blocks_per_day_measured, 86400); + assert.equal(res.body.stale, false); + assert.equal(res.headers['Cache-Control'], 'public, max-age=10, s-maxage=10'); +}); +test('/api/stats marks a stale observer', async () => { + const res = fakeRes(); + const old = { ...state, updated_at: '2026-10-05T19:00:00.000Z' }; + const sql = async (q) => (/row_to_json/.test(q) ? [{ now: NOW, state: old }] : /LIMIT 1/.test(q) ? [last] : [{ n: 0 }]); + await stats({ sql, env: {} })({ method: 'GET', url: '/api/stats' }, res); + assert.equal(res.body.stale, true); + assert.ok(res.body.age_s > 1000); +}); +test('rewardAt: halving and ramp bookkeeping', () => { + const r = rewardAt(63_115_200); + assert.equal(r.sompi, '1584404390'); assert.equal(r.halving_period, 1); assert.equal(r.next_halving_daa, 126_230_400); assert.equal(r.ramp_factor, 1); + assert.equal(rewardAt(0).ramp_factor, 0.1); +}); +test('/api/supply carries every documented field, the cap and a 33-row schedule', async () => { + const res = fakeRes(); + await supply({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/supply' }, res); + assert.equal(res.code, 200); + for (const f of SUPPLY_FIELDS) assert.ok(f in res.body, `missing ${f}`); + assert.equal(res.body.max_supply_ign, '4000000000'); + assert.equal(res.body.schedule.length, 33); + for (const f of SCHEDULE_FIELDS) assert.ok(f in res.body.schedule[0], `missing schedule.${f}`); + assert.equal(res.body.schedule[0].per_second_ign, '31.68808781'); + assert.equal(res.body.schedule[1].per_second_ign, '15.8440439'); + assert.equal(res.body.schedule[1].start_daa, 63115200); + assert.equal(res.body.minted_at_end_ign, '3963038988.86765648'); + assert.equal(res.body.circulating_ign, '482350.69732271'); // mintedByRule(125,064): the rule summed over DAA 0 to 125,063 + assert.equal(res.body.halving.next_halving_daa, 63115200); + assert.equal(res.body.ramp.complete, false); + assert.equal(res.body.check.rule_match, 500); +}); +test('both refuse anything but GET', async () => { + for (const h of [stats({ sql: fakeSql(), env: {} }), supply({ sql: fakeSql(), env: {} })]) { + const res = fakeRes(); await h({ method: 'POST', url: '/' }, res); assert.equal(res.code, 405); + } +}); diff --git a/site/api/stats.mjs b/site/api/stats.mjs new file mode 100644 index 00000000..bc07b0d8 --- /dev/null +++ b/site/api/stats.mjs @@ -0,0 +1,90 @@ +// 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(); diff --git a/site/api/supply.mjs b/site/api/supply.mjs new file mode 100644 index 00000000..cdb70efc --- /dev/null +++ b/site/api/supply.mjs @@ -0,0 +1,76 @@ +// Igneum supply. GET /api/supply: coins minted so far by the emission rule at the node's DAA score, the hard cap, the +// halving schedule as a table and the 30-day launch ramp (docs/spec/02-consensus.md 2.5, consensus/core/src/igneum.rs), +// plus the observer's hourly check of the chain's coinbase sums against that rule (live_state.supply_check). Cached +// 10 s at the edge. Documented in docs/api/public-stats.md. +import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs'; +import * as E from '../lib/emission.mjs'; + +export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'unit', 'daa', 'max_supply_ign', 'circulating_ign', 'circulating_sompi', 'minted_at_end_ign', 'never_minted_ign', + 'emission_per_second_ign', 'block_reward_ign', 'halving', 'ramp', 'schedule', 'check', 'rule', 'source', 'note']; +export const SCHEDULE_FIELDS = ['period', 'start_daa', 'end_daa', 'years_from_genesis', 'per_second_ign', 'per_block_ign_at_1bps', 'minted_by_end_ign', 'share_of_cap_by_end']; +const S = Number(E.SECONDS_PER_YEAR); + +export function shape({ now, state: s, daa }) { + const minted = daa === null ? null : E.mintedByRule(daa); + const end = E.mintedAtEnd(); + const period = daa === null ? null : Math.floor(daa / Number(E.HALVING_INTERVAL_SECONDS)); + const nextHalving = period === null ? null : (period + 1) * Number(E.HALVING_INTERVAL_SECONDS); + const ramp = daa === null ? null : Number(E.LAUNCH_RAMP_SECONDS) - daa; + const schedule = E.halvingTable().map(r => ({ + period: r.period, start_daa: Number(r.start_daa), end_daa: Number(r.end_daa), + years_from_genesis: `${Number(r.start_daa) / S} to ${Number(r.end_daa) / S}`, + per_second_ign: E.sompiToIgn(r.per_second_sompi), per_block_ign_at_1bps: E.sompiToIgn(r.per_second_sompi), + minted_by_end_ign: E.sompiToIgn(r.minted_by_end_sompi), + share_of_cap_by_end: Math.round(Number(r.minted_by_end_sompi * 1_000_000n / E.SUPPLY_CAP_SOMPI)) / 10_000, + })); + return { + ok: true, now: new Date(now).toISOString(), + network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, + unit: { symbol: 'IGN', decimals_consensus: 8, decimals_evm: 18, note: 'the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa\'s SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals' }, + daa, + max_supply_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI), + circulating_ign: minted === null ? null : E.sompiToIgn(minted), + circulating_sompi: minted === null ? null : minted.toString(), + minted_at_end_ign: E.sompiToIgn(end), + never_minted_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI - end), + emission_per_second_ign: daa === null ? null : E.sompiToIgn(E.blockSubsidy(daa, 1)), + block_reward_ign: daa === null ? null : E.sompiToIgn(E.blockSubsidy(daa, 1)), + halving: { + interval_daa_s: Number(E.HALVING_INTERVAL_SECONDS), interval_years: 2, period, next_halving_daa: nextHalving, + next_halving_in_s: nextHalving === null ? null : nextHalving - daa, + next_halving_estimate: nextHalving === null ? null : new Date(now + (nextHalving - daa) * 1000).toISOString(), + estimate_note: 'the estimate assumes one DAA second per wall-clock second from now', + }, + ramp: { + start_percent: Number(E.LAUNCH_RAMP_START_PERCENT), length_daa_s: Number(E.LAUNCH_RAMP_SECONDS), length_days: 30, + factor_now: daa === null ? null : Math.round(E.rampFactor(daa) * 1e6) / 1e6, complete: daa === null ? null : daa >= Number(E.LAUNCH_RAMP_SECONDS), + remaining_s: ramp === null ? null : Math.max(0, ramp), + withheld_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI - end), withheld_note: 'the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)', + }, + schedule, + check: s && s.supply_check ? s.supply_check : null, + rule: 'E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1', + source: 'docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node\'s DAA score (site/lib/emission.mjs mintedByRule, exact)', + note: 'circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks.', + }; +} + +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 rows = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state, + (SELECT max(daa_score) FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes') AS daa`); + const now = new Date(rows[0].now).getTime(); + return res.status(200).json(shape({ now, state: rows[0].state || null, daa: num(rows[0].daa) })); + } catch (e) { + res.setHeader('Cache-Control', 'no-store'); + return res.status(500).json({ ok: false, error: String(e.message || e) }); + } + }; +} +export default createHandler(); diff --git a/tools/ci/public-api-check.mjs b/tools/ci/public-api-check.mjs new file mode 100644 index 00000000..488307ed --- /dev/null +++ b/tools/ci/public-api-check.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Checks that a deployed site answers /api/stats and /api/supply with every documented field (the FIELDS lists in +// site/api/stats.mjs and site/api/supply.mjs, the same lists docs/api/public-stats.md is written from). +// node tools/ci/public-api-check.mjs [https://igneum.network] +// Exit 1 on a missing field, a non-200 answer or a stale observer older than 10 minutes (the numbers are what pool +// software and profitability sites read; a stale answer is a wrong answer). +import { FIELDS as STATS, REWARD_FIELDS, LAST_BLOCK_FIELDS } from '../../site/api/stats.mjs'; +import { FIELDS as SUPPLY, SCHEDULE_FIELDS } from '../../site/api/supply.mjs'; + +const base = (process.argv[2] || process.env.PUBLIC_API_URL || 'https://igneum.network').replace(/\/$/, ''); +const missing = []; +async function get(path) { + const r = await fetch(base + path, { headers: { 'cache-control': 'no-cache' }, signal: AbortSignal.timeout(15000) }); + const j = await r.json().catch(() => null); + if (r.status !== 200 || !j || j.ok !== true) { console.error(`${path}: HTTP ${r.status} ${j ? JSON.stringify(j).slice(0, 200) : ''}`); process.exit(1); } + return j; +} +const s = await get('/api/stats'); +for (const f of STATS) if (!(f in s)) missing.push(`stats.${f}`); +if (s.block_reward) for (const f of REWARD_FIELDS) if (!(f in s.block_reward)) missing.push(`stats.block_reward.${f}`); +if (s.last_block) for (const f of LAST_BLOCK_FIELDS) if (!(f in s.last_block)) missing.push(`stats.last_block.${f}`); +const p = await get('/api/supply'); +for (const f of SUPPLY) if (!(f in p)) missing.push(`supply.${f}`); +if (p.schedule && p.schedule[0]) for (const f of SCHEDULE_FIELDS) if (!(f in p.schedule[0])) missing.push(`supply.schedule.${f}`); +if (missing.length) { console.error(`public api check: ${missing.length} missing field(s): ${missing.join(', ')}`); process.exit(1); } +if (s.stale && s.age_s > 600) { console.error(`public api check: the observer is stale (${s.age_s} s)`); process.exit(1); } +console.log(`public api check: ${base} /api/stats (${STATS.length} fields, height ${s.height}, reward ${s.block_reward && s.block_reward.ign} IGN, ${s.stale ? 'STALE ' + s.age_s + ' s' : 'live'}) and /api/supply (${SUPPLY.length} fields, circulating ${p.circulating_ign} IGN of ${p.max_supply_ign}) answer with every documented field`);