igneum/site/lc/app.js

119 lines
10 KiB
JavaScript

// Igneum reference apps, the browser driver for /light and /receipt (8 October 2026). Fetches data from the read
// service behind rpc.devnet.igneum.network/light and verifies every byte in this tab with core.js. Libraries, pinned:
// BLAKE2b and keccak from @noble/hashes 2.4.0, BLS12-381 from @noble/curves 2.4.0 (pure JavaScript, served by jsdelivr
// as ES modules). Known-failed first: before the genuine result is shown, a copy of the same proof with one byte
// altered is run through the same verifier and must read as refused.
import { blake2b } from 'https://cdn.jsdelivr.net/npm/@noble/hashes@2.4.0/blake2.js/+esm';
import { keccak_256 } from 'https://cdn.jsdelivr.net/npm/@noble/hashes@2.4.0/sha3.js/+esm';
import { bls12_381 } from 'https://cdn.jsdelivr.net/npm/@noble/curves@2.4.0/bls12-381.js/+esm';
import { verifyBalance, verifyReceipt, formatIgn } from './core.js';
export const LIBRARIES = { '@noble/hashes': '2.4.0', '@noble/curves': '2.4.0' };
const deps = { blake2b, bls: bls12_381, keccak: keccak_256 };
const q = new URLSearchParams(location.search);
export const API = (q.get('api') || 'https://rpc.devnet.igneum.network/light').replace(/\/$/, '');
const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const short = h => { const s = String(h).replace(/^0x/, ''); return s.slice(0, 8) + '…' + s.slice(-6); };
const clone = x => JSON.parse(JSON.stringify(x));
const flipHex = (s, at) => { const h = s.replace(/^0x/, ''); const i = Math.min(at, h.length - 1); const d = (parseInt(h[i], 16) ^ 1).toString(16); return (s.startsWith('0x') ? '0x' : '') + h.slice(0, i) + d + h.slice(i + 1); };
async function getJson(url) {
const r = await fetch(url, { cache: 'no-store' });
let j = null; try { j = await r.json(); } catch { j = null; }
if (!j) throw new Error(`${url.replace(API, '')} answered ${r.status} with no JSON`);
if (!j.ok) throw new Error(j.error || `${url.replace(API, '')} answered ${r.status}`);
return j;
}
function setStatus(text, kind = '') { const el = $('[data-status]'); if (!el) return; el.textContent = text; el.dataset.kind = kind; }
function stepsHtml(result) {
return '<ol class="steps">' + result.steps.map(s => `<li class="${s.ok ? 'ok' : 'bad'}"><b>${s.ok ? 'checked' : 'refused'}</b><span>${esc(s.name)}</span><small>${esc(s.detail)}</small></li>`).join('') + '</ol>';
}
function negativeHtml(name, result) {
return `<div class="neg ${result.verified ? 'wrong' : 'right'}"><b>${result.verified ? 'NOT REFUSED (this is a bug)' : 'refused'}</b> <span>${esc(name)}</span><small>${esc(result.reason || 'verified')}</small></div>`;
}
const ago = ms => { const s = Math.max(0, Math.round((Date.now() - Number(ms)) / 1000)); return s < 90 ? `${s} s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : `${(s / 3600).toFixed(1)} h ago`; };
// ---- /light -------------------------------------------------------------------------------------------------------
export async function runLight(address) {
const out = $('[data-result]'); out.innerHTML = '';
setStatus('fetching the latest certified checkpoint…', 'busy');
const cp = await getJson(`${API}/checkpoint`);
setStatus(`checkpoint ${cp.index} fetched; fetching the proof for ${short(address)}…`, 'busy');
const proof = await getJson(`${API}/balance?address=${address}&checkpoint=${cp.hash}&index=${cp.index}`);
// the service may answer with an earlier certificate (the first checkpoint above the carrier: the smallest proof); verified like any other
if (proof.checkpoint_certificate) { Object.assign(cp, proof.checkpoint_certificate); }
setStatus('verifying in this tab…', 'busy');
await new Promise(r => setTimeout(r, 20));
// known-failed first: the same proof with one byte of the last account-proof node altered
const bad = clone(proof); const last = bad.account.accountProof.length - 1;
bad.account.accountProof[last] = flipHex(bad.account.accountProof[last], 30);
const neg = verifyBalance(cp, bad, deps);
const res = verifyBalance(cp, proof, deps);
window.__igneumLight = { cp, proof, neg, res };
const top = res.verified
? `<div class="headline"><div class="eyebrow ember">Proven balance · Devnet 3, no value</div><div class="big">${esc(formatIgn(res.balance_wei))} <span>IGN</span></div>
<p class="pt">${esc(res.balance_wei)} wei at chain block ${res.block}, under checkpoint ${res.checkpoint} (locked ${esc(ago(Number(proof.headers[proof.headers.length - 1].timestamp)))}). Verified here in ${res.ms} ms, ${res.headers} headers checked.</p></div>`
: `<div class="headline bad"><div class="eyebrow">Not verified · Devnet 3, no value</div><p class="pt">${esc(res.reason)}</p></div>`;
out.innerHTML = top + negativeHtml('a copy of this proof with one byte of a trie node altered', neg) + stepsHtml(res)
+ `<p class="note">Data served by ${esc(API)} (a read service in front of a Devnet 3 node). Nothing it answered was taken on trust: the certificate, every header hash and parent link, the coinbase inclusion, the segment record's signature and the account proof were recomputed in this tab. What is trusted: that the aggregator's statement is the true execution result (every node checks it natively before paying the record; the SP1 proof behind it is verified by nodes, not in this tab yet), and the voter list with weights, which came from the node (spec 10.1).</p>`;
setStatus(res.verified ? 'verified' : 'refused', res.verified ? 'ok' : 'bad');
return res;
}
// ---- /receipt -----------------------------------------------------------------------------------------------------
export async function runReceipt(tx) {
const out = $('[data-result]'); out.innerHTML = '';
setStatus('fetching the latest certified checkpoint…', 'busy');
const cp = await getJson(`${API}/checkpoint`);
setStatus(`checkpoint ${cp.index} fetched; fetching the inclusion proof…`, 'busy');
const r = await getJson(`${API}/receipt?tx=${tx}&checkpoint=${cp.hash}&index=${cp.index}`);
// the service may answer with an earlier certificate (the first checkpoint above the block: the smallest proof); it is verified like any other
const receipt = { format: 'igneum-receipt-v1', issued: new Date().toISOString(), ...r, checkpoint: { ...r.checkpoint, certificate: r.checkpoint.certificate || cp } };
delete receipt.ok; delete receipt.now;
setStatus('verifying in this tab…', 'busy');
await new Promise(resolve => setTimeout(resolve, 20));
const bad = clone(receipt); bad.raw_tx_hex = flipHex(bad.raw_tx_hex, 40);
const neg = verifyReceipt(bad, deps);
const res = verifyReceipt(receipt, deps);
window.__igneumReceipt = { receipt, neg, res };
const t = res.tx || {};
const when = new Date(Number(res.block_time || 0)).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const top = res.verified
? `<div class="headline"><div class="eyebrow ember">Final · Devnet 3, no value</div><div class="big">${esc(formatIgn(t.value || '0'))} <span>IGN</span></div>
<div class="kv"><div class="k">To</div><div class="mono">${esc(t.to || 'contract creation')}</div><div class="k">From</div><div class="mono">${esc(receipt.tx_as_reported.from)} <small>(as the node reports it; the signature is the chain's check)</small></div>
<div class="k">Transaction</div><div class="mono">0x${esc(receipt.tx_hash)}</div><div class="k">Block</div><div class="mono">${esc(res.block)} <small>at ${esc(when)}, DAA ${esc(res.block_daa)}</small></div>
<div class="k">Finality</div><div>checkpoint ${res.checkpoint}, ${esc(res.certificate)}; ${res.headers} headers from the block to the checkpoint, verified here in ${res.ms} ms</div>
${receipt.execution ? `<div class="k">Execution</div><div>status ${receipt.execution.status === '0x1' ? 'success' : 'failed'}, gas ${parseInt(receipt.execution.gas_used, 16)}, ${receipt.execution.logs} log(s) <small>(as the node reports it, not proven here)</small></div>` : ''}</div></div>`
: `<div class="headline bad"><div class="eyebrow">Not verified · Devnet 3, no value</div><p class="pt">${esc(res.reason)}</p></div>`;
out.innerHTML = top + negativeHtml('a copy of this receipt with one nibble of the raw transaction altered', neg) + stepsHtml(res)
+ (res.verified ? `<p><button class="btn" type="button" data-download>Download the receipt (JSON, ${Math.round(JSON.stringify(receipt).length / 1024)} KB)</button></p>
<p class="note">The file carries the raw transaction, the including block's header and merkle path, every header up to the certified checkpoint, the certificate and the voter table. Anyone re-verifies it offline with the one-file verifier: <code>node verify-receipt.js receipt.json</code> (<a href="/lc/verify-receipt.js" download>verify-receipt.js</a>, plain JavaScript, no npm, no network). A tampered file fails there the same way the copy above failed here.</p>` : '');
const dl = $('[data-download]');
if (dl) dl.addEventListener('click', () => {
const blob = new Blob([JSON.stringify(receipt, null, 1)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `igneum-receipt-${receipt.tx_hash.slice(0, 12)}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
});
setStatus(res.verified ? 'verified' : 'refused', res.verified ? 'ok' : 'bad');
return res;
}
// ---- wiring ---------------------------------------------------------------------------------------------------------
function wire() {
const form = $('[data-form]'); if (!form) return;
const input = form.querySelector('input'); const kind = form.dataset.form;
if (q.get(kind === 'light' ? 'address' : 'tx')) input.value = q.get(kind === 'light' ? 'address' : 'tx');
form.addEventListener('submit', async e => {
e.preventDefault();
const v = input.value.trim();
const ok = kind === 'light' ? /^0x[0-9a-fA-F]{40}$/.test(v) : /^0x[0-9a-fA-F]{64}$/.test(v);
if (!ok) { setStatus(kind === 'light' ? 'an address is 0x and 40 hex digits' : 'a transaction hash is 0x and 64 hex digits', 'bad'); return; }
form.querySelector('button').disabled = true;
try { if (kind === 'light') await runLight(v); else await runReceipt(v); }
catch (err) { setStatus(String(err.message || err), 'bad'); $('[data-result]').innerHTML = `<div class="headline bad"><div class="eyebrow">Could not fetch · Devnet 3, no value</div><p class="pt">${esc(String(err.message || err))}</p></div>`; }
finally { form.querySelector('button').disabled = false; }
});
if (input.value) form.requestSubmit();
}
if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); else wire(); }