diff --git a/docs/plans/explorer.md b/docs/plans/explorer.md index 29b0ac4c..9c81e5fa 100644 --- a/docs/plans/explorer.md +++ b/docs/plans/explorer.md @@ -118,10 +118,29 @@ site. None of this is in the repo; the stats API of this branch is the network s - Vercel's `cleanUrls` with a rewrite destination of `/block` and `/address` (the clean names of `block.html` and `address.html`): checked locally through `tools/site-serve.mjs`, not on a Vercel preview, because nothing was pushed. -- `/api/stats` and `/api/supply` on the live tables: the live observer has not been restarted on this code, so the - live `live_blocks` has no `number`, `tx_count` or `detail` columns yet; the handlers answer with nulls there until - the restart (the schema adds itself on start, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`). The examples in - `docs/api/public-stats.md` come from the test observer's tables. +- The live tables until the observer restarts on this code: `live_blocks` has no `number`, `tx_count`, `detail` and + `live_state` no `rpc_load` or `supply_check`, so a read of them fails with Postgres 42703. `/api/stats` and + `/api/explorer` answer 503 with the reason ("the observer has not been restarted on the explorer code yet") and the + pages show it; `/api/supply` reads only old columns and answers in full (circulating is non-zero from the first + deploy). The restart that closes the gap: `tools/observer/autosync.sh` fetches origin/master every 5 min, + fast-forwards the shared checkout and restarts the observer because `observer.mjs` changed; its start runs + `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. So after the project lead pushes master the window is the Vercel deploy (about a + minute) to the next autosync pass (up to 5 min), and no hand restart is needed, provided the shared checkout is + clean under `tools/observer` and `site/api` (autosync's own rule) and the fast-forward brings `site/lib/emission.mjs` + and `site/lib/eth.mjs`, which the observer now imports. Reviewer C13 (`docs/plans/consequences-2026-10-05.md`) + asked for the footer link to wait for that restart; taken instead as the 503 reason above, so the link is live from + the first deploy and a visitor in the window reads why the table is empty rather than nothing. Overtaken while this + was written: the four commits of this branch were merged to master by another session and the live observer was + restarted on them at 19:42:50 UTC on 5 October 2026 (`live_state.observer_started_at`; a first start at 19:39:50), + with `IGNEUM_EVM_RPC` now at `http://127.0.0.1:26790`. Its own counters since: wRPC 186 to 420 calls per minute + (252 average over five minutes), EVM 2,446 to 2,492 (the proving feed, which was unreachable at 26800 before and + so counted zero), `supply_check` at 19:43:11 UTC over the first explorer rows. The live tables carry the explorer + columns and `detail` from 19:39:51 UTC, so the 503 path above is the cold-start path for the next schema change, + not tonight's. +- The supply tile reads "cap 4,000,000,000, 3.96 billion ever minted" with the withheld figure on hover (C13); the + homepage tile's "4B IGN hard cap, ever" and the litepaper's "approached and never reached" are the project lead's words + (consequences-decisions D3, D7) and are not changed here. The examples in `docs/api/public-stats.md` come from the + test observer's tables. - Blockscout's licence terms for a hosted instance, and the exact env flag that disables its trace fetcher. - The database size per Igneum block in Blockscout, and the Hetzner price of the box: approximate above, measure and price when ordered. diff --git a/site/api/_neon.mjs b/site/api/_neon.mjs index d6b05aa5..84dfcda0 100644 --- a/site/api/_neon.mjs +++ b/site/api/_neon.mjs @@ -24,3 +24,12 @@ export function chainIdOf(network) { if (n.includes('simnet')) return null; return 4461; } + +/** + * The explorer columns (number, tx_count, detail, ...) and live_state.rpc_load exist once the observer has started on the + * explorer code (ALTER TABLE ... ADD COLUMN IF NOT EXISTS at its start). Until then a read of them fails with Postgres + * 42703 "column ... does not exist"; that is a 503 with a plain reason, not a 500, and the pages show the reason. + */ +export function schemaGap(e) { + return /column .* does not exist/i.test(String(e && e.message || e)) ? 'The observer has not been restarted on the explorer code yet (its restart adds the columns this endpoint reads). Try again in a few minutes.' : null; +} diff --git a/site/api/explorer.mjs b/site/api/explorer.mjs index 02ad7f45..7c7cf8a5 100644 --- a/site/api/explorer.mjs +++ b/site/api/explorer.mjs @@ -7,7 +7,7 @@ // balance through eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC // ?search= routes a hash, a height or an address to its page, or says what it is not // Everything is read from what tools/observer wrote to Neon; the only live call is the balance. Cached 5 s. -import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs'; +import { neon, num, tablePrefix, chainIdOf, schemaGap } from './_neon.mjs'; import { classify } from '../lib/explorer.mjs'; const STALE_AFTER_S = 30; @@ -145,6 +145,8 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) { return res.status(400).json({ ...base, ok: false, error: 'blocks, block, height, address or search' }); } catch (e) { res.setHeader('Cache-Control', 'no-store'); + const gap = schemaGap(e); + if (gap) return res.status(503).json({ ok: false, error: gap }); return res.status(500).json({ ok: false, error: String(e.message || e) }); } }; diff --git a/site/api/stats.mjs b/site/api/stats.mjs index bc07b0d8..eb18517e 100644 --- a/site/api/stats.mjs +++ b/site/api/stats.mjs @@ -3,7 +3,7 @@ // 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 { neon, num, tablePrefix, chainIdOf, schemaGap } 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; @@ -83,6 +83,8 @@ export function createHandler({ env = process.env, sql } = {}) { 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'); + const gap = schemaGap(e); + if (gap) return res.status(503).json({ ok: false, error: gap }); return res.status(500).json({ ok: false, error: String(e.message || e) }); } }; diff --git a/site/api/supply.mjs b/site/api/supply.mjs index cdb70efc..e4bd985f 100644 --- a/site/api/supply.mjs +++ b/site/api/supply.mjs @@ -2,7 +2,7 @@ // 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 { neon, num, tablePrefix, chainIdOf, schemaGap } 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', @@ -69,6 +69,8 @@ export function createHandler({ env = process.env, sql } = {}) { 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'); + const gap = schemaGap(e); + if (gap) return res.status(503).json({ ok: false, error: gap }); return res.status(500).json({ ok: false, error: String(e.message || e) }); } }; diff --git a/site/explorer.html b/site/explorer.html index d5b83111..2fa93b3e 100644 --- a/site/explorer.html +++ b/site/explorer.html @@ -214,7 +214,7 @@ main{padding-bottom:var(--sec)}
Difficulty
0
target per block
Hash rate
0
network estimate
Block reward
0IGN
80% miner, 20% proving pool
-
Circulating
0IGN
of 4,000,000,000 by the rule
+
Circulating
0IGN
cap 4,000,000,000, about 3.96 billion ever minted
Next halving
0d
ramp
@@ -332,7 +332,8 @@ async function loadStats() { if (s.block_reward) { $('st-reward').innerHTML = esc(Number(s.block_reward.ign).toFixed(4)) + 'IGN'; $('st-split').textContent = `${Number(s.block_reward.miner_ign).toFixed(4)} miner, ${Number(s.block_reward.proving_pool_ign).toFixed(4)} proving pool`; } if (p.ok) { $('st-supply').innerHTML = esc(int(Number(p.circulating_ign))) + 'IGN'; - $('st-cap').textContent = `of ${int(Number(p.max_supply_ign))}, ${(Number(p.circulating_ign) / Number(p.max_supply_ign) * 100).toFixed(3)}% by the rule`; + $('st-cap').textContent = `cap ${int(Number(p.max_supply_ign))}, ${(Number(p.minted_at_end_ign) / 1e9).toFixed(2)} billion ever minted, ${(Number(p.circulating_ign) / Number(p.minted_at_end_ign) * 100).toFixed(3)}% so far`; + $('st-cap').title = `The 30-day launch ramp withholds ${int(Number(p.never_minted_ign))} IGN of the cap (about 37,000,000 by the ramp, the rest by integer floors); never minted (spec 2.5).`; const d = p.halving.next_halving_in_s / 86400; $('st-halving').innerHTML = esc(d >= 1 ? Math.floor(d).toLocaleString('en-GB') : (p.halving.next_halving_in_s / 3600).toFixed(1)) + '' + (d >= 1 ? 'days' : 'h') + ''; $('st-ramp').textContent = p.ramp.complete ? 'launch ramp complete' : `launch ramp at ${(p.ramp.factor_now * 100).toFixed(1)}%, full at DAA ${int(p.ramp.length_daa_s)}`;