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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 19:35:40 +00:00
parent 5aa75166d8
commit 8ffcdd2182
4 changed files with 337 additions and 8 deletions

122
site/lib/emission.mjs Normal file
View file

@ -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 : '');
}

View file

@ -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');
});

View file

@ -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/<hash>` and `/address/<addr>` 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.

View file

@ -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/<hash> and
// /address/<addr> 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<Vec<u8>>): 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();
}