From 894fe08f3448535b9a4079b8e52340956dfaffff Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:29:45 +0000 Subject: [PATCH] Explorer: a schema-gap read answers 503 with the reason, the supply tile names the cap and what is ever minted (reviewer C13), the plan records the live restart Until an observer has started on new code, a read of a column it adds fails with Postgres 42703; the three public handlers now answer 503 "the observer has not been restarted on the explorer code yet" instead of 500, and the pages show it. The Circulating tile reads "cap 4,000,000,000, 3.96 billion ever minted" with the withheld figure on hover; the homepage tile and the litepaper keep the project lead's words (consequences-decisions D3, D7). The live observer was restarted on this code at 19:42:50 UTC by another session; its counters are in the plan. Co-Authored-By: Claude Fable 5.1 --- docs/plans/explorer.md | 27 +++++++++++++++++++++++---- site/api/_neon.mjs | 9 +++++++++ site/api/explorer.mjs | 4 +++- site/api/stats.mjs | 4 +++- site/api/supply.mjs | 4 +++- site/explorer.html | 5 +++-- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/docs/plans/explorer.md b/docs/plans/explorer.md index 29b0ac4c5..9c81e5fa8 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 d6b05aa5b..84dfcda02 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 02ad7f454..7c7cf8a56 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 bc07b0d8d..eb18517ec 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 cdb70efc1..e4bd985f7 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 d5b831119..2fa93b3ee 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)}`;