igneum/tools/reference-apps/light-service/serve.mjs

276 lines
18 KiB
JavaScript

// Igneum reference apps, the read service (8 October 2026). Runs on build-1 at 127.0.0.1:26890 behind
// https://rpc.devnet.igneum.network/light/ (the build-server lane's Caddy route, prefix stripped). GET only, CORS open,
// answers JSON that a browser verifies by itself with site/lc/core.js: nothing here is a verdict, everything is data
// the page recomputes. Reads a Devnet 3 node that executes (the exec JSON-RPC for records, receipts and eth_getProof;
// the wRPC JSON websocket for block bodies and headers).
//
// /checkpoint the latest certified checkpoint (proxied from the site's /api/checkpoint?source=dn3)
// /balance?address=0x..&checkpoint=<hash>&index=<n>
// the newest paid segment record under that checkpoint, the carrier block's coinbase and
// merkle path, the header path carrier..checkpoint, and eth_getProof at the record's block
// /receipt?tx=0x..&checkpoint=<hash>&index=<n>
// the raw transaction, its including block's merkle path, the header path to the checkpoint
// /health
//
// Env: LIGHT_PORT (26890), EXEC_RPC (http://127.0.0.1:26881), WRPC (ws://127.0.0.1:28880), DATABASE_URL (the Devnet 3
// observer's Neon string: /checkpoint reads the dn3_ rows directly), else CHECKPOINT_URL (the site's API). Zero npm dependencies beyond @noble/hashes
// (blake2b, keccak) installed beside this file on the box.
import { createServer } from 'node:http';
import { blake2b } from '@noble/hashes/blake2.js';
import { keccak_256 } from '@noble/hashes/sha3.js';
import { coinbaseTxHash, merkleRoot, merklePath, hexToBytes, bytesToHex, segmentRecordsOf } from '../../../site/lc/core.js';
import { readCheckpoint, earliestCheckpointAbove, neon } from '../../../site/api/checkpoint.mjs';
const PORT = Number(process.env.LIGHT_PORT || 26890);
const EXEC = process.env.EXEC_RPC || 'http://127.0.0.1:26881';
const WRPC = process.env.WRPC || 'ws://127.0.0.1:28880';
const CHECKPOINT_URL = process.env.CHECKPOINT_URL || 'https://igneum.network/api/checkpoint?source=dn3';
const SEGMENT_BLOCKS = 8;
const strip = s => String(s).replace(/^0x/i, '').toLowerCase();
const log = (...a) => console.log(new Date().toISOString(), ...a);
// ---- the exec JSON-RPC -------------------------------------------------------------------------------------------
async function exec(method, params) {
const r = await fetch(EXEC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) });
const j = await r.json();
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
return j.result;
}
// ---- the wRPC JSON websocket, one connection, reopened on loss; u64 nonces kept as strings ---------------------------
let ws = null, wsOpen = null, nextId = 1; const pending = new Map();
function wrpcConnect() {
if (wsOpen) return wsOpen;
wsOpen = new Promise((resolve, reject) => {
const s = new WebSocket(WRPC);
s.addEventListener('open', () => { ws = s; resolve(s); });
s.addEventListener('error', e => { wsOpen = null; reject(new Error('wrpc: ' + (e.message || 'socket error'))); });
s.addEventListener('close', () => { ws = null; wsOpen = null; for (const [, p] of pending) p.reject(new Error('wrpc: closed')); pending.clear(); });
s.addEventListener('message', e => {
let m; try { m = JSON.parse(String(e.data).replace(/"nonce":(\d+)/g, '"nonce":"$1"')); } catch { return; }
const p = pending.get(m.id); if (!p) return; pending.delete(m.id);
if (m.error) p.reject(new Error('wrpc ' + p.method + ': ' + (m.error.message || JSON.stringify(m.error)))); else p.resolve(m.params);
});
});
return wsOpen;
}
async function wrpc(method, params) {
const s = await wrpcConnect();
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject, method });
s.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error('wrpc ' + method + ': timeout')); } }, 20000);
});
}
// ---- headers: the verifier's shape (site/verify/core.js headerHash), cached by hash ---------------------------------
const headerCache = new Map();
function shapeHeader(h) {
return {
hash: h.hash, version: h.version, parents_by_level: h.parentsByLevel, hash_merkle_root: h.hashMerkleRoot, accepted_id_merkle_root: h.acceptedIdMerkleRoot,
utxo_commitment: h.utxoCommitment, timestamp: String(h.timestamp), bits: h.bits, nonce: String(h.nonce), daa_score: String(h.daaScore),
blue_work: h.blueWork, blue_score: String(h.blueScore), pruning_point: h.pruningPoint, vote_key_hash: h.voteKeyHash,
};
}
async function header(hash) {
const k = strip(hash);
if (headerCache.has(k)) return headerCache.get(k);
const b = (await wrpc('getBlock', { hash: k, includeTransactions: false })).block;
const h = shapeHeader(b.header);
if (headerCache.size > 20000) headerCache.clear();
headerCache.set(k, h);
return h;
}
async function body(hash) {
return (await wrpc('getBlock', { hash: strip(hash), includeTransactions: true })).block;
}
// The header path from `from` (a block in the past of chain block `chainNumber`) up to the checkpoint (chain block
// `cpNumber`): [from, ..., chain block chainNumber, ..., checkpoint]; every next header names the previous as a direct parent.
async function headerPath(fromHash, chainNumber, cpNumber, cpHash) {
const chainHashes = [];
for (let n = chainNumber; n <= cpNumber; n++) {
const b = await exec('eth_getBlockByNumber', ['0x' + n.toString(16), false]);
if (!b) throw new Error(`chain block ${n} is not executed on this node`);
chainHashes.push(strip(b.hash));
}
if (chainHashes[chainHashes.length - 1] !== strip(cpHash)) throw new Error(`chain block ${cpNumber} on this node is ${chainHashes[chainHashes.length - 1].slice(0, 12)}, not the certified checkpoint ${strip(cpHash).slice(0, 12)}`);
// down from the chain block to `from` through direct parents (a merged block sits a few parents below)
const start = chainHashes[0];
let down = [];
if (strip(fromHash) !== start) {
const target = strip(fromHash);
const prev = new Map([[start, null]]);
let frontier = [start]; let found = false;
for (let depth = 0; depth < 80 && frontier.length && !found; depth++) {
const next = [];
for (const h of frontier) {
const hd = await header(h);
for (const p of (hd.parents_by_level[0] || [])) {
const ps = strip(p);
if (prev.has(ps)) continue;
prev.set(ps, h);
if (ps === target) { found = true; break; }
next.push(ps);
}
if (found) break;
}
frontier = next;
}
if (!found) throw new Error(`block ${target.slice(0, 12)} is not within 80 parents below chain block ${chainNumber}`);
for (let h = target; h !== null; h = prev.get(h)) down.push(h);
down = down.slice(0, -1); // ends with the chain block, which chainHashes carries
}
const order = [...down, ...chainHashes];
const out = [];
for (const h of order) out.push(await header(h));
for (let i = 1; i < out.length; i++) if (!(out[i].parents_by_level[0] || []).map(strip).includes(strip(out[i - 1].hash))) throw new Error(`header ${i} of the path does not name header ${i - 1} as a direct parent`);
return out;
}
// ---- the checkpoint -----------------------------------------------------------------------------------------------
async function checkpoint() {
if (process.env.DATABASE_URL) {
// the Devnet 3 observer's rows, read the way the site's /api/checkpoint reads them (DATABASE_URL from the box's observer env)
const cp = await readCheckpoint(neon(), 'dn3');
if (!cp) throw httpError(404, 'no certified Devnet 3 checkpoint stored yet');
return { ok: true, now: new Date().toISOString(), ...cp };
}
const r = await fetch(CHECKPOINT_URL, { signal: AbortSignal.timeout(15000), cache: 'no-store' });
const j = await r.json();
if (!j.ok) throw new Error('checkpoint: ' + (j.error || r.status));
return j;
}
async function chainNumberOf(hash) {
const b = await exec('eth_getBlockByHash', ['0x' + strip(hash), false]);
if (!b) throw new Error(`the checkpoint ${strip(hash).slice(0, 12)} is not an executed chain block on this node yet`);
return Number(b.number);
}
// ---- /balance -------------------------------------------------------------------------------------------------------
async function balance(q) {
const address = q.get('address') || '';
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) throw httpError(400, 'address: 0x and 40 hex digits');
const cpHash = q.get('checkpoint'), cpIndex = Number(q.get('index'));
if (!cpHash || !(cpIndex >= 0)) throw httpError(400, 'checkpoint and index are required (from /api/checkpoint?source=dn3)');
const cpNumber = await chainNumberOf(cpHash);
// the newest paid segment record carried at or below the checkpoint
let chosen = null;
for (let first = Math.floor(cpNumber / SEGMENT_BLOCKS) * SEGMENT_BLOCKS, k = 0; first >= 0 && k < 400; first -= SEGMENT_BLOCKS, k++) {
const s = await exec('igneum_getSegmentRecords', ['0x' + first.toString(16)]);
if (!s || !s.paid) continue;
const carrierNumber = Number(s.paid.carrierNumber);
if (carrierNumber > cpNumber) continue;
const c = (s.carried || []).find(x => x.valid && Number(x.carrierNumber) === carrierNumber && strip(x.keyHash) === strip(s.paid.keyHash));
if (!c) continue;
chosen = { first, last: Number(c.last), carrier: strip(c.carrier), carrierNumber, keyHash: c.keyHash, statement: c.statement, paidWei: s.paid.wei };
break;
}
if (!chosen) throw httpError(503, `no paid segment record carried at or below chain block ${cpNumber} within 400 segments`);
const carrier = await body(chosen.carrier);
const coinbase = carrier.transactions[0];
const evmHashes = (carrier.evmTransactions || []).map(raw => keccak_256(hexToBytes(strip(raw))));
const leaves = [coinbaseTxHash(coinbase, blake2b), ...evmHashes];
const root = bytesToHex(merkleRoot(leaves, blake2b));
if (root !== strip(carrier.header.hashMerkleRoot)) throw httpError(500, `the carrier's body root recomputes to ${root.slice(0, 12)}, the header says ${strip(carrier.header.hashMerkleRoot).slice(0, 12)}: the serialisation here is wrong, not the chain`);
const records = segmentRecordsOf(hexToBytes(strip(coinbase.payload)));
const rec = records.find(r => Number(r.first) === chosen.first && Number(r.last) === chosen.last);
if (!rec) throw httpError(500, `the carrier's coinbase holds ${records.length} segment record(s), none for segment ${chosen.first}`);
// the smallest proof: the earliest certified checkpoint at or above the carrier (the certificate used travels in the answer)
let certificate = null;
if (process.env.DATABASE_URL) certificate = await earliestCheckpointAbove(neon(), chosen.carrierNumber, chainNumberOf).catch(() => null);
let cpN = cpNumber, cpH = cpHash, cpI = cpIndex;
if (certificate) { cpH = certificate.hash; cpI = Number(certificate.index); cpN = await chainNumberOf(cpH); }
const headers = await headerPath(chosen.carrier, chosen.carrierNumber, cpN, cpH);
const account = await exec('eth_getProof', [address, [], '0x' + chosen.last.toString(16)]);
const chainId = await exec('eth_chainId', []);
return {
ok: true, now: new Date().toISOString(), chain_id: 'igneum-devnet-3', address,
checkpoint: { hash: strip(cpH), index: cpI, chain_block: cpN },
checkpoint_certificate: certificate ? { ok: true, ...certificate } : undefined,
segment: { first: chosen.first, last: chosen.last, carrier: chosen.carrier, carrier_chain_block: chosen.carrierNumber, aggregator_key_hash: chosen.keyHash, paid_wei: chosen.paidWei, statement: chosen.statement },
headers,
carrier: { coinbase, evm_tx_hashes: evmHashes.map(bytesToHex), leaf_index: 0, merkle_siblings: siblings(leaves, 0), amount_wire_len: 8 },
segment_record_hex: bytesToHex(rec.bytes),
account: { ...account, evm_chain_id: Number(chainId) },
};
}
function siblings(leaves, index) {
// recompute the actual sibling hashes level by level (merklePath only gives the shape)
const H = d => blake2b(d, { dkLen: 32, key: new TextEncoder().encode('MerkleBranchHash') });
const Z = new Uint8Array(32); const out = []; let level = leaves.slice(); let i = index;
while (level.length > 1) {
out.push(bytesToHex(i % 2 === 0 ? (level[i + 1] || Z) : level[i - 1]));
const next = []; for (let k = 0; k < level.length; k += 2) { const l = level[k], r = level[k + 1] || Z; const m = new Uint8Array(64); m.set(l); m.set(r, 32); next.push(H(m)); }
level = next; i = i >> 1;
}
return out;
}
void merklePath;
// ---- /receipt -------------------------------------------------------------------------------------------------------
async function receipt(q) {
const tx = q.get('tx') || '';
if (!/^0x[0-9a-fA-F]{64}$/.test(tx)) throw httpError(400, 'tx: 0x and 64 hex digits');
let cpHash = q.get('checkpoint'), cpIndex = Number(q.get('index'));
if (!cpHash || !(cpIndex >= 0)) throw httpError(400, 'checkpoint and index are required (from /checkpoint)');
const t = await exec('eth_getTransactionByHash', [tx]);
if (!t) throw httpError(404, 'the node has not executed a transaction with that hash (unknown, or not yet in a block)');
const rcpt = await exec('eth_getTransactionReceipt', [tx]);
const chainNumber = Number(t.blockNumber);
// the smallest proof: the earliest certified checkpoint at or above the block (from the Devnet 3 observer's rows), else
// the checkpoint the caller named; the certificate used is returned so the page verifies against that one
let certificate = null;
if (process.env.DATABASE_URL) certificate = await earliestCheckpointAbove(neon(), chainNumber, chainNumberOf).catch(() => null);
if (certificate) { cpHash = certificate.hash; cpIndex = Number(certificate.index); }
const cpNumber = await chainNumberOf(cpHash);
if (chainNumber > cpNumber) throw httpError(409, `not final yet: executed at chain block ${chainNumber}, the latest certified checkpoint is chain block ${cpNumber}; try again in about ${chainNumber - cpNumber + 30} s`);
const including = strip(t.igneum.includingBlock);
const b = await body(including);
const raws = b.evmTransactions || [];
const idx = raws.findIndex(raw => bytesToHex(keccak_256(hexToBytes(strip(raw)))) === strip(tx));
if (idx < 0) throw httpError(500, 'the including block\'s body does not carry the transaction');
const leaves = [coinbaseTxHash(b.transactions[0], blake2b), ...raws.map(raw => keccak_256(hexToBytes(strip(raw))))];
const root = bytesToHex(merkleRoot(leaves, blake2b));
if (root !== strip(b.header.hashMerkleRoot)) throw httpError(500, `the including block's body root recomputes to ${root.slice(0, 12)}, the header says ${strip(b.header.hashMerkleRoot).slice(0, 12)}`);
const headers = await headerPath(including, chainNumber, cpNumber, cpHash);
return {
ok: true, now: new Date().toISOString(), chain_id: 'igneum-devnet-3', tx_hash: strip(tx), raw_tx_hex: strip(raws[idx]),
checkpoint: { hash: strip(cpHash), index: cpIndex, chain_block: cpNumber, certificate: certificate ? { ok: true, ...certificate } : undefined },
including_block: { header: headers[0], leaf_index: idx + 1, leaf_count: leaves.length, merkle_siblings: siblings(leaves, idx + 1) },
headers,
execution: rcpt ? { chain_block: chainNumber, chain_block_hash: strip(rcpt.blockHash), status: rcpt.status, gas_used: rcpt.gasUsed, from: rcpt.from, to: rcpt.to, contract_address: rcpt.contractAddress, logs: (rcpt.logs || []).length, effective_gas_price: rcpt.effectiveGasPrice, as_reported_by: 'the node (not proven here)' } : null,
tx_as_reported: { from: t.from, to: t.to, value: t.value, nonce: t.nonce, type: t.type, chain_id: t.chainId },
};
}
// ---- the server -----------------------------------------------------------------------------------------------------
function httpError(status, message) { const e = new Error(message); e.status = status; return e; }
const server = createServer(async (req, res) => {
const u = new URL(req.url || '/', 'http://x');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'content-type');
res.setHeader('Cache-Control', 'no-store');
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
if (req.method !== 'GET') { res.writeHead(405, { 'content-type': 'application/json' }); return res.end('{"ok":false,"error":"GET only"}'); }
const t0 = Date.now();
try {
let out;
if (u.pathname === '/health') out = { ok: true, exec: EXEC, wrpc: WRPC, status: await exec('igneum_getExecStatus', []).catch(e => String(e.message)) };
else if (u.pathname === '/checkpoint') out = await checkpoint();
else if (u.pathname === '/balance') out = await balance(u.searchParams);
else if (u.pathname === '/receipt') out = await receipt(u.searchParams);
else throw httpError(404, 'no such endpoint: /checkpoint, /balance, /receipt, /health');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(out));
log(req.method, u.pathname, 200, Date.now() - t0, 'ms');
} catch (e) {
const status = e.status || 500;
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: String(e.message || e) }));
log(req.method, u.pathname + u.search, status, Date.now() - t0, 'ms', String(e.message || e).slice(0, 200));
}
});
server.listen(PORT, '127.0.0.1', () => log(`light service on 127.0.0.1:${PORT}, exec ${EXEC}, wrpc ${WRPC}`));