igneum/site/api/supply.mjs
igneum-labs 4b052d63c8 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>
2026-10-05 19:35:40 +00:00

76 lines
5.5 KiB
JavaScript

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