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>
This commit is contained in:
parent
8ffcdd2182
commit
48d987cfb4
6 changed files with 519 additions and 0 deletions
227
docs/api/public-stats.md
Normal file
227
docs/api/public-stats.md
Normal file
|
|
@ -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 <url>` 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.
|
||||||
26
site/api/_neon.mjs
Normal file
26
site/api/_neon.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
73
site/api/public-stats.test.mjs
Normal file
73
site/api/public-stats.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
90
site/api/stats.mjs
Normal file
90
site/api/stats.mjs
Normal file
|
|
@ -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();
|
||||||
76
site/api/supply.mjs
Normal file
76
site/api/supply.mjs
Normal file
|
|
@ -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();
|
||||||
27
tools/ci/public-api-check.mjs
Normal file
27
tools/ci/public-api-check.mjs
Normal file
|
|
@ -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`);
|
||||||
Loading…
Reference in a new issue