igneum/site/lib/explorer.mjs
igneum-labs 4295346a2c Explorer pages: /explorer, /block/<hash|number>, /address/<addr>, the search router, a local preview server, CI
Fed by /api/explorer from the observer's tables. Latest blocks (hash, number, DAA, blue score, miner, txs, proof
records, time); a block's header, parents, children, mergeset, coinbase outputs, EVM transactions, shards, checkpoint
and certificate; an address's blocks, what they earned, vote keys and balance (eth_getBalance when EXPLORER_EVM_RPC is
set). Same tokens as live.html. vercel.json rewrites /block/:id and /address/:addr; the footer links the explorer; the
link checker skips template literals and resolves /api/<name> to its function. node tools/site-serve.mjs previews the
site with the functions in-process.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 19:35:40 +00:00

90 lines
4.2 KiB
JavaScript

// Igneum explorer: the search router and the formatters shared by /api/explorer and the pages (the pages inline the
// same functions; site/lib/explorer.test.mjs checks both). Zero dependencies.
const HEX64 = /^(0x)?[0-9a-f]{64}$/i;
const HEX40 = /^0x[0-9a-f]{40}$/i;
const BECH = /^(igneum|igneumdev|igneumtest|igneumsim):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{20,}$/i;
const DIGITS = /^\d{1,12}$/;
/**
* Classifies what someone typed in the search box.
* 64 hex (0x optional) -> {type: 'hash'} a DAG block hash, or an EVM transaction hash; the API tries the block first
* 0x + 40 hex -> {type: 'address'} an EVM payout address
* igneum...:bech32 -> {type: 'address'} the coinbase's UTXO address
* digits -> {type: 'height'} a chain block number (the EVM block number)
* anything else -> {type: 'none'}
* `value` is the normalised form: hashes without 0x and lower case, 0x addresses lower case, numbers as Number.
*/
export function classify(input) {
const q = String(input || '').trim();
if (!q) return { type: 'none', value: null };
if (HEX64.test(q)) return { type: 'hash', value: q.replace(/^0x/i, '').toLowerCase() };
if (HEX40.test(q)) return { type: 'address', value: q.toLowerCase(), kind: 'evm' };
if (BECH.test(q)) return { type: 'address', value: q.toLowerCase(), kind: 'utxo' };
if (DIGITS.test(q)) return { type: 'height', value: Number(q) };
return { type: 'none', value: null };
}
/** The page a classified search leads to. A hash goes to /block/<hash>; the API answers 404 with a hint when it is not a block. */
export function hrefFor(c) {
if (c.type === 'hash') return `/block/${c.value}`;
if (c.type === 'address') return `/address/${c.value}`;
if (c.type === 'height') return `/block/${c.value}`;
return null;
}
/** sompi (8 decimals) as IGN text, trailing zeros trimmed, at most `places` places. */
export function ign(sompi, places = 4) {
if (sompi === null || sompi === undefined) return 'n/a';
let v; try { v = BigInt(sompi); } catch { return 'n/a'; }
const neg = v < 0n; if (neg) v = -v;
const whole = v / 100000000n, frac = (v % 100000000n).toString().padStart(8, '0').slice(0, places).replace(/0+$/, '');
return (neg ? '-' : '') + whole.toLocaleString('en-GB') + (frac ? '.' + frac : '');
}
/** wei (18 decimals) as IGN text. */
export function ignFromWei(wei, places = 4) {
if (wei === null || wei === undefined) return 'n/a';
let v; try { v = BigInt(wei); } catch { return 'n/a'; }
const neg = v < 0n; if (neg) v = -v; const U = 10n ** 18n;
const whole = v / U, frac = (v % U).toString().padStart(18, '0').slice(0, places).replace(/0+$/, '');
return (neg ? '-' : '') + whole.toLocaleString('en-GB') + (frac ? '.' + frac : '');
}
/** First and last characters of a hash or address: 8 and 6 by default. */
export function shortHash(h, head = 8, tail = 6) {
const s = String(h || ''); if (!s) return '';
if (s.length <= head + tail + 1) return s;
return s.slice(0, head) + '…' + s.slice(-tail);
}
/** A hash rate in H/s with a unit: 27,759,288 -> "27.8 MH/s". */
export function hashrate(n) {
if (n === null || n === undefined || isNaN(n)) return 'n/a';
const u = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s']; let i = 0; let v = Number(n);
while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
return (i ? v.toFixed(1) : String(Math.round(v))) + ' ' + u[i];
}
/** A relative time from a millisecond difference: "12 s ago", "3 min ago", "2 h 5 min ago", "4 d ago". */
export function rel(ms) {
if (ms === null || ms === undefined || isNaN(ms)) return 'n/a';
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} s ago`;
const m = Math.floor(s / 60); if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60); if (h < 24) return `${h} h ${m % 60} min ago`;
return `${Math.floor(h / 24)} d ago`;
}
/** Thousands-separated integer, or n/a. */
export function int(n) {
if (n === null || n === undefined || isNaN(n)) return 'n/a';
return Math.round(Number(n)).toLocaleString('en-GB');
}
/** A millisecond timestamp as "2026-10-05 19:21:56 UTC". */
export function utc(ms) {
if (!ms) return 'n/a';
const d = new Date(Number(ms)); if (isNaN(d)) return 'n/a';
return d.toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
}