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