igneum/site/lc/app.js
igneum-labs bd39a7c18a Reference apps, Review B F04: a recovery lock is never presented as a final lock
The light service maps the node's lockKind (igneum_getFinalityCheckpoints, 2.0.2 line) to lock_state beside every certificate
it answers with; core.js reads it as one step on /light and /receipt, refuses an unknown kind and a receipt that claims final
under a reported recovery lock; the pages print "recovery lock, not final" on the result, the receipt file carries lock_state,
the one-file verifier prints it; the shared terms block defines the recovery lock; /oracle says recovery locks are not accepted
by the Sepolia verifiers (two-thirds rule only, so every stored root passed the final rule). Node and browser negative cases
added. On a node before the field nothing changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:58:50 +00:00

158 lines
16 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, verifyPaymentReceipt, 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); };
// The release manifest names the network every page verifies against (/release.json, network.id); a certificate or a proof
// naming any other network string is refused before any check runs (the coordinator's rule, 8 October 2026).
let manifestNetwork = null;
async function expectedNetwork() {
if (manifestNetwork) return manifestNetwork;
try { const m = await (await fetch('/release.json', { cache: 'no-store' })).json(); manifestNetwork = m && m.network && m.network.id; } catch { manifestNetwork = null; }
return manifestNetwork;
}
async function guardNetwork(cp, extra) {
const want = await expectedNetwork();
if (!want) throw new Error('the release manifest is unreadable, so the network to verify against is unknown; refused');
if (cp.chain_id !== want || (cp.network && cp.network !== want)) throw new Error(`the certificate names ${cp.chain_id}, the manifest names ${want}; refused`);
if (extra && extra.chain_id && extra.chain_id !== want) throw new Error(`the proof names ${extra.chain_id}, the manifest names ${want}; refused`);
return want;
}
async function lockLine() {
// "no certificate yet": the lock condition from the chain, live
try {
const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'igneum_getProvingStatus', params: [] }) });
const st = (await r.json()).result;
return `no finality certificate yet on the Igneum 2.0 devnet; the first lock comes when the weight window fills at DAA ${Number(st.weightWindow).toLocaleString('en-GB')}, the chain reads DAA ${Number(st.tipDaa).toLocaleString('en-GB')} now`;
} catch { return 'no finality certificate yet on the Igneum 2.0 devnet; the first lock comes when the weight window fills at DAA 7,200'; }
}
const RPC = (q.get('rpc') || 'https://rpc.devnet.igneum.network');
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');
let cp;
try { cp = await getJson(`${API}/checkpoint`); } catch (e) { if (/no finality certificate yet/.test(String(e.message))) throw new Error(await lockLine()); throw e; }
await guardNetwork(cp);
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); }
await guardNetwork(cp, proof);
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${res.lock_state === 'recovery' ? ' recovery' : ''}"><div class="eyebrow ember">${res.lock_state === 'recovery' ? 'Proven balance under a RECOVERY LOCK, not final' : '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} (${res.lock_state === 'recovery' ? 'recovery lock' : 'locked'} ${esc(ago(Number(proof.headers[proof.headers.length - 1].timestamp)))}). Verified here in ${res.ms} ms, ${res.headers} headers checked.</p>
${res.lock_state === 'recovery' ? `<p class="pt"><b>Recovery lock.</b> The node reports this checkpoint locked under the recovery rule: more than half of the anchored weight after a full window with no lock, not the two-thirds final rule. Nothing under it is final. The state is the node's report; the certificate bytes carry none.</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 ? (res.lock_state === 'recovery' ? 'verified under a recovery lock, not final' : 'verified') : 'refused', res.verified ? (res.lock_state === 'recovery' ? 'warn' : 'ok') : 'bad');
return res;
}
// ---- /receipt -----------------------------------------------------------------------------------------------------
export async function runReceipt(tx) {
const out = $('[data-result]'); out.innerHTML = '';
setStatus('fetching the latest certified checkpoint…', 'busy');
let cp;
try { cp = await getJson(`${API}/checkpoint`); } catch (e) { if (/no finality certificate yet/.test(String(e.message))) throw new Error(await lockLine()); throw e; }
await guardNetwork(cp);
setStatus(`checkpoint ${cp.index} fetched; fetching the inclusion proof…`, 'busy');
const r = await getJson(`${API}/receipt?tx=${tx}&checkpoint=${cp.hash}&index=${cp.index}`);
await guardNetwork(r.checkpoint.certificate || cp, r);
// 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', kind: 'transaction inclusion receipt', authenticates: 'inclusion of the signed transaction in a finalised block; the execution outcome is reported by the node, not authenticated', 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 = verifyPaymentReceipt(receipt, deps);
if (res.payment) { receipt.kind = 'payment receipt'; receipt.authenticates = 'inclusion of the signed transaction in a finalised block and its successful execution outcome (status and logs) through the proven segment\'s receipts commitment'; }
let negPay = null;
if (res.payment) { const b2 = clone(receipt); const r0 = b2.segment.receipts[Number(b2.segment.receipt_position)]; r0.status = '0x0'; negPay = verifyPaymentReceipt(b2, deps); }
if (res.lock_state) receipt.lock_state = res.lock_state;
// negative case under a recovery lock: a copy claiming lock_state final must be refused
let negLock = null;
if (res.lock_state === 'recovery') { const b3 = clone(receipt); b3.lock_state = 'final'; negLock = verifyReceipt(b3, deps); }
window.__igneumReceipt = { receipt, neg, res, negLock };
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">${res.lock_state === 'recovery' ? (res.payment ? 'Payment receipt · included, executed, proven, under a RECOVERY LOCK (not final)' : 'Transaction inclusion receipt · included, under a RECOVERY LOCK (not final)') : res.payment ? 'Payment receipt · included, executed, proven and finalised' : 'Transaction inclusion receipt · included and finalised'} · Devnet 3, no value</div><div class="big">${esc(formatIgn(t.value || '0'))} <span>IGN</span></div>
${res.payment ? `<p class="pt"><b>Outcome authenticated:</b> ${esc(res.outcome.asset)}, ${esc(formatIgn(res.outcome.amount_wei))} IGN to ${esc(res.outcome.recipient || 'contract creation')}, executed with status success, through the receipts commitment of the proven segment ending at chain block ${esc(String(receipt.execution.chain_block))}.</p>` : `<p class="pt"><b>This is the inclusion receipt.</b> ${esc(res.payment_unavailable || receipt.payment_unavailable || 'the outcome is not authenticated')}${res.receipt_status === 'failed' ? ' The authenticated status is FAILED: no transfer took place.' : ''}</p>`}
<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">${res.lock_state === 'recovery' ? 'Recovery lock' : 'Finality'}</div><div>${res.lock_state === 'recovery' ? '<b>recovery lock, not final</b> (the node reports the checkpoint locked under the recovery rule; the certificate bytes carry no state); ' : ''}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">Executed</div><div>status ${receipt.execution.status === '0x1' ? 'success' : 'failed'}, gas ${parseInt(receipt.execution.gas_used, 16)}, ${receipt.execution.logs} log(s) <small>${res.payment ? '(authenticated: the receipt sits in the shard receipts trie whose root the proven segment statement commits to)' : '(as the node reports it: executed is reported, not authenticated by this receipt; a payment receipt would authenticate the outcome)'}</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) + (negLock ? negativeHtml('a copy of this receipt claiming lock_state final under a certificate the node reports as a recovery lock', negLock) : '') + (negPay ? negativeHtml('a copy of this payment receipt with the receipt status flipped to failed', { verified: negPay.verified && negPay.payment, reason: negPay.reason }) : '') + stepsHtml(res)
+ (res.verified ? `<p><button class="btn" type="button" data-download>Download the ${res.payment ? 'payment' : 'inclusion'} 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-${res.payment ? 'payment' : 'inclusion'}-receipt-${receipt.tx_hash.slice(0, 12)}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
});
setStatus(res.verified ? (res.lock_state === 'recovery' ? 'verified under a recovery lock, not final' : 'verified') : 'refused', res.verified ? (res.lock_state === 'recovery' ? 'warn' : '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(); }