From 6983685730e069aedb65bec21f5ee4bb355be70f Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:35:40 +0100 Subject: [PATCH 1/4] Observer: explorer detail per block from the notification, chain block number from the shard plan, RPC load counter, hourly coinbase check against spec 2.5 site/lib/emission.mjs is the emission rule as igneum.rs computes it (block_subsidy, launch_ramp, an exact floor-sum for minted-so-far); its tests reproduce the node's own test values and a devnet coinbase (block 2622db76: payload 454,486,399 at DAA 125,064, outputs 454,485,299 = E(125,063), what the merged parent declared). Every live_blocks row gains tx_count, evm_miner (the IGNA tag, else the vote key's low 20 bytes), proof_records (IGNP section), subsidy_sompi, paid_sompi, selected_parent, number, detail. No extra RPC per block: measured 282 against 283 wRPC and 785 against 776 EVM calls per minute before and after. live_state.rpc_load and live_state.supply_check are new. Co-Authored-By: Claude Fable 5.1 --- site/lib/emission.mjs | 122 +++++++++++++++++++++++++++ site/lib/emission.test.mjs | 56 +++++++++++++ tools/observer/README.md | 5 +- tools/observer/observer.mjs | 162 ++++++++++++++++++++++++++++++++++-- 4 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 site/lib/emission.mjs create mode 100644 site/lib/emission.test.mjs diff --git a/site/lib/emission.mjs b/site/lib/emission.mjs new file mode 100644 index 000000000..28f8f7e60 --- /dev/null +++ b/site/lib/emission.mjs @@ -0,0 +1,122 @@ +// Igneum emission, spec 2.5 (docs/spec/02-consensus.md) as the node computes it +// (vendor/igneum-node/consensus/core/src/igneum.rs: block_subsidy, launch_ramp). Shared by the observer's coinbase +// check (tools/observer/observer.mjs) and the public /api/supply. BigInt throughout; sompi is the 8-decimal unit the +// devnet pays in (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA), the EVM side shows the same amount at 18 decimals. + +export const SOMPI_PER_IGN = 100_000_000n; +export const SECONDS_PER_YEAR = 31_557_600n; // 365.25 days +export const HALVING_INTERVAL_SECONDS = 2n * SECONDS_PER_YEAR; // 63,115,200 +export const YEAR_ONE_EMISSION_SOMPI = 1_000_000_000n * SOMPI_PER_IGN; +export const BASE_SUBSIDY_PER_SECOND_SOMPI = YEAR_ONE_EMISSION_SOMPI / SECONDS_PER_YEAR; // 3,168,808,781 (floor) +export const SUPPLY_CAP_SOMPI = 4_000_000_000n * SOMPI_PER_IGN; +export const LAUNCH_RAMP_SECONDS = 2_592_000n; // 30 days +export const LAUNCH_RAMP_START_PERCENT = 10n; +export const PROVING_POOL_SHARE_PERCENT = 20n; +export const SUBSIDY_PERIODS = 33; // period 32 pays 0 + +/** Per-second subsidy of two-year period i: BASE >> i (igneum.rs SUBSIDY_PER_SECOND_BY_PERIOD). */ +export function subsidyPerSecond(period) { + const i = Math.min(Math.max(0, period), SUBSIDY_PERIODS - 1); + return BASE_SUBSIDY_PER_SECOND_SOMPI >> BigInt(i); +} + +/** igneum.rs launch_ramp: linear from 10% at second 0 to 100% at LAUNCH_RAMP_SECONDS, integer floor. */ +export function launchRamp(fullSubsidy, secondsSinceGenesis) { + const s = BigInt(secondsSinceGenesis); + if (s >= LAUNCH_RAMP_SECONDS) return fullSubsidy; + const numerator = LAUNCH_RAMP_START_PERCENT * LAUNCH_RAMP_SECONDS + (100n - LAUNCH_RAMP_START_PERCENT) * s; + const denominator = 100n * LAUNCH_RAMP_SECONDS; + return fullSubsidy * numerator / denominator; +} + +/** igneum.rs block_subsidy(daa_score, bps): per-block subsidy in sompi paid for each blue block merged at this DAA score. */ +export function blockSubsidy(daaScore, bps = 1) { + const b = BigInt(bps); + const seconds = BigInt(daaScore) / b; + const period = Number(seconds / HALVING_INTERVAL_SECONDS); + const perBlock = subsidyPerSecond(period) / b; + return launchRamp(perBlock, seconds); +} + +/** The ramp factor at a DAA score as a decimal in [0.1, 1], for display. */ +export function rampFactor(daaScore) { + const s = BigInt(daaScore); + if (s >= LAUNCH_RAMP_SECONDS) return 1; + return Number(LAUNCH_RAMP_START_PERCENT * LAUNCH_RAMP_SECONDS + (100n - LAUNCH_RAMP_START_PERCENT) * s) / Number(100n * LAUNCH_RAMP_SECONDS); +} + +// sum_{i=0}^{n-1} floor((a*i + b) / m), exact in O(log) (the classic floor-sum; a, b >= 0, m > 0, BigInt). +export function floorSum(n, m, a, b) { + let ans = 0n; + for (;;) { + if (a >= m) { ans += (n - 1n) * n / 2n * (a / m); a %= m; } + if (b >= m) { ans += n * (b / m); b %= m; } + const yMax = a * n + b; + if (yMax < m) return ans; + const n2 = yMax / m, b2 = yMax % m; + [n, m, a, b] = [n2, a, m, b2]; + } +} + +/** + * Coins minted by the rule from DAA 0 up to (not including) `daaScore`, in sompi, at `bps` = 1: the sum of + * blockSubsidy(t) over every DAA second t. Exact: the ramp part is a floor-sum, the rest is per-period arithmetic. + * This is the schedule; the chain pays blockSubsidy per blue block it merges (and per red inside the window), so + * the paid total tracks it one block per DAA step. The observer's coinbase check (live_state.supply_check) reports + * the difference it sees. + */ +export function mintedByRule(daaScore) { + let t = BigInt(daaScore); + if (t <= 0n) return 0n; + let total = 0n; + // ramp: per-second = floor(B * (10R + 90 s) / (100 R)) = floor((90B s + 10RB) / (100R)) + const B = BASE_SUBSIDY_PER_SECOND_SOMPI, R = LAUNCH_RAMP_SECONDS; + const rampSeconds = t < R ? t : R; + total += floorSum(rampSeconds, 100n * R, 90n * B, 10n * R * B); + if (t <= R) return total; + // after the ramp, period by period + let from = R; + while (from < t) { + const period = Number(from / HALVING_INTERVAL_SECONDS); + const periodEnd = BigInt(period + 1) * HALVING_INTERVAL_SECONDS; + const to = t < periodEnd ? t : periodEnd; + total += (to - from) * subsidyPerSecond(period); + from = to; + } + return total; +} + +/** Minted by the rule when every period has paid out: the cap minus the ramp's withheld part and the floors. */ +export function mintedAtEnd() { + return mintedByRule(BigInt(SUBSIDY_PERIODS) * HALVING_INTERVAL_SECONDS); +} + +/** The halving table: one row per two-year period until the per-second subsidy is 0. */ +export function halvingTable(maxRows = SUBSIDY_PERIODS) { + const rows = []; + for (let i = 0; i < Math.min(maxRows, SUBSIDY_PERIODS); i++) { + const start = BigInt(i) * HALVING_INTERVAL_SECONDS; + rows.push({ + period: i, + start_daa: start, + end_daa: start + HALVING_INTERVAL_SECONDS, + per_second_sompi: subsidyPerSecond(i), + minted_by_end_sompi: mintedByRule(start + HALVING_INTERVAL_SECONDS), + }); + } + return rows; +} + +/** sompi -> decimal IGN string with up to 8 places, no exponent, no rounding. */ +export function sompiToIgn(sompi, places = 8) { + const v = BigInt(sompi); const neg = v < 0n; const a = neg ? -v : v; + const whole = a / SOMPI_PER_IGN, frac = (a % SOMPI_PER_IGN).toString().padStart(8, '0').slice(0, places).replace(/0+$/, ''); + return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : ''); +} + +/** wei (18 decimals) -> decimal IGN string, up to `places` places. */ +export function weiToIgn(wei, places = 6) { + const v = BigInt(wei); const neg = v < 0n; const a = neg ? -v : v; const U = 10n ** 18n; + const whole = a / U, frac = (a % U).toString().padStart(18, '0').slice(0, places).replace(/0+$/, ''); + return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : ''); +} diff --git a/site/lib/emission.test.mjs b/site/lib/emission.test.mjs new file mode 100644 index 000000000..da8e5bfef --- /dev/null +++ b/site/lib/emission.test.mjs @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as E from './emission.mjs'; + +test('constants match igneum.rs and spec 2.5', () => { + assert.equal(E.BASE_SUBSIDY_PER_SECOND_SOMPI, 3_168_808_781n); // igneum.rs test: assert_eq!(BASE_SUBSIDY_PER_SECOND_SOMPI, 3_168_808_781) + assert.equal(E.HALVING_INTERVAL_SECONDS, 63_115_200n); + assert.equal(E.LAUNCH_RAMP_SECONDS, 2_592_000n); + assert.equal(E.SUPPLY_CAP_SOMPI, 400_000_000_000_000_000n); +}); +test('blockSubsidy reproduces the node tests (igneum.rs)', () => { + assert.equal(E.blockSubsidy(2_592_000, 1), 3_168_808_781n); // ramp over + assert.equal(E.blockSubsidy(63_115_200 - 1, 1), 3_168_808_781n); + assert.equal(E.blockSubsidy(63_115_200, 1), 1_584_404_390n); + assert.equal(E.blockSubsidy(2 * 63_115_200, 1), 792_202_195n); + assert.equal(E.blockSubsidy(31 * 63_115_200, 1), 1n); + assert.equal(E.blockSubsidy(32 * 63_115_200, 1), 0n); + assert.equal(E.blockSubsidy(2_592_000 * 10, 10), 316_880_878n); + assert.equal(E.blockSubsidy(63_115_200 * 10, 10), 158_440_439n); + assert.equal(E.launchRamp(1_000_000n, 1_296_000), 550_000n); + assert.equal(E.launchRamp(1_000_000n, 2_591_999), 999_999n); +}); +test('blockSubsidy matches a devnet coinbase payload (block 2622db76, DAA 125,064, 5 Oct 2026)', () => { + assert.equal(E.blockSubsidy(125_064, 1), 454_486_399n); // payload bytes 8..16 = 0x1b16e97f + assert.equal(E.blockSubsidy(125_063, 1), 454_485_299n); // what its merged parent declared and it paid: 363,588,240 + 90,897,059 +}); +test('mintedByRule equals the brute-force sum and respects the cap', () => { + let s = 0n; for (let t = 0; t < 3000; t++) { if (t === 777) assert.equal(E.mintedByRule(777), s); s += E.blockSubsidy(t, 1); } + assert.equal(E.mintedByRule(3000), s); + assert.equal(E.mintedByRule(0), 0n); + const end = E.mintedAtEnd(); + assert.ok(end < E.SUPPLY_CAP_SOMPI); + assert.equal(E.sompiToIgn(end), '3963038988.86765648'); // the cap less the ramp's withheld part (about 37 million) and the floors + // the ramp withholds 0.45 x 2,592,000 / 31,557,600 x 10^9 = 36,961,... IGN (spec 2.5 "about 37 million") + const withheld = E.SUPPLY_CAP_SOMPI - end; + assert.ok(withheld > 36_900_000n * E.SOMPI_PER_IGN && withheld < 37_000_000n * E.SOMPI_PER_IGN, withheld.toString()); +}); +test('floorSum against a loop', () => { + for (const [n, m, a, b] of [[10n, 7n, 3n, 5n], [1000n, 13n, 17n, 0n], [5n, 1n, 0n, 0n], [333n, 1000n, 999n, 999n]]) { + let s = 0n; for (let i = 0n; i < n; i++) s += (a * i + b) / m; + assert.equal(E.floorSum(n, m, a, b), s); + } +}); +test('halvingTable has 33 rows and halves per second', () => { + const t = E.halvingTable(); + assert.equal(t.length, 33); + assert.equal(t[0].per_second_sompi, 3_168_808_781n); + assert.equal(t[1].per_second_sompi, 1_584_404_390n); + assert.equal(t[32].per_second_sompi, 0n); + assert.equal(t[1].start_daa, 63_115_200n); +}); +test('sompiToIgn and weiToIgn', () => { + assert.equal(E.sompiToIgn(454_485_299n), '4.54485299'); + assert.equal(E.sompiToIgn(100_000_000n), '1'); + assert.equal(E.weiToIgn('0x3275f7e5ad621000'), '3.636084'); +}); diff --git a/tools/observer/README.md b/tools/observer/README.md index 66d346c1d..466fe10ed 100644 --- a/tools/observer/README.md +++ b/tools/observer/README.md @@ -34,6 +34,7 @@ IGNEUM_RPC=ws://127.0.0.1:28640 node tools/observer/observer.mjs - `engine` is the miner's tag in the coinbase extra data after the node's version prefix. The node exposes no engine name over RPC, so this is null on devnet v0. - When the node refuses the hash-rate estimate (it needs a 1,000-block window) the observer reports blue work added per second over the last 10 minutes instead. - Proving v0 (4 Oct 2026, spec 7.7): every chain block (the `isChainBlock` flag of a new block, or a `virtualChainChanged` addition) has its shard plan read over `IGNEUM_EVM_RPC` as `{blockHash}` (the chain block hash is the same hash on both layers), one `live_proofs` row per shard in state `planned`; a plan the EVM node has not executed yet is retried with a growing delay (up to 30 tries). The proof records of the chain blocks of the last 10 minutes are polled in rotation (80 blocks per 2 s tick while active, 10 before activation, four calls in flight); a shard moves to `proving` (a record in the node's pool), `verified` (the SP1 proof verified by the node's verifier, or the record carried by a block and checked by consensus, which is what happens before activation) or `paid` (a carrying segment paid it). `lag_daa` is the carrier's DAA score minus the block's; `prover` is the first 8 hex characters of the record's vote key hash. A block whose every shard is paid, or older than 10 minutes, leaves the rotation. Events: `proving` (activation reached, first paid shard seen) and `prover_seen` (one per prover per run). A reorg drops the removed chain blocks from the rotation. +- Explorer (5 Oct 2026, `docs/plans/explorer.md`): every block row also carries what `/explorer`, `/block/` and `/address/` show, all read from the `blockAdded` notification itself (no extra RPC per block, measured: 282 against 283 wRPC calls per minute, 785 against 776 EVM calls in the same minute, before and after): `tx_count` (EVM transactions in the block), `evm_miner` (the coinbase's `IGNA` payout address, else the vote key hash's low 20 bytes as `consensus/core/src/evm.rs` falls back to), `proof_records` (records in the `IGNP` section, 274 bytes each), `subsidy_sompi` (the `E(daa)` the payload declares), `paid_sompi` (the coinbase outputs' sum), `selected_parent`, `number` (the chain block number, filled in when the shard plan arrives) and `detail` (header fields, mergeset, coinbase outputs, EVM transaction hashes, certificate indices). `live_state.rpc_load` counts this process's RPC calls per minute; `live_state.supply_check` is the hourly comparison of the newest 500 blocks with the emission rule (`site/lib/emission.mjs`, spec 2.5): the declared subsidy against `blockSubsidy(daa, 1)`, and each block's outputs against the declared subsidies of the blocks it merges. The observer now imports `site/lib/emission.mjs` and `site/lib/eth.mjs` (keccak for transaction hashes); autosync restarts only on `observer.mjs` and `run.sh` changes, so a change to those two libraries needs a restart by hand. - Finality v2 (3 Oct 2026): subscribes to `FinalityLock` (the node's lock event) and polls `getFinalityCheckpoints` every 2 s and `getFinalityWeights` every 10 s. Every checkpoint the node reports is upserted into `live_checkpoints`; a checkpoint turning `locked` writes the event `checkpoint N locked (xx% of weight, yy% of active, v votes of n voters) at block h`. The weights snapshot (total, active, per key) goes into `live_state.finality`. A node from before the finality layer answers the RPC with an error; the observer then logs once and skips finality. ## Tables @@ -42,7 +43,7 @@ Created on start if missing. | Table | Rows | Columns | |---|---|---| -| `live_blocks` | one per block, kept `LIVE_RETAIN_HOURS` | `hash`, `blue_score`, `daa_score`, `timestamp_ms`, `parents` (count), `parent_hashes`, `is_chain_block`, `vote_key_hash`, `miner_address`, `engine`, `received_at`. Indexes on `received_at`, `timestamp_ms`, `(vote_key_hash, received_at)`. | +| `live_blocks` | one per block, kept `LIVE_RETAIN_HOURS` | `hash`, `blue_score`, `daa_score`, `timestamp_ms`, `parents` (count), `parent_hashes`, `is_chain_block`, `vote_key_hash`, `miner_address`, `engine`, `received_at`, `color`; explorer: `tx_count`, `evm_miner`, `proof_records`, `subsidy_sompi`, `paid_sompi`, `selected_parent`, `number`, `detail` (jsonb). Indexes on `received_at`, `timestamp_ms`, `(vote_key_hash, received_at)`, `(evm_miner, received_at)`, `(miner_address, received_at)`, `number`. | | `live_state` | one row, updated every 2 s | `block_count`, `header_count`, `blue_score`, `difficulty`, `hashes_per_second_estimate`, `peers`, `mempool`, `node_version`, `network`, `blocks_60s`, `blocks_per_minute` (60 pairs of minute epoch ms and count), `observer_started_at`, `updated_at` | | `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%), `checkpoint_locked`. | | `live_checkpoints` | one per checkpoint index, kept 7 days | `index`, `hash`, `blue_score`, `daa_score`, `state` (proposed, certified, locked), `signed_weight`, `active_weight`, `total_weight`, `fraction_active`, `fraction_total`, `votes_seen`, `voters`, `aggregators` (key hashes whose sortition proof made them aggregators), `locked_at`, `first_seen_at`, `updated_at`. | @@ -56,4 +57,4 @@ Created on start if missing. ## Reading it -`site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s. +`site/api/stats.mjs`, `site/api/supply.mjs` and `site/api/explorer.mjs` serve `/api/stats`, `/api/supply` and `/api/explorer` (docs/api/public-stats.md). `site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s. diff --git a/tools/observer/observer.mjs b/tools/observer/observer.mjs index 8db92d5b2..9cc45255d 100644 --- a/tools/observer/observer.mjs +++ b/tools/observer/observer.mjs @@ -24,6 +24,14 @@ // every 2 s; writes the checkpoints table and emits "checkpoint N locked (xx% of weight)" events. // Light client (site/api/checkpoint.mjs, site/verify/): every certificate a block carries is stored with the voter // table the node reports and the header chain from the previous locked checkpoint, so a browser can verify it. +// Explorer (5 October 2026, docs/plans/explorer.md): every block row also carries what /explorer, /block/ and +// /address/ show: the EVM transaction count and hashes, the miner's EVM payout address (the coinbase's IGNA +// tag, else the vote key hash's low 20 bytes as consensus/core/src/evm.rs falls back to), the number of proof records +// carried (the IGNP section), the subsidy the payload declares and the outputs paid, the header fields, the mergeset +// and the certificate indices, all read from the blockAdded notification itself: no extra RPC per block. The chain +// block number comes with the shard plan the proving feed already fetches. live_state.rpc_load counts this +// process's RPC calls per minute (wRPC and EVM) so the load of a change can be measured; live_state.supply_check is +// the hourly comparison of the coinbase sums with the emission rule (site/lib/emission.mjs, spec 2.5). // Zero dependencies: Node 22 WebSocket and fetch, Neon's HTTP SQL endpoint. // // Ingest path (4 Oct 2026, after the observer fell 80 min behind under machine load 200 to 300): notifications @@ -36,6 +44,8 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; +import { blockSubsidy } from '../../site/lib/emission.mjs'; +import { keccak256 } from '../../site/lib/eth.mjs'; const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610'; const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24); @@ -91,6 +101,18 @@ async function setupSchema() { `CREATE INDEX IF NOT EXISTS ${TB}_vote_key_received ON ${TB} (vote_key_hash, received_at)`, // GHOSTDAG colour (4 Oct 2026, additive): pending until a chain block merges it, then blue (paid) or red (excluded) `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS color text NOT NULL DEFAULT 'pending'`, + // Explorer (5 Oct 2026, additive): what the block pages show, from the notification; number from the shard plan + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS tx_count int`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS evm_miner text`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS proof_records int`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS subsidy_sompi bigint`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS paid_sompi bigint`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS selected_parent text`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS number bigint`, + `ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS detail jsonb`, + `CREATE INDEX IF NOT EXISTS ${TB}_evm_miner_received ON ${TB} (evm_miner, received_at)`, + `CREATE INDEX IF NOT EXISTS ${TB}_miner_address_received ON ${TB} (miner_address, received_at)`, + `CREATE INDEX IF NOT EXISTS ${TB}_number ON ${TB} (number)`, `CREATE TABLE IF NOT EXISTS ${TS} ( id int PRIMARY KEY DEFAULT 1 CHECK (id = 1), block_count bigint, header_count bigint, blue_score bigint, difficulty double precision, @@ -103,6 +125,9 @@ async function setupSchema() { `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS queue_depth int`, // Proving v0 (4 Oct 2026, additive): {supported, active, activation_daa, tip_daa, blocks_fully_proven_10m, ...} `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS proving jsonb`, + // Explorer: this process's RPC calls per minute, and the hourly coinbase-versus-rule comparison + `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS rpc_load jsonb`, + `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS supply_check jsonb`, // One row per planned shard of a chain block (spec 7.7 item 8): the plan as the block joins the chain, then the // record's progress. prover is the first 8 hex characters of the record's vote key hash (never the full key, R4.6.2). `CREATE TABLE IF NOT EXISTS ${TP} ( @@ -266,6 +291,7 @@ class Rpc { }); } call(method, params = {}) { + rpcLoad.wrpc++; return new Promise((resolve, reject) => { if (!this.open) return reject(new Error('rpc not connected')); const id = ++this.id; this.pending.set(id, { resolve, reject }); @@ -279,6 +305,89 @@ class Rpc { const log = (...a) => console.log(new Date().toISOString(), ...a); const short = h => (h || '').slice(0, 8); +// RPC load (5 Oct 2026): calls this process makes, counted per wall-clock minute, the last five minutes kept. Logged +// once a minute and written to live_state.rpc_load, so a change to the observer can be measured against the rule +// in docs/plans/explorer.md (the explorer must stay under 2x of the load before it). +const rpcLoad = { wrpc: 0, evm: 0, minutes: [], minuteStart: Date.now() }; +function rollRpcLoad(now) { + if (now - rpcLoad.minuteStart < 60_000) return; + const secs = (now - rpcLoad.minuteStart) / 1000; + const m = { at: new Date(rpcLoad.minuteStart).toISOString(), wrpc: Math.round(rpcLoad.wrpc * 60 / secs), evm: Math.round(rpcLoad.evm * 60 / secs) }; + rpcLoad.minutes.push(m); if (rpcLoad.minutes.length > 5) rpcLoad.minutes.shift(); + log(`rpc load: wrpc ${m.wrpc}/min, evm ${m.evm}/min`); + rpcLoad.wrpc = 0; rpcLoad.evm = 0; rpcLoad.minuteStart = now; +} +function rpcLoadState() { + const ms = rpcLoad.minutes; if (!ms.length) return null; + const avg = k => Math.round(ms.reduce((a, m) => a + m[k], 0) / ms.length); + return { wrpc_per_min: ms[ms.length - 1].wrpc, evm_per_min: ms[ms.length - 1].evm, wrpc_per_min_5m: avg('wrpc'), evm_per_min_5m: avg('evm'), minutes: ms.length, evm_rpc: EVM }; +} + +// Explorer detail of a block, all from the notification (header, coinbase, verbose data, EVM transactions). +// Coinbase payload: blue score u64 LE, subsidy u64 LE (the full E(daa) of this block, coinbase.rs), script version +// u16 LE, script length u8, script, extra data. Extra data: the node's version tag, the miner's tag, the key reveal +// (IGNK, finality.rs), the payout address (IGNA || 40 hex, evm.rs), the proof-record section (records || len_le32 || +// IGNP, proving.rs, 274 bytes each) and the finality section (items || len_le32 || IGNF). +const PROOF_RECORD_LEN = 274; +function coinbaseDetail(block, prefix) { + const tx = block.transactions && block.transactions[0]; + if (!tx) return null; + const b = payloadBytes(tx.payload); + if (b.length < 19) return null; + const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); + const subsidy = dv.getBigUint64(8, true); + const extra = b.subarray(19 + b[18]); + const text = Buffer.from(extra).toString('latin1'); + const a = text.indexOf('IGNA'); + const evmMiner = a >= 0 && /^[0-9a-f]{40}$/.test(text.slice(a + 4, a + 44)) ? '0x' + text.slice(a + 4, a + 44) : null; + // proof records: strip the finality section, then read the IGNP trailer + let body = extra; let n = body.length; + if (n >= 8 && String.fromCharCode(...body.subarray(n - 4)) === 'IGNF') { const len = new DataView(body.buffer, body.byteOffset, n).getUint32(n - 8, true); if (len + 8 <= n) body = body.subarray(0, n - 8 - len); } + n = body.length; let records = 0; + if (n >= 8 && String.fromCharCode(...body.subarray(n - 4)) === 'IGNP') { const len = new DataView(body.buffer, body.byteOffset, n).getUint32(n - 8, true); if (len + 8 <= n) records = Math.floor(len / PROOF_RECORD_LEN); } + const outputs = (tx.outputs || []).map(o => { + const spk = typeof o.scriptPublicKey === 'string' ? o.scriptPublicKey : (o.scriptPublicKey && o.scriptPublicKey.script) || ''; + const script = Uint8Array.from(Buffer.from(String(spk).slice(4), 'hex')); // 2-byte version prefix, then the script + const addr = (o.verboseData && o.verboseData.scriptPublicKeyAddress) || scriptToAddress(prefix, script); + const pool = !addr && script.length > 2 && script[0] === 0x6a; // OP_RETURN "igneum-proving-pool-v0": the pool output burns on the UTXO side + return { address: addr, value: String(o.value), pool }; + }); + const paid = outputs.reduce((t, o) => t + BigInt(o.value), 0n); + return { id: tx.verboseData && tx.verboseData.transactionId, subsidy: subsidy.toString(), paid: paid.toString(), outputs, evm_miner: evmMiner, records, reveal: text.includes('IGNK') }; +} +// EVM transactions ride in the block as raw bytes (RpcBlock.evm_transactions, Vec>): the hash is keccak-256 of +// the envelope. Decoding fields from the RLP is left to the execution layer (/api/explorer reads them from the EVM RPC +// when one is configured); here the count and the hashes are enough for the table and the block page. +function evmTxs(block) { + const list = block.evmTransactions || []; + return list.map(t => { + const raw = typeof t === 'string' ? Buffer.from(t.replace(/^0x/, ''), 'hex') : Array.isArray(t) ? Buffer.from(t) : null; + if (!raw) return { hash: null, size: null }; + return { hash: '0x' + Buffer.from(keccak256(raw)).toString('hex'), size: raw.length }; + }); +} +function blockDetail(block, prefix) { + const h = block.header, vd = block.verboseData || {}; + const cb = coinbaseDetail(block, prefix); + const txs = evmTxs(block); + const certs = certificatesIn(block).map(c => c.index); + const vk = h.voteKeyHash || null; + const evmMiner = (cb && cb.evm_miner) || (vk ? '0x' + vk.slice(24, 64) : null); // evm.rs miner_evm_address: bytes 12..32 of the vote key hash + return { + row: { + tx_count: txs.length, evm_miner: evmMiner, proof_records: cb ? cb.records : 0, + subsidy_sompi: cb ? cb.subsidy : null, paid_sompi: cb ? cb.paid : null, selected_parent: vd.selectedParentHash || null, + }, + detail: { + version: h.version, bits: h.bits, nonce: String(h.nonce), blue_work: h.blueWork, hash_merkle_root: h.hashMerkleRoot, + accepted_id_merkle_root: h.acceptedIdMerkleRoot, utxo_commitment: h.utxoCommitment, pruning_point: h.pruningPoint, + difficulty: vd.difficulty ?? null, parents_by_level: Array.isArray(h.parentsByLevel) ? h.parentsByLevel.length : null, + mergeset: { blues: vd.mergeSetBluesHashes || [], reds: vd.mergeSetRedsHashes || [] }, + coinbase: cb, txs, certificates: certs, + }, + }; +} + const pendingBlocks = []; // rows waiting for the next flush const pendingChain = { add: new Set(), remove: new Set() }; const pendingMerge = new Map(); // chain block hash -> { blues, reds } from its verbose data, or null to fetch with getBlock @@ -363,11 +472,15 @@ function onBlock(block) { } if (vd && vd.isChainBlock) queuePlan(h.hash); lastBlockAt = now; blockCountAtLastBlock = lastDagBlockCount; resubscribeAttempts = 0; + let ex = null; try { ex = blockDetail(block, addressPrefix); } catch (e) { log('block detail failed', short(h.hash), e.message); } pendingBlocks.push({ hash: h.hash, blue_score: h.blueScore, daa_score: h.daaScore, timestamp_ms: h.timestamp, parents: parents.length, parent_hashes: parents, is_chain_block: !!(block.verboseData && block.verboseData.isChainBlock), vote_key_hash: vk, miner_address: miner.address, engine: miner.extra, + tx_count: ex ? ex.row.tx_count : null, evm_miner: ex ? ex.row.evm_miner : null, proof_records: ex ? ex.row.proof_records : null, + subsidy_sompi: ex ? ex.row.subsidy_sompi : null, paid_sompi: ex ? ex.row.paid_sompi : null, selected_parent: ex ? ex.row.selected_parent : null, + detail: ex ? JSON.stringify(ex.detail) : null, }); noteArrival(now, Number(h.timestamp) || now); noteWork(now, h.blueWork); @@ -383,11 +496,13 @@ function pgArray(list) { return `{${list.map(s => `"${String(s).replace(/["\\]/g async function flushBlocks() { if (!pendingBlocks.length) return; const rows = pendingBlocks.splice(0, 200); - const cols = ['hash', 'blue_score', 'daa_score', 'timestamp_ms', 'parents', 'parent_hashes', 'is_chain_block', 'vote_key_hash', 'miner_address', 'engine']; + const cols = ['hash', 'blue_score', 'daa_score', 'timestamp_ms', 'parents', 'parent_hashes', 'is_chain_block', 'vote_key_hash', 'miner_address', 'engine', + 'tx_count', 'evm_miner', 'proof_records', 'subsidy_sompi', 'paid_sompi', 'selected_parent', 'detail']; + const cast = { parent_hashes: '::text[]', detail: '::jsonb', subsidy_sompi: '::bigint', paid_sompi: '::bigint' }; const params = []; const values = []; for (const r of rows) { const ph = []; - for (const c of cols) { params.push(c === 'parent_hashes' ? pgArray(r[c]) : r[c]); ph.push(`$${params.length}${c === 'parent_hashes' ? '::text[]' : ''}`); } + for (const c of cols) { params.push(c === 'parent_hashes' ? pgArray(r[c]) : r[c]); ph.push(`$${params.length}${cast[c] || ''}`); } values.push(`(${ph.join(',')})`); } try { @@ -444,6 +559,7 @@ async function tick(rpc) { } async function tickInner(rpc) { const now = Date.now(); + rollRpcLoad(now); let dag, info, peersRes, hps; try { [dag, info, peersRes, hps] = await Promise.all([ @@ -500,17 +616,19 @@ async function tickInner(rpc) { try { await sql(`INSERT INTO ${TS} (id, block_count, header_count, blue_score, difficulty, hashes_per_second_estimate, peers, mempool, - node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality, observer_lag_s, queue_depth, proving) - VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb, $14, $15, $16::jsonb) + node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality, observer_lag_s, queue_depth, proving, rpc_load, supply_check) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb, $14, $15, $16::jsonb, $17::jsonb, $18::jsonb) ON CONFLICT (id) DO UPDATE SET block_count = EXCLUDED.block_count, header_count = EXCLUDED.header_count, blue_score = EXCLUDED.blue_score, difficulty = EXCLUDED.difficulty, hashes_per_second_estimate = EXCLUDED.hashes_per_second_estimate, peers = EXCLUDED.peers, mempool = EXCLUDED.mempool, node_version = EXCLUDED.node_version, network = EXCLUDED.network, blocks_60s = EXCLUDED.blocks_60s, blocks_per_minute = EXCLUDED.blocks_per_minute, observer_started_at = EXCLUDED.observer_started_at, updated_at = now(), - finality = EXCLUDED.finality, observer_lag_s = EXCLUDED.observer_lag_s, queue_depth = EXCLUDED.queue_depth, proving = EXCLUDED.proving`, + finality = EXCLUDED.finality, observer_lag_s = EXCLUDED.observer_lag_s, queue_depth = EXCLUDED.queue_depth, proving = EXCLUDED.proving, + rpc_load = EXCLUDED.rpc_load, supply_check = EXCLUDED.supply_check`, [dag.blockCount, dag.headerCount, (await rpc.call('getSinkBlueScore', {}).catch(() => ({}))).blueScore ?? null, diff, hps ? hps.networkHashesPerSecond : localHashesPerSecond(), peers.length, info.mempoolSize ?? 0, nodeVersion, network, blocks60s(now), JSON.stringify(blocksPerMinute(now)), STARTED_AT.toISOString(), - finality ? JSON.stringify(finality) : null, lagS === null ? null : Math.round(lagS * 10) / 10, queueDepth, JSON.stringify(proving)]); + finality ? JSON.stringify(finality) : null, lagS === null ? null : Math.round(lagS * 10) / 10, queueDepth, JSON.stringify(proving), + JSON.stringify(rpcLoadState()), lastSupplyCheck ? JSON.stringify(lastSupplyCheck) : null]); } catch (e) { log('state write failed', e.message); } } @@ -741,6 +859,7 @@ const hx = v => (v === null || v === undefined ? null : (typeof v === 'string' & const weiStr = v => { if (v === null || v === undefined) return null; try { return BigInt(v).toString(); } catch { return null; } }; async function evm(method, params = []) { + rpcLoad.evm++; const r = await fetch(EVM, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(8000) }); const j = await r.json(); if (j.error) { const e = new Error(`${method}: ${j.error.message}`); e.code = j.error.code; throw e; } @@ -802,6 +921,9 @@ async function flushPlans() { // block_ts comes from the block table (the header time the page's axis uses), one update for the batch await sql(`INSERT INTO ${TP} (block_hash, shard, shards, block_number, block_daa, pgas) VALUES ${values.join(',')} ON CONFLICT (block_hash, shard) DO NOTHING`, params); await sql(`UPDATE ${TP} p SET block_ts = b.timestamp_ms FROM ${TB} b WHERE b.hash = p.block_hash AND p.block_ts IS NULL AND p.block_hash = ANY($1::text[])`, [pgArray([...new Set(rows.map(r => r.hash))])]).catch(() => { }); + // explorer: the chain block's number (the EVM block number), one update for the batch, no extra RPC + const nums = [...new Map(rows.map(r => [r.hash, r.number]))]; + await sql(`UPDATE ${TB} b SET number = v.n::bigint FROM (SELECT unnest($1::text[]) AS h, unnest($2::text[]) AS n) v WHERE b.hash = v.h AND b.number IS NULL`, [pgArray(nums.map(x => x[0])), pgArray(nums.map(x => String(x[1])))]).catch(e => log('number update failed', e.message)); } } catch (e) { log('plan flush failed', e.message); } finally { plansBusy = false; } @@ -880,6 +1002,33 @@ async function provingState() { }; } +// ---------- Supply check (spec 2.5 against the chain) ---------- +// Two comparisons over the newest stored blocks, once an hour and 20 s after start: (a) the subsidy each payload +// declares equals blockSubsidy(daa, 1) of site/lib/emission.mjs; (b) each block's outputs sum to the declared subsidy +// of every block it merges (utxo_validation.rs:176 pays a merged block what its own payload declares; blues and reds +// inside the DAA window alike). Written to live_state.supply_check; /api/supply shows it beside the rule's total. +const SUPPLY_CHECK_EVERY_MS = 60 * 60_000, SUPPLY_SAMPLE = 500; +let lastSupplyCheck = null; +async function supplyCheck() { + try { + const rows = await sql(`SELECT hash, daa_score, subsidy_sompi, paid_sompi, detail->'mergeset' AS mergeset FROM ${TB} + WHERE subsidy_sompi IS NOT NULL ORDER BY received_at DESC LIMIT ${SUPPLY_SAMPLE}`); + const byHash = new Map(rows.map(r => [r.hash, r])); + let ruleOk = 0, ruleBad = 0, sumOk = 0, sumBad = 0, sumSkipped = 0; const examples = []; + for (const r of rows) { + const want = blockSubsidy(Number(r.daa_score), 1); + if (BigInt(r.subsidy_sompi) === want) ruleOk++; else { ruleBad++; if (examples.length < 3) examples.push({ kind: 'rule', block: short(r.hash), daa: Number(r.daa_score), declared: String(r.subsidy_sompi), rule: want.toString() }); } + const ms = r.mergeset || { blues: [], reds: [] }; + const merged = [...(ms.blues || []), ...(ms.reds || [])]; + if (!merged.length || merged.some(h => !byHash.has(h))) { sumSkipped++; continue; } // a merged block outside the sample: no verdict + const expect = merged.reduce((t, h) => t + BigInt(byHash.get(h).subsidy_sompi), 0n); + if (BigInt(r.paid_sompi) === expect) sumOk++; else { sumBad++; if (examples.length < 3) examples.push({ kind: 'sum', block: short(r.hash), paid: String(r.paid_sompi), expected: expect.toString(), merged: merged.length }); } + } + lastSupplyCheck = { checked_at: new Date().toISOString(), sampled: rows.length, rule_match: ruleOk, rule_mismatch: ruleBad, sum_match: sumOk, sum_mismatch: sumBad, sum_skipped: sumSkipped, examples, bps: 1 }; + log(`supply check: ${rows.length} blocks, payload subsidy = rule ${ruleOk}/${ruleOk + ruleBad}, outputs = merged subsidies ${sumOk}/${sumOk + sumBad} (${sumSkipped} without a verdict)`); + } catch (e) { log('supply check failed', e.message); } +} + async function prune() { try { await sql(`DELETE FROM ${TB} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]); @@ -988,6 +1137,7 @@ async function main() { setInterval(pollRecords, STATE_EVERY_MS); setInterval(() => tick(rpc), STATE_EVERY_MS); setInterval(prune, PRUNE_EVERY_MS); + setInterval(supplyCheck, SUPPLY_CHECK_EVERY_MS); setTimeout(supplyCheck, 20_000); tick(rpc); prune(); } From 1d39b0e355d474d25900b09b35b41ed4822edb87 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:35:40 +0100 Subject: [PATCH 2/4] 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 --- docs/api/public-stats.md | 227 +++++++++++++++++++++++++++++++++ site/api/_neon.mjs | 26 ++++ site/api/public-stats.test.mjs | 73 +++++++++++ site/api/stats.mjs | 90 +++++++++++++ site/api/supply.mjs | 76 +++++++++++ tools/ci/public-api-check.mjs | 27 ++++ 6 files changed, 519 insertions(+) create mode 100644 docs/api/public-stats.md create mode 100644 site/api/_neon.mjs create mode 100644 site/api/public-stats.test.mjs create mode 100644 site/api/stats.mjs create mode 100644 site/api/supply.mjs create mode 100644 tools/ci/public-api-check.mjs diff --git a/docs/api/public-stats.md b/docs/api/public-stats.md new file mode 100644 index 000000000..d41fb08ae --- /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 000000000..d6b05aa5b --- /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 000000000..c7853f41c --- /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 000000000..bc07b0d8d --- /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 000000000..cdb70efc1 --- /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 000000000..488307edc --- /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`); From 658daf698a7340c0f906fcffc845916f179a1931 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:35:40 +0100 Subject: [PATCH 3/4] Explorer pages: /explorer, /block/, /address/, the search router, a local preview server, CI Fed by /api/explorer from the observer's tables. Latest blocks (hash, number, DAA, blue score, miner, txs, proof records, time); a block's header, parents, children, mergeset, coinbase outputs, EVM transactions, shards, checkpoint and certificate; an address's blocks, what they earned, vote keys and balance (eth_getBalance when EXPLORER_EVM_RPC is set). Same tokens as live.html. vercel.json rewrites /block/:id and /address/:addr; the footer links the explorer; the link checker skips template literals and resolves /api/ to its function. node tools/site-serve.mjs previews the site with the functions in-process. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 5 + site/404.html | 1 + site/address.html | 297 +++++++++++++++++++++++++++++++ site/api/explorer.mjs | 152 ++++++++++++++++ site/bench.html | 1 + site/block.html | 330 +++++++++++++++++++++++++++++++++++ site/build.mjs | 4 +- site/evidence.html | 1 + site/explorer.html | 347 +++++++++++++++++++++++++++++++++++++ site/faucet.html | 1 + site/index.html | 1 + site/lib/explorer.mjs | 90 ++++++++++ site/lib/explorer.test.mjs | 58 +++++++ site/litepaper.html | 1 + site/live.html | 1 + site/metamask.html | 1 + site/miner.html | 1 + site/miners.html | 1 + site/partials/footer.html | 1 + site/vercel.json | 23 ++- site/wallet.html | 1 + tools/ci/link-check.mjs | 8 +- tools/site-serve.mjs | 44 +++++ 23 files changed, 1356 insertions(+), 14 deletions(-) create mode 100644 site/address.html create mode 100644 site/api/explorer.mjs create mode 100644 site/block.html create mode 100644 site/explorer.html create mode 100644 site/lib/explorer.mjs create mode 100644 site/lib/explorer.test.mjs create mode 100644 tools/site-serve.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea5ce0451..9879a98b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,11 @@ jobs: run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh - name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors) run: node --test site/api/faucet.test.mjs + - name: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture) + run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs + - name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge) + if: github.ref == 'refs/heads/master' + run: node tools/ci/public-api-check.mjs https://igneum.network - name: ship tool self-test (version bump, the dl-both and public manifest helpers) run: node tools/ship-app.mjs --self-test - name: relay unit tests (parsers, secret compare, the wake endpoint) diff --git a/site/404.html b/site/404.html index 321532bd4..076e9b219 100644 --- a/site/404.html +++ b/site/404.html @@ -172,6 +172,7 @@ p{margin:0;color:var(--ink-2);max-width:52ch}