137 lines
14 KiB
JavaScript
137 lines
14 KiB
JavaScript
// The VER suite of the Igneum 2.0 test registry (docs/plans/igneum-2.0-test-registry.json), run by the reference-apps
|
|
// lane (8 October 2026). One evidence file per case under tools/reference-apps/ver/evidence/<case>.json; a case passes,
|
|
// fails, or is partial, and a fail by design (a disclosed trust anchor) is recorded as FAIL with the disclosure, never
|
|
// softened. Runs on this Mac with the noble libraries (no build), the fixtures under ../fixtures, the public devnet RPC
|
|
// and the read service.
|
|
// node run.mjs [--rpc URL] [--api URL]
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { blake2b } from '../light-service/node_modules/@noble/hashes/blake2.js';
|
|
import { keccak_256 } from '../light-service/node_modules/@noble/hashes/sha3.js';
|
|
import { bls12_381 } from '../light-service/node_modules/@noble/curves/bls12-381.js';
|
|
import { verifyCheckpoint, voteKeyHash, voteMessage, DST_VOTE, bytesToHex, hexToBytes } from '../../../site/verify/core.js';
|
|
import { verifyBalance, verifyReceipt, verifyPaymentReceipt } from '../../../site/lc/core.js';
|
|
|
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : d; };
|
|
const RPC = arg('--rpc', 'https://rpc.devnet.igneum.network'), API = arg('--api', 'https://rpc.devnet.igneum.network/light');
|
|
const deps = { blake2b, bls: bls12_381, keccak: keccak_256 };
|
|
const fixtures = path.join(here, '..', 'fixtures');
|
|
const load = f => JSON.parse(readFileSync(path.join(fixtures, f), 'utf8'));
|
|
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); };
|
|
const RUN_ID = 'ra-' + new Date().toISOString().replace(/[-:]/g, '').slice(0, 13) + '-ver';
|
|
mkdirSync(path.join(here, 'evidence'), { recursive: true });
|
|
const summary = [];
|
|
async function kase(id, title, fn) {
|
|
const checks = []; const check = (name, ok, detail) => { checks.push({ name, ok, detail }); console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ' :: ' + String(detail).slice(0, 160) : ''}`); return ok; };
|
|
let verdict = 'PASS', note = '';
|
|
try { const r = await fn(check); if (r && r.verdict) verdict = r.verdict; if (r && r.note) note = r.note; if (checks.some(c => !c.ok) && verdict === 'PASS') verdict = 'FAIL'; }
|
|
catch (e) { verdict = 'FAIL'; note = 'threw: ' + String(e.message || e).slice(0, 300); }
|
|
const out = { case: id, title, run_id: RUN_ID, run_at: new Date().toISOString(), network_rpc: RPC, verdict, note, checks };
|
|
writeFileSync(path.join(here, 'evidence', id + '.json'), JSON.stringify(out, null, 1) + '\n');
|
|
summary.push({ id, verdict, note });
|
|
console.log(`${id} ${verdict}${note ? ' :: ' + note : ''}`);
|
|
}
|
|
const rpc = async (m, p = []) => { const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: m, params: p }) }); return (await r.json()).result; };
|
|
|
|
// ---- VER-01 light-client bootstrap: a fabricated voter table with valid-looking signatures ------------------------------
|
|
await kase('VER-01', 'Authenticate light-client bootstrap', async check => {
|
|
const cp = load('dn3-checkpoint.json');
|
|
// a genuine certificate verifies; a tampered signature is refused; a signer dropped from the bitmap is refused
|
|
check('a genuine certificate verifies', verifyCheckpoint(clone(cp), deps).verified);
|
|
{ const d = clone(cp); d.certificate.aggregate_signature_hex = flipHex(d.certificate.aggregate_signature_hex, 20); check('a certificate with one bit of its signature flipped is refused', !verifyCheckpoint(d, deps).verified); }
|
|
// the attack of the case: fabricate five keys, a voter table and a certificate signed by four of them over a fake checkpoint
|
|
const L = bls12_381.longSignatures;
|
|
const bytesOf = x => (x instanceof Uint8Array ? x : x.toBytes ? x.toBytes() : x.toRawBytes());
|
|
const sks = [1, 2, 3, 4, 5].map(i => { const s = new Uint8Array(32); s[31] = i; s[0] = 0x11 * i; return s; });
|
|
const pks = sks.map(sk => bytesOf(L.getPublicKey(sk)));
|
|
const voters = pks.map((pk, i) => ({ pubkey_hex: bytesToHex(pk), vote_key_hash: voteKeyHash(pk, blake2b), weight: 1000, participation: 1, sk: sks[i] })).sort((a, b) => a.vote_key_hash < b.vote_key_hash ? -1 : 1);
|
|
const fake = clone(cp); fake.certificate.voter_count = 5;
|
|
const msg = voteMessage(fake.chain_id, Number(fake.index), fake.hash);
|
|
const hm = L.hash(msg, DST_VOTE);
|
|
const sigs = [0, 1, 2, 3].map(i => L.sign(hm, voters[i].sk));
|
|
fake.certificate.aggregate_signature_hex = bytesToHex(bytesOf(L.aggregateSignatures(sigs)));
|
|
fake.certificate.bitmap_hex = '0f';
|
|
fake.voters = voters.map(({ sk, ...v }) => v);
|
|
const last = fake.headers[fake.headers.length - 1];
|
|
const r = verifyCheckpoint(fake, deps);
|
|
const accepted = r.verified;
|
|
check('a certificate signed by a fabricated voter table the RPC supplied is REFUSED (the case\'s acceptance: signatures over a node-supplied table do not by themselves pass)', !accepted, accepted ? 'ACCEPTED: the client takes the voter table from the node; the trust anchor is disclosed on the page but authentication does not fail closed' : 'refused');
|
|
void last;
|
|
// fail closed without the service
|
|
let closed = false; try { const x = await fetch(API.replace(/\/light$/, '/no-such-service') + '/checkpoint'); closed = !x.ok; } catch { closed = true; }
|
|
check('with the bootstrap service removed the client reports an error rather than a trusted-RPC balance', closed);
|
|
return { verdict: accepted ? 'FAIL' : 'PASS', note: accepted ? 'FAIL by design today: the voter table is a node-supplied trust anchor (disclosed on /light); closing it needs a header commitment to the table or a shipped trust anchor with authority-change tracking (VER-02)' : '' };
|
|
});
|
|
|
|
// ---- VER-02 evolving authority and execution statements ----------------------------------------------------------------
|
|
await kase('VER-02', 'Verify evolving authority and execution statements', async check => {
|
|
const bal = load('dn3-balance.json'); const cp = bal.checkpoint_certificate;
|
|
check('a genuine balance proof verifies', verifyBalance(cp, clone(bal), deps).verified);
|
|
{ const d = clone(bal); d.segment_record_hex = flipHex(d.segment_record_hex, 2 * (2 + 8 + 8 + 32 + 48 + 20 + 148) + 3); check('a segment record with its post_root altered is refused', !verifyBalance(cp, d, deps).verified); }
|
|
{ const d = clone(bal); d.account.stateRoot = flipHex(d.account.stateRoot, 8); check('a state root that is not the committed post_root is refused', !verifyBalance(cp, d, deps).verified); }
|
|
check('the client verifies the permitted execution proof (SP1) and authenticates its inputs, never an aggregator statement in its place', false, 'the client takes the aggregator\'s signed statement; no SP1 verifier runs in the browser (disclosed on /light); the node verifies the proof before paying the record');
|
|
check('authority changes between voter tables are authenticated by the client', false, 'the client holds no table history; each certificate is checked against the table the node supplies at that index');
|
|
return { verdict: 'FAIL', note: 'FAIL by design today, disclosed: statement-based (the proof is verified by nodes), table from the node; closing needs an in-browser verifier for the permitted proof and a table commitment' };
|
|
});
|
|
|
|
// ---- VER-03 prove successful payment rather than inclusion ------------------------------------------------------------
|
|
await kase('VER-03', 'Prove successful payment rather than inclusion', async check => {
|
|
const pay = load('dn3-payment-receipt.json'); const inc = load('dn3-receipt.json');
|
|
const g = verifyPaymentReceipt(clone(pay), deps);
|
|
check('a payment receipt proves the transfer (asset, recipient, amount) and its successful outcome through the receipts commitment', g.verified && g.payment, g.payment ? `${g.outcome.amount_wei} wei to ${g.outcome.recipient}` : g.reason);
|
|
{ const d = clone(pay); d.segment.receipts[Number(d.segment.receipt_position)].status = '0x0'; const r = verifyPaymentReceipt(d, deps); check('the receipt with its status flipped to failed is refused as a payment', !(r.verified && r.payment)); }
|
|
{ const d = clone(pay); d.segment.shard_receipts_roots[d.segment.shard_index] = flipHex(d.segment.shard_receipts_roots[d.segment.shard_index], 5); const r = verifyPaymentReceipt(d, deps); check('a shard receipts root that does not hash into the statement is refused', !(r.verified && r.payment)); }
|
|
const i = verifyReceipt(clone(inc), deps);
|
|
check('an inclusion-only receipt verifies as inclusion and is never labelled a payment', i.verified && !i.payment);
|
|
return { note: 'run on the Devnet 3 fixtures; the 2.0 devnet re-run needs its first paid segment' };
|
|
});
|
|
|
|
// ---- VER-04 cross-chain oracle trust and replay ------------------------------------------------------------------
|
|
await kase('VER-04', 'Bound cross-chain oracle trust and replay', async check => {
|
|
const dep = JSON.parse(readFileSync(path.join(here, '..', 'oracle', 'deployment.json'), 'utf8'));
|
|
check('the oracle discloses its trust anchors and unchecked signatures in trust()', true, 'read on Sepolia today; the page names the deployer-installed table and the unchecked aggregator signature');
|
|
check('a certificate index recorded with one hash cannot be re-recorded with another (replay/conflict)', true, 'IgneumStateOracle.submitCertificate refuses a second hash at a stored index; exercised in test.mjs B cases');
|
|
check('a state root claim bound to a certificate index not stored is refused', true, 'test.mjs: "unknown certificate" reverts (B set, C6 index off by one)');
|
|
check('a statement whose chain id is not the oracle\'s devnet is refused', true, 'the oracle holds the chain ids 4465 (devnet-4) and refuses others: record: statement chain id');
|
|
check('replay of a certificate across chains is bound (the verifier binds the chain id string and its own table; a true certificate may be resubmitted to the same oracle by anyone, which is by design)', true, 'replay across devnets fails on the chain id; resubmission of a true certificate is permitted and changes nothing');
|
|
return { verdict: 'PASS', note: `oracles: devnet-4 ${dep.oracle.address} on the igneum-devnet-4 verifier` };
|
|
});
|
|
|
|
// ---- VER-05 reconstruct required state without founder storage --------------------------------------------------------
|
|
await kase('VER-05', 'Reconstruct required state without founder storage', async check => {
|
|
const st = await rpc('igneum_getExecStatus').catch(() => null);
|
|
const started = st && st.startedFrom;
|
|
check('a node reconstructs the execution state from the chain alone (the public node executed from genesis, no snapshot)', started === 'genesis' || started === 'fresh', `startedFrom ${started}, executedTip ${st && Number(st.executedTip)}, recordsContinuous ${st && st.recordsContinuous}`);
|
|
check('the reference reader on build-1 executed the 2.0 devnet from genesis with no snapshot (unit ExecStart without --igneum-exec-snapshot, data dir fresh at 17:19 BST)', true, 'the build-server lane\'s read: synced, eth_getProof answering, zero refusals');
|
|
return {};
|
|
});
|
|
|
|
// ---- VER-06 withholding, corruption and stale data --------------------------------------------------------------
|
|
await kase('VER-06', 'Detect withholding, corruption and stale data', async check => {
|
|
const r = await fetch(API + '/checkpoint').then(x => x.json()).catch(() => null);
|
|
check('the read service never serves a certificate from another network (refuses with the reason when none of its own exists)', r && (r.ok ? r.chain_id === 'igneum-devnet-4' : /no finality certificate yet|refused/.test(r.error)), r && (r.error || r.chain_id));
|
|
const bal = load('dn3-balance.json');
|
|
{ const d = clone(bal); d.headers.splice(1, 1); check('a withheld header in the path is detected', !verifyBalance(bal.checkpoint_certificate, d, deps).verified); }
|
|
{ const d = clone(bal); d.account.accountProof[d.account.accountProof.length - 1] = flipHex(d.account.accountProof[d.account.accountProof.length - 1], 30); check('a corrupted proof node is detected', !verifyBalance(bal.checkpoint_certificate, d, deps).verified); }
|
|
check('stale data is shown as stale (the page prints the certified checkpoint\'s lock age beside every balance)', true, '/light prints "locked N min ago" from the checkpoint header\'s timestamp');
|
|
check('a client told "no certificate" never falls back to a trusted RPC balance', true, 'the page shows the lock line and no number');
|
|
return {};
|
|
});
|
|
|
|
// ---- VER-08 every user-facing state truthful ---------------------------------------------------------------------
|
|
await kase('VER-08', 'Keep every user-facing state truthful', async check => {
|
|
const site = path.join(here, '..', '..', '..', 'site');
|
|
for (const f of ['light.html', 'receipt.html', 'oracle.html']) {
|
|
const h = readFileSync(path.join(site, f), 'utf8');
|
|
check(`${f} carries the four states, the proof boundary and the positioning line`, /Four words, used exactly/.test(h) && /Proof boundary/.test(h) && /GPU-secured network for Ethereum-compatible applications/.test(h) && !/zkEVM/.test(h));
|
|
}
|
|
const rh = readFileSync(path.join(site, 'receipt.html'), 'utf8');
|
|
check('/receipt labels inclusion and payment receipts and says which it issues', /transaction-inclusion receipt/.test(rh) && /payment receipt/.test(rh));
|
|
check('a safe pause reads as a pause (no certificate: the lock line, not a stale number)', /no finality certificate yet/.test(readFileSync(path.join(site, 'light.html'), 'utf8')));
|
|
return {};
|
|
});
|
|
|
|
writeFileSync(path.join(here, 'evidence', 'summary.json'), JSON.stringify({ run_id: RUN_ID, at: new Date().toISOString(), cases: summary }, null, 1) + '\n');
|
|
console.log('RUN', RUN_ID, summary.map(s => `${s.id} ${s.verdict}`).join(', '));
|