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>
541 lines
38 KiB
JavaScript
541 lines
38 KiB
JavaScript
// Igneum reference apps, the checks (no I/O, no DOM). 8 October 2026. Used by the light wallet (/light), the receipt
|
|
// page (/receipt) and the one-file offline receipt verifier. Everything here recomputes; nothing is taken on trust
|
|
// from the node that served the data. `deps` is { blake2b, bls, keccak } (@noble/hashes blake2b and keccak_256,
|
|
// @noble/curves bls12_381), injected so the browser, Node and the bundled verifier share one file.
|
|
//
|
|
// What a balance proof chains together (every link recomputed here):
|
|
// certificate ──signs──> checkpoint header ──parents──> ... ──parents──> carrier header
|
|
// carrier header.hash_merkle_root ──merkle path──> coinbase transaction hash ──payload──> IGNS segment record
|
|
// segment record ──BLS signature by a voter──> public values ──post_root──> MPT account proof ──> balance
|
|
// What a receipt proof chains together: certificate ──> checkpoint ──parents──> including block ──merkle──> raw tx.
|
|
//
|
|
// Formats, from the node's own code (vendor/igneum-node-light at 5b673577 plus e6081dd6): consensus/core/src/hashing
|
|
// (header, transaction), crypto/merkle (the body root), consensus/core/src/proving.rs (IGNS, SegmentRecord,
|
|
// BlockStatement), consensus/core/src/finality.rs (the coinbase extra data and IGNF), igneum/exec/src/state.rs
|
|
// (the keccak-keyed MPT in reth's layout, eth_getProof).
|
|
import { headerHash, voteKeyHash, verifyCheckpoint, hexToBytes, bytesToHex } from '../verify/core.js';
|
|
|
|
export { headerHash, voteKeyHash, verifyCheckpoint, hexToBytes, bytesToHex };
|
|
|
|
export const DST_SEGMENT = 'IGNEUM_SEGMENT_RECORD_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_';
|
|
export const SEGMENT_RECORD_LEN = 2 + 8 + 8 + 32 + 48 + 20 + 340 + 32 + 96;
|
|
export const ZERO32 = new Uint8Array(32);
|
|
const te = new TextEncoder();
|
|
const strip = s => String(s).replace(/^0x/i, '');
|
|
const u64le = v => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v), true); return b; };
|
|
const u16le = v => { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, Number(v), true); return b; };
|
|
const u32le = v => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(v), true); return b; };
|
|
function concat(parts) { const n = parts.reduce((a, p) => a + p.length, 0); const m = new Uint8Array(n); let o = 0; for (const p of parts) { m.set(p, o); o += p.length; } return m; }
|
|
const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
const bigOf = b => { let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; };
|
|
export const keyed = (blake2b, key) => data => blake2b(data, { dkLen: 32, key: te.encode(key) });
|
|
|
|
// ---- the body merkle root (crypto/merkle/src/lib.rs): leaves padded to a power of two, a missing right child is the
|
|
// zero hash, inner node = BLAKE2b-256 keyed "MerkleBranchHash" over left || right; one leaf is its own root --------
|
|
export function merkleRoot(leaves, blake2b) {
|
|
const H = keyed(blake2b, 'MerkleBranchHash');
|
|
if (leaves.length === 0) return ZERO32;
|
|
let level = leaves.slice();
|
|
while (level.length > 1) {
|
|
const next = [];
|
|
for (let i = 0; i < level.length; i += 2) next.push(H(concat([level[i], level[i + 1] || ZERO32])));
|
|
level = next;
|
|
}
|
|
return level[0];
|
|
}
|
|
export function merklePath(leaves, index) {
|
|
const path = []; let level = leaves.slice(); let i = index;
|
|
while (level.length > 1) {
|
|
const sib = i % 2 === 0 ? (level[i + 1] || ZERO32) : level[i - 1];
|
|
path.push(sib);
|
|
const next = []; for (let k = 0; k < level.length; k += 2) next.push(new Uint8Array(0));
|
|
// only the shape matters here; the hashes are recomputed by the verifier
|
|
level = next; i = i >> 1;
|
|
}
|
|
return path;
|
|
}
|
|
export function merkleRootFromPath(leaf, index, siblings, blake2b) {
|
|
const H = keyed(blake2b, 'MerkleBranchHash');
|
|
let h = leaf, i = index;
|
|
for (const s of siblings) { h = i % 2 === 0 ? H(concat([h, s])) : H(concat([s, h])); i = i >> 1; }
|
|
return h;
|
|
}
|
|
|
|
// ---- the coinbase transaction hash (consensus/core/src/hashing/tx.rs, FULL flags) from the RPC's JSON ------------
|
|
export function coinbaseTxHash(tx, blake2b, amountWireLen = 8) {
|
|
const version = Number(tx.version || 0);
|
|
const parts = [u16le(version), u64le((tx.inputs || []).length)];
|
|
for (const inp of tx.inputs || []) {
|
|
parts.push(hexToBytes(strip(inp.previousOutpoint.transactionId)), u32le(inp.previousOutpoint.index));
|
|
const sig = hexToBytes(strip(inp.signatureScript || ''));
|
|
parts.push(u64le(sig.length), sig);
|
|
if (version < 1) parts.push(new Uint8Array([Number(inp.sigOpCount || 0)]));
|
|
parts.push(u64le(inp.sequence));
|
|
if (version >= 1) parts.push(u16le(inp.computeBudget || 0));
|
|
}
|
|
parts.push(u64le((tx.outputs || []).length));
|
|
for (const out of tx.outputs || []) {
|
|
const v = new Uint8Array(amountWireLen); let x = BigInt(out.value);
|
|
for (let i = 0; i < amountWireLen; i++) { v[i] = Number(x & 0xffn); x >>= 8n; }
|
|
const spk = hexToBytes(strip(out.scriptPublicKey)); // version u16 LE || script, as the RPC serialises it
|
|
parts.push(v, spk.subarray(0, 2), u64le(spk.length - 2), spk.subarray(2));
|
|
if (version >= 1) parts.push(new Uint8Array([out.covenant ? 1 : 0]));
|
|
}
|
|
const payload = hexToBytes(strip(tx.payload || ''));
|
|
parts.push(u64le(tx.lockTime || 0), hexToBytes(strip(tx.subnetworkId)), u64le(tx.gas || 0), u64le(payload.length), payload);
|
|
const mass = BigInt(tx.mass || 0);
|
|
if (version < 1) { if (mass > 0n) parts.push(u64le(mass)); } else parts.push(u64le(mass));
|
|
return keyed(blake2b, 'TransactionHash')(concat(parts));
|
|
}
|
|
|
|
// ---- the coinbase payload: blue_score u64 || subsidy || script version u16 || script len u8 || script || extra; the
|
|
// extra data ends with nested sections `items || len_le32 || TAG`: IGNF last, IGNP before it, IGNS before that ----
|
|
export function coinbaseExtraData(payload) {
|
|
if (payload.length < 19) return new Uint8Array(0);
|
|
const scriptLen = payload[18];
|
|
return payload.subarray(19 + scriptLen);
|
|
}
|
|
function takeSection(extra, tag) {
|
|
const n = extra.length;
|
|
if (n < 8) return { items: null, before: extra };
|
|
const t = String.fromCharCode(...extra.subarray(n - 4));
|
|
if (t !== tag) return { items: null, before: extra };
|
|
const len = new DataView(extra.buffer, extra.byteOffset + n - 8, 4).getUint32(0, true);
|
|
if (len + 8 > n) return { items: null, before: extra };
|
|
return { items: extra.subarray(n - 8 - len, n - 8), before: extra.subarray(0, n - 8 - len) };
|
|
}
|
|
export function segmentRecordsOf(payload) {
|
|
const extra = coinbaseExtraData(payload);
|
|
const f = takeSection(extra, 'IGNF');
|
|
const p = takeSection(f.before, 'IGNP');
|
|
const s = takeSection(p.before, 'IGNS');
|
|
const out = [];
|
|
if (!s.items) return out;
|
|
for (let o = 0; o + SEGMENT_RECORD_LEN <= s.items.length; o += SEGMENT_RECORD_LEN) out.push(parseSegmentRecord(s.items.subarray(o, o + SEGMENT_RECORD_LEN)));
|
|
return out;
|
|
}
|
|
export function parseSegmentRecord(b) {
|
|
if (b.length !== SEGMENT_RECORD_LEN) throw new Error(`segment record is ${b.length} bytes, not ${SEGMENT_RECORD_LEN}`);
|
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
let o = 0;
|
|
const version = dv.getUint16(o, true); o += 2;
|
|
const first = dv.getBigUint64(o, true); o += 8;
|
|
const last = dv.getBigUint64(o, true); o += 8;
|
|
const block = b.subarray(o, o + 32); o += 32;
|
|
const pubkey = b.subarray(o, o + 48); o += 48;
|
|
const payout = b.subarray(o, o + 20); o += 20;
|
|
const publicValues = b.subarray(o, o + 340); o += 340;
|
|
const proofHash = b.subarray(o, o + 32); o += 32;
|
|
const signature = b.subarray(o, o + 96);
|
|
return { bytes: b, version, first, last, block, pubkey, payout, publicValues, proofHash, signature, statement: parseBlockStatement(publicValues) };
|
|
}
|
|
export function parseBlockStatement(b) {
|
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
const h = o => bytesToHex(b.subarray(o, o + 32));
|
|
return {
|
|
chain_id: dv.getBigUint64(0), number: dv.getBigUint64(8), block_hash: h(16), parent_hash: h(48), shard_count: dv.getUint32(80),
|
|
tx_commitment: h(84), pre_root: h(116), post_root: h(148), receipts: h(180), gas_used: dv.getBigUint64(212), pgas_used: dv.getBigUint64(220),
|
|
executed: dv.getUint32(228), skipped: dv.getUint32(232), provers: h(236), shard_vk: h(268), agg_vk: h(300), chain_len: dv.getBigUint64(332),
|
|
};
|
|
}
|
|
export function segmentRecordMessage(chainId, r) {
|
|
return concat([te.encode('igneum-segment-record-v1/' + chainId), new Uint8Array([0]), u64le(r.first), u64le(r.last), r.block, r.payout, r.publicValues, r.proofHash]);
|
|
}
|
|
export function verifySegmentRecordSignature(chainId, r, bls) {
|
|
const L = bls.longSignatures;
|
|
return L.verify(r.signature, L.hash(segmentRecordMessage(chainId, r), DST_SEGMENT), r.pubkey);
|
|
}
|
|
|
|
// ---- RLP and the Merkle Patricia trie (keccak-keyed, Ethereum's layout) ----------------------------------------
|
|
export function rlpDecode(b) {
|
|
const [item, rest] = rlpItem(b, 0);
|
|
if (rest !== b.length) throw new Error('rlp: trailing bytes');
|
|
return item;
|
|
}
|
|
function rlpItem(b, o) {
|
|
if (o >= b.length) throw new Error('rlp: short');
|
|
const x = b[o];
|
|
if (x < 0x80) return [b.subarray(o, o + 1), o + 1];
|
|
if (x < 0xb8) { const n = x - 0x80; return [b.subarray(o + 1, o + 1 + n), o + 1 + n]; }
|
|
if (x < 0xc0) { const ll = x - 0xb7; const n = Number(bigOf(b.subarray(o + 1, o + 1 + ll))); return [b.subarray(o + 1 + ll, o + 1 + ll + n), o + 1 + ll + n]; }
|
|
let n, start;
|
|
if (x < 0xf8) { n = x - 0xc0; start = o + 1; } else { const ll = x - 0xf7; n = Number(bigOf(b.subarray(o + 1, o + 1 + ll))); start = o + 1 + ll; }
|
|
const end = start + n; if (end > b.length) throw new Error('rlp: list overruns');
|
|
const items = []; let p = start;
|
|
while (p < end) { const [it, q] = rlpItem(b, p); items.push(it); p = q; }
|
|
return [items, end];
|
|
}
|
|
export function rlpEncode(item) {
|
|
if (item instanceof Uint8Array) {
|
|
if (item.length === 1 && item[0] < 0x80) return item;
|
|
return concat([rlpLen(item.length, 0x80), item]);
|
|
}
|
|
const body = concat(item.map(rlpEncode));
|
|
return concat([rlpLen(body.length, 0xc0), body]);
|
|
}
|
|
function rlpLen(n, base) {
|
|
if (n < 56) return new Uint8Array([base + n]);
|
|
const bytes = []; let x = n; while (x > 0) { bytes.unshift(x & 0xff); x = Math.floor(x / 256); }
|
|
return new Uint8Array([base + 55 + bytes.length, ...bytes]);
|
|
}
|
|
export const trimBig = v => { v = BigInt(v); if (v === 0n) return new Uint8Array(0); let h = v.toString(16); if (h.length % 2) h = '0' + h; return hexToBytes(h); };
|
|
const nibbles = b => { const out = []; for (const x of b) { out.push(x >> 4, x & 15); } return out; };
|
|
function hpDecode(b) { // hex-prefix: returns [nibbles, isLeaf]
|
|
const n = nibbles(b); const flag = n[0];
|
|
const body = flag & 1 ? n.slice(1) : n.slice(2);
|
|
return [body, (flag & 2) !== 0];
|
|
}
|
|
// Walks the proof from the root to the key; returns the value bytes (Uint8Array) or null for a proven absence.
|
|
// Throws on any node that does not hash to its reference, any malformed node, or a path that does not reach a verdict.
|
|
export function mptVerify(root, key, proofNodes, keccak) {
|
|
const path = nibbles(key); let want = root; let pos = 0;
|
|
const byHash = new Map(); for (const n of proofNodes) byHash.set(bytesToHex(keccak(n)), n);
|
|
if (proofNodes.length === 0) { if (eq(root, keccak(rlpEncode(new Uint8Array(0))))) return null; throw new Error('mpt: no proof nodes for a non-empty root'); }
|
|
let node = byHash.get(bytesToHex(want));
|
|
if (!node) throw new Error('mpt: the first proof node is not the root');
|
|
for (let guard = 0; guard < 128; guard++) {
|
|
const items = rlpDecode(node);
|
|
if (!Array.isArray(items)) throw new Error('mpt: node is not a list');
|
|
let next;
|
|
if (items.length === 17) {
|
|
if (pos === path.length) { const v = items[16]; return v.length ? v : null; }
|
|
next = items[path[pos]]; pos += 1;
|
|
} else if (items.length === 2) {
|
|
const [np, leaf] = hpDecode(items[0]);
|
|
for (let i = 0; i < np.length; i++) if (path[pos + i] !== np[i]) return null; // the key diverges: proven absent
|
|
pos += np.length;
|
|
if (leaf) { if (pos !== path.length) return null; return items[1]; }
|
|
next = items[1];
|
|
} else throw new Error('mpt: node with ' + items.length + ' items');
|
|
if (next instanceof Uint8Array) {
|
|
if (next.length === 0) return null; // an empty slot: proven absent
|
|
if (next.length !== 32) throw new Error('mpt: bad reference');
|
|
node = byHash.get(bytesToHex(next));
|
|
if (!node) throw new Error('mpt: a referenced node is missing from the proof');
|
|
want = next;
|
|
} else {
|
|
node = rlpEncode(next); // an embedded node (under 32 bytes)
|
|
}
|
|
}
|
|
throw new Error('mpt: path too deep');
|
|
}
|
|
// Verifies an eth_getProof account proof against `stateRoot`; returns {exists, nonce, balance, storageRoot, codeHash}.
|
|
export function verifyAccountProof(stateRoot, address, proofHex, keccak) {
|
|
const key = keccak(hexToBytes(strip(address)));
|
|
const v = mptVerify(stateRoot, key, proofHex.map(h => hexToBytes(strip(h))), keccak);
|
|
if (v === null) return { exists: false, nonce: 0n, balance: 0n, storageRoot: null, codeHash: null };
|
|
const items = rlpDecode(v);
|
|
if (!Array.isArray(items) || items.length !== 4) throw new Error('mpt: account leaf is not [nonce, balance, storageRoot, codeHash]');
|
|
return { exists: true, nonce: bigOf(items[0]), balance: bigOf(items[1]), storageRoot: bytesToHex(items[2]), codeHash: bytesToHex(items[3]) };
|
|
}
|
|
export function verifyStorageProof(storageRoot, slot, proofHex, keccak) {
|
|
const key = keccak(hexToBytes(strip(slot).padStart(64, '0')));
|
|
const v = mptVerify(storageRoot, key, proofHex.map(h => hexToBytes(strip(h))), keccak);
|
|
if (v === null) return 0n;
|
|
const item = rlpDecode(v);
|
|
if (Array.isArray(item)) throw new Error('mpt: storage leaf is a list');
|
|
return bigOf(item);
|
|
}
|
|
|
|
// ---- the header path: headers[0] is the oldest, each next header names the previous as a direct parent ------------
|
|
export function verifyHeaderPath(headers, blake2b, fromHash, toHash) {
|
|
if (!headers.length) throw new Error('no headers');
|
|
for (let i = 0; i < headers.length; i++) {
|
|
const h = headers[i];
|
|
const got = headerHash(h, blake2b);
|
|
if (got !== strip(h.hash)) throw new Error(`header ${i} (${strip(h.hash).slice(0, 12)}) does not recompute: ${got.slice(0, 12)}`);
|
|
if (i > 0) {
|
|
const direct = (h.parents_by_level && h.parents_by_level[0]) || [];
|
|
if (!direct.includes(strip(headers[i - 1].hash))) throw new Error(`header ${i} does not name header ${i - 1} as a direct parent`);
|
|
}
|
|
}
|
|
if (fromHash && strip(headers[0].hash) !== strip(fromHash)) throw new Error('the first header is not the block the proof starts from');
|
|
if (toHash && strip(headers[headers.length - 1].hash) !== strip(toHash)) throw new Error('the last header is not the certified checkpoint');
|
|
return headers.length;
|
|
}
|
|
|
|
|
|
// ---- the lock state (Review B F04, 8 October 2026): a recovery lock is never presented as a final lock ---------------
|
|
// The certificate bytes carry no state; the node reports it beside the checkpoint (lockKind "final" or "recovery" and
|
|
// recoveryLock on igneum_getFinalityCheckpoints, 2.0.2 line; the service maps it to lock_state; absent on nodes before the field). A recovery lock is a lock under the recovery rule (more than
|
|
// half of the anchored weight after a full window with no lock), reported by the node, not provable from the certificate.
|
|
export function lockStateOf(cp) {
|
|
const v = cp && (cp.lock_state ?? cp.lockKind ?? (cp.recoveryLock === true ? 'recovery' : cp.recoveryLock === false ? 'final' : undefined));
|
|
if (v === undefined || v === null || v === '') return null;
|
|
if (v !== 'final' && v !== 'recovery') throw new Error(`unknown lock state "${v}" reported for checkpoint ${cp.index}; refused`);
|
|
return v;
|
|
}
|
|
export const lockStateDetail = st => st === 'recovery'
|
|
? 'RECOVERY LOCK as the node reports it: a lock under the recovery rule, not the final rule; nothing under it is final'
|
|
: st === 'final' ? 'final lock as the node reports it' : 'no lock state reported by this node (a node before the field)';
|
|
|
|
// ---- the balance proof ------------------------------------------------------------------------------------------
|
|
// `cp` is the /api/checkpoint body (certificate, voters, headers to the previous lock); `proof` is the /balance body:
|
|
// { address, chain_id, checkpoint: {hash, index}, headers: [carrier .. checkpoint], carrier: { coinbase: <RpcTransaction>,
|
|
// evm_tx_hashes: [hex...], leaf_index: 0, merkle_siblings: [hex...] }, segment_record_hex, account: eth_getProof result }.
|
|
// Returns { verified, steps: [{name, ok, detail}], balance, ... } and stops at the first failing step.
|
|
export function verifyBalance(cp, proof, deps) {
|
|
const { blake2b, bls, keccak } = deps;
|
|
const steps = []; const t0 = now();
|
|
const step = (name, fn) => { try { const d = fn(); steps.push({ name, ok: true, detail: d }); return d; } catch (e) { steps.push({ name, ok: false, detail: String(e.message || e) }); throw e; } };
|
|
const done = extra => ({ ...extra, steps, ms: Math.round((now() - t0) * 10) / 10 });
|
|
try {
|
|
const cert = step('certificate: BLS aggregate over the checkpoint, 2/3 of active and 17/30 of total weight', () => {
|
|
const r = verifyCheckpoint(cp, { blake2b, bls });
|
|
if (!r.verified) throw new Error(r.reason);
|
|
return `checkpoint ${r.index}, ${r.signers} of ${r.voters} voters, ${(r.weight_fraction_total * 100).toFixed(1)}% of total weight, ${r.headers_checked} headers to the previous lock`;
|
|
});
|
|
step('the proof names the certified checkpoint', () => {
|
|
if (strip(proof.checkpoint.hash) !== strip(cp.hash) || Number(proof.checkpoint.index) !== Number(cp.index)) throw new Error('the balance proof is for another checkpoint than the certificate');
|
|
if (proof.chain_id !== cp.chain_id) throw new Error(`chain ${proof.chain_id} is not ${cp.chain_id}`);
|
|
return `${cp.chain_id}, checkpoint ${cp.index}`;
|
|
});
|
|
step('header chain from the carrier block up to the checkpoint (every hash recomputed, every parent link checked)', () =>
|
|
`${verifyHeaderPath(proof.headers, blake2b, proof.headers[0].hash, cp.hash)} headers, ${strip(proof.headers[0].hash).slice(0, 12)} up to ${strip(cp.hash).slice(0, 12)}`);
|
|
const carrier = proof.headers[0];
|
|
const record = step('the coinbase transaction is in the carrier block (hash recomputed, merkle path to hash_merkle_root)', () => {
|
|
const cb = coinbaseTxHash(proof.carrier.coinbase, blake2b, proof.carrier.amount_wire_len || 8);
|
|
const sibs = proof.carrier.merkle_siblings.map(s => hexToBytes(strip(s)));
|
|
const root = merkleRootFromPath(cb, Number(proof.carrier.leaf_index), sibs, blake2b);
|
|
if (bytesToHex(root) !== strip(carrier.hash_merkle_root)) throw new Error('the merkle path does not reach the carrier\'s hash_merkle_root');
|
|
const recs = segmentRecordsOf(hexToBytes(strip(proof.carrier.coinbase.payload)));
|
|
const want = strip(proof.segment_record_hex);
|
|
const rec = recs.find(r => bytesToHex(r.bytes) === want);
|
|
if (!rec) throw new Error(`the carrier's coinbase carries ${recs.length} segment record(s), none is the named one`);
|
|
return `coinbase ${bytesToHex(cb).slice(0, 12)}, leaf ${proof.carrier.leaf_index} of ${proof.carrier.evm_tx_hashes.length + 1}, record for blocks ${rec.first} to ${rec.last}`;
|
|
}) && segmentRecordsOf(hexToBytes(strip(proof.carrier.coinbase.payload))).find(r => bytesToHex(r.bytes) === strip(proof.segment_record_hex));
|
|
const agg = step('the segment record is signed by its aggregator (BLS over the record under the network\'s tag)', () => {
|
|
if (!verifySegmentRecordSignature(cp.chain_id, record, bls)) throw new Error('the aggregator\'s signature does not verify');
|
|
// the aggregator signs with a vote key; a prove-only key has no mining weight and is not in the voter table, which is
|
|
// reported, not refused (the record's standing comes from the nodes' native check and the payout, not from weight)
|
|
const kh = voteKeyHash(record.pubkey, blake2b);
|
|
const voter = (cp.voters || []).find(v => (v.vote_key_hash || voteKeyHash(hexToBytes(v.pubkey_hex), blake2b)) === kh);
|
|
const total = (cp.voters || []).reduce((a, v) => a + Number(v.weight), 0);
|
|
return voter
|
|
? `aggregator ${kh.slice(0, 12)}, a voter with weight ${voter.weight} of ${total} (${(100 * Number(voter.weight) / total).toFixed(2)}%)`
|
|
: `aggregator ${kh.slice(0, 12)}, a prove-only key (not in the voter table of ${cp.voters.length}, no mining weight)`;
|
|
});
|
|
const st = record.statement;
|
|
step('the record\'s statement names the chain and the block whose state it commits', () => {
|
|
if (Number(st.number) !== Number(record.last)) throw new Error('the statement is not for the segment\'s last block');
|
|
if (bytesToHex(record.block) !== st.block_hash) throw new Error('the record\'s block hash is not the statement\'s');
|
|
// the EVM chain id in the statement is the one in force at that block (Devnet 3: 4463 below its class v5 floor, 4464 above);
|
|
// the record's binding to the chain is the signed message, which names the network by its string ("igneum-devnet-3")
|
|
const ids = cp.chain_id === 'igneum-devnet-3' ? [4463, 4464] : cp.chain_id === 'igneum-devnet-4' ? [4465] : [Number(proof.account.evm_chain_id)];
|
|
if (!ids.includes(Number(st.chain_id))) throw new Error(`statement chain id ${st.chain_id} is not ${cp.chain_id}'s (${ids.join(' or ')})`);
|
|
return `EVM chain id ${st.chain_id}, chain block ${st.number}, post_root ${st.post_root.slice(0, 12)}, ${st.executed} executed, chain_len ${st.chain_len}`;
|
|
});
|
|
let acct = null;
|
|
step('account proof under post_root (keccak-keyed Merkle Patricia trie, every node hashed)', () => {
|
|
if (Number(proof.account.blockNumber) !== Number(st.number)) throw new Error('the account proof is for another block than the statement');
|
|
if (strip(proof.account.stateRoot) !== st.post_root) throw new Error('the node\'s state root is not the proven post_root');
|
|
const a = verifyAccountProof(hexToBytes(st.post_root), proof.address, proof.account.accountProof, keccak);
|
|
acct = a;
|
|
if (a.exists && a.balance !== BigInt(proof.account.balance)) throw new Error('the proven balance is not the balance the node reported');
|
|
if (!a.exists && BigInt(proof.account.balance) !== 0n) throw new Error('the node reports a balance for an account the trie does not hold');
|
|
return `${proof.account.accountProof.length} nodes, ${a.exists ? 'account present' : 'account absent (exclusion proof)'}`;
|
|
});
|
|
const lockState = lockStateOf(cp); step('lock state: the node\'s report beside the certificate (the certificate bytes carry none)', () => lockStateDetail(lockState));
|
|
const balance = acct.exists ? acct.balance : 0n;
|
|
return done({ verified: true, balance, balance_wei: balance.toString(), nonce: acct.nonce.toString(), block: Number(st.number), post_root: st.post_root, checkpoint: Number(cp.index), lock_state: lockState, headers: proof.headers.length, aggregator: agg, certificate: cert });
|
|
} catch (e) {
|
|
return done({ verified: false, reason: String(e.message || e) });
|
|
}
|
|
}
|
|
|
|
// ---- the receipt proof ------------------------------------------------------------------------------------------
|
|
// `receipt` = { chain_id, tx_hash, raw_tx_hex, checkpoint: {hash, index, certificate: <the /api/checkpoint body>},
|
|
// including_block: { header, evm_tx_hashes, leaf_index, merkle_siblings }, headers: [including .. checkpoint],
|
|
// execution (optional, as the node reports it): { chain_block, status, gas_used, ... } }
|
|
export function verifyReceipt(receipt, deps) {
|
|
const { blake2b, bls, keccak } = deps;
|
|
const steps = []; const t0 = now();
|
|
const step = (name, fn) => { try { const d = fn(); steps.push({ name, ok: true, detail: d }); return d; } catch (e) { steps.push({ name, ok: false, detail: String(e.message || e) }); throw e; } };
|
|
const done = extra => ({ ...extra, steps, ms: Math.round((now() - t0) * 10) / 10 });
|
|
try {
|
|
const cp = receipt.checkpoint.certificate;
|
|
const cert = step('certificate: BLS aggregate over the checkpoint, 2/3 of active and 17/30 of total weight', () => {
|
|
const r = verifyCheckpoint(cp, { blake2b, bls });
|
|
if (!r.verified) throw new Error(r.reason);
|
|
if (strip(cp.hash) !== strip(receipt.checkpoint.hash) || receipt.chain_id !== cp.chain_id) throw new Error('the receipt names another checkpoint or chain than its certificate');
|
|
return `checkpoint ${r.index} on ${cp.chain_id}, ${r.signers} of ${r.voters} voters, ${(r.weight_fraction_total * 100).toFixed(1)}% of total weight`;
|
|
});
|
|
const lockState = step('lock state: the node\'s report beside the certificate (the certificate bytes carry none)', () => {
|
|
const st = lockStateOf(cp);
|
|
if (receipt.lock_state !== undefined && receipt.lock_state !== null && receipt.lock_state !== (st || 'final')) throw new Error(`the receipt says lock_state "${receipt.lock_state}" but its certificate is reported as ${st || 'final (no state field)'}; refused`);
|
|
return lockStateDetail(st);
|
|
}) && lockStateOf(cp);
|
|
let tx = null;
|
|
step('the transaction hash is keccak256 of the raw signed transaction', () => {
|
|
const raw = hexToBytes(strip(receipt.raw_tx_hex));
|
|
const h = bytesToHex(keccak(raw));
|
|
if (h !== strip(receipt.tx_hash)) throw new Error(`the raw bytes hash to ${h.slice(0, 12)}, not ${strip(receipt.tx_hash).slice(0, 12)}`);
|
|
tx = parseTx(raw);
|
|
return `${raw.length} bytes, type ${tx.type}, to ${tx.to || 'contract creation'}, value ${tx.value} wei, nonce ${tx.nonce}`;
|
|
});
|
|
step('header chain from the including block up to the checkpoint (every hash recomputed, every parent link checked)', () =>
|
|
`${verifyHeaderPath(receipt.headers, blake2b, receipt.including_block.header.hash, cp.hash)} headers, ${strip(receipt.headers[0].hash).slice(0, 12)} up to ${strip(cp.hash).slice(0, 12)}`);
|
|
step('the transaction is a leaf of the including block\'s hash_merkle_root', () => {
|
|
const ib = receipt.including_block;
|
|
const sibs = ib.merkle_siblings.map(s => hexToBytes(strip(s)));
|
|
const root = merkleRootFromPath(hexToBytes(strip(receipt.tx_hash)), Number(ib.leaf_index), sibs, blake2b);
|
|
if (bytesToHex(root) !== strip(receipt.headers[0].hash_merkle_root)) throw new Error('the merkle path does not reach the including block\'s hash_merkle_root');
|
|
return `leaf ${ib.leaf_index} of ${ib.leaf_count}`;
|
|
});
|
|
return done({ verified: true, tx, headers: receipt.headers.length, checkpoint: Number(cp.index), lock_state: lockState, certificate: cert, block: strip(receipt.headers[0].hash), block_daa: receipt.headers[0].daa_score, block_time: receipt.headers[0].timestamp });
|
|
} catch (e) {
|
|
return done({ verified: false, reason: String(e.message || e) });
|
|
}
|
|
}
|
|
|
|
// A typed (EIP-1559 type 2, EIP-2930 type 1) or legacy raw transaction: the fields a receipt shows. No signature
|
|
// recovery here (the node's `from` is shown as reported; the chain's own check of the signature is what put the
|
|
// transaction in a block).
|
|
export function parseTx(raw) {
|
|
const type = raw[0] <= 0x7f ? raw[0] : 0;
|
|
const body = rlpDecode(type ? raw.subarray(1) : raw);
|
|
const hex = b => '0x' + bytesToHex(b);
|
|
if (type === 2) return { type, chain_id: bigOf(body[0]).toString(), nonce: bigOf(body[1]).toString(), to: body[5].length ? hex(body[5]) : null, value: bigOf(body[6]).toString(), data: hex(body[7]), gas: bigOf(body[4]).toString() };
|
|
if (type === 1) return { type, chain_id: bigOf(body[0]).toString(), nonce: bigOf(body[1]).toString(), to: body[4].length ? hex(body[4]) : null, value: bigOf(body[5]).toString(), data: hex(body[6]), gas: bigOf(body[3]).toString() };
|
|
return { type: 0, nonce: bigOf(body[0]).toString(), to: body[3].length ? hex(body[3]) : null, value: bigOf(body[4]).toString(), data: hex(body[5]), gas: bigOf(body[2]).toString() };
|
|
}
|
|
|
|
|
|
// ---- the ordered receipts trie (reth's layout: key = RLP(index), value = the EIP-2718 receipt envelope) ----------
|
|
// Built in full from the shard's receipts, so the root is recomputed rather than proven: a shard is bounded by its pgas
|
|
// budget and holds at most a few hundred receipts.
|
|
function hpEncode(nibs, leaf) {
|
|
const odd = nibs.length % 2; const flag = (leaf ? 2 : 0) + odd;
|
|
const out = []; if (odd) out.push((flag << 4) | nibs[0]); else out.push(flag << 4, );
|
|
for (let i = odd; i < nibs.length; i += 2) out.push((nibs[i] << 4) | nibs[i + 1]);
|
|
return new Uint8Array(out);
|
|
}
|
|
function trieNode(items, keccak) {
|
|
// items: [{nibs, value}] sorted by nibs; returns the RLP of the node (inline when under 32 bytes, else its hash)
|
|
if (items.length === 0) return new Uint8Array(0);
|
|
const encode = node => { const enc = rlpEncode(node); return enc.length < 32 ? enc : keccak(enc); };
|
|
if (items.length === 1) return encode([hpEncode(items[0].nibs, true), items[0].value]);
|
|
let prefix = 0;
|
|
while (items.every(it => it.nibs.length > prefix && it.nibs[prefix] === items[0].nibs[prefix])) prefix++;
|
|
if (prefix > 0) {
|
|
const child = trieNode(items.map(it => ({ nibs: it.nibs.slice(prefix), value: it.value })), keccak);
|
|
return encode([hpEncode(items[0].nibs.slice(0, prefix), false), child.length === 32 && rlpEncode(child).length > 32 ? child : child]);
|
|
}
|
|
const branch = []; let value = new Uint8Array(0);
|
|
for (let b = 0; b < 16; b++) {
|
|
const sub = items.filter(it => it.nibs.length > 0 && it.nibs[0] === b).map(it => ({ nibs: it.nibs.slice(1), value: it.value }));
|
|
branch.push(sub.length ? trieNode(sub, keccak) : new Uint8Array(0));
|
|
}
|
|
const here = items.find(it => it.nibs.length === 0); if (here) value = here.value;
|
|
branch.push(value);
|
|
return encode(branch);
|
|
}
|
|
// Children are RLP-embedded when their encoding is under 32 bytes, else referenced by hash; trieNode returns either
|
|
// the short RLP or the 32-byte hash, and rlpEncode treats a Uint8Array as a string item: a short child must be
|
|
// spliced in raw, so the list encoder below handles both forms.
|
|
export function orderedTrieRoot(values, keccak) {
|
|
const items = values.map((v, i) => ({ nibs: nibblesOf(rlpEncode(trimBig(BigInt(i)))), value: v }));
|
|
items.sort((a, b) => { const n = Math.min(a.nibs.length, b.nibs.length); for (let k = 0; k < n; k++) if (a.nibs[k] !== b.nibs[k]) return a.nibs[k] - b.nibs[k]; return a.nibs.length - b.nibs.length; });
|
|
if (!items.length) return keccak(rlpEncode(new Uint8Array(0)));
|
|
const root = trieNodeTop(items, keccak);
|
|
return root;
|
|
}
|
|
function nibblesOf(b) { const out = []; for (const x of b) out.push(x >> 4, x & 15); return out; }
|
|
// the node forms: an Embedded child is spliced raw into the parent's list; a Hashed child is a 32-byte string item
|
|
class Raw { constructor(bytes) { this.bytes = bytes; } }
|
|
function rlpList(parts) {
|
|
const body = concat(parts.map(p => p instanceof Raw ? p.bytes : rlpEncode(p)));
|
|
const len = body.length; const head = len < 56 ? new Uint8Array([0xc0 + len]) : (() => { const b = []; let x = len; while (x > 0) { b.unshift(x & 0xff); x = Math.floor(x / 256); } return new Uint8Array([0xf7 + b.length, ...b]); })();
|
|
return concat([head, body]);
|
|
}
|
|
function build(items, keccak) { // returns { enc } the node's RLP
|
|
if (items.length === 1) return rlpList([hpEncode(items[0].nibs, true), items[0].value]);
|
|
let prefix = 0;
|
|
while (items.every(it => it.nibs.length > prefix && it.nibs[prefix] === items[0].nibs[prefix])) prefix++;
|
|
if (prefix > 0) {
|
|
const childEnc = build(items.map(it => ({ nibs: it.nibs.slice(prefix), value: it.value })), keccak);
|
|
return rlpList([hpEncode(items[0].nibs.slice(0, prefix), false), childEnc.length < 32 ? new Raw(childEnc) : keccak(childEnc)]);
|
|
}
|
|
const parts = [];
|
|
for (let b = 0; b < 16; b++) {
|
|
const sub = items.filter(it => it.nibs.length > 0 && it.nibs[0] === b).map(it => ({ nibs: it.nibs.slice(1), value: it.value }));
|
|
if (!sub.length) { parts.push(new Uint8Array(0)); continue; }
|
|
const enc = build(sub, keccak); parts.push(enc.length < 32 ? new Raw(enc) : keccak(enc));
|
|
}
|
|
const here = items.find(it => it.nibs.length === 0); parts.push(here ? here.value : new Uint8Array(0));
|
|
return rlpList(parts);
|
|
}
|
|
function trieNodeTop(items, keccak) { return keccak(build(items, keccak)); }
|
|
void trieNode;
|
|
|
|
// A receipt as the node encodes it for the trie (alloy ReceiptEnvelope::encode_2718): type byte for 1 and 2, then
|
|
// RLP([status, cumulative_gas_used, logs_bloom, [[address, [topics], data]...]]). Fields from eth_getBlockReceipts.
|
|
export function receiptEnvelope(r) {
|
|
const h = x => hexToBytes(strip(x));
|
|
const status = Number(r.status) === 1 ? new Uint8Array([1]) : new Uint8Array(0);
|
|
const logs = (r.logs || []).map(l => [h(l.address), (l.topics || []).map(h), h(l.data || '0x')]);
|
|
const body = rlpEncode([status, trimBig(BigInt(r.cumulativeGasUsed)), h(r.logsBloom), logs]);
|
|
const type = Number(r.type || 0);
|
|
return type === 1 || type === 2 ? concat([new Uint8Array([type]), body]) : body;
|
|
}
|
|
|
|
// ---- the payment receipt: the outcome authenticated through the proven segment's receipts commitment ------------
|
|
// `pr` = the inclusion receipt's fields plus: segment: { record_hex, carrier: {...as /balance}, headers: [carrier..checkpoint],
|
|
// shard_receipts_roots: [hex...], shard_index, receipts: [eth_getBlockReceipts rows of that shard, in order],
|
|
// receipt_position }. The transaction's chain block must be the segment's last block (the statement commits that
|
|
// block's receipts); the verifier says so when it is not.
|
|
export function verifyPaymentReceipt(pr, deps) {
|
|
const { blake2b, bls, keccak } = deps;
|
|
const base = verifyReceipt(pr, deps);
|
|
if (!base.verified) return { ...base, payment: false };
|
|
const steps = base.steps.slice(); const t0 = now();
|
|
const step = (name, fn) => { try { const d = fn(); steps.push({ name, ok: true, detail: d }); return d; } catch (e) { steps.push({ name, ok: false, detail: String(e.message || e) }); throw e; } };
|
|
const done = extra => ({ ...base, ...extra, steps, ms: Math.round((base.ms + now() - t0) * 10) / 10 });
|
|
try {
|
|
const seg = pr.segment;
|
|
if (!seg) throw Object.assign(new Error(pr.payment_unavailable || 'no proven segment record covers the chain block that executed this transaction yet'), { soft: true });
|
|
const cp = pr.checkpoint.certificate;
|
|
step('header chain from the record\'s carrier block up to the checkpoint', () => `${verifyHeaderPath(seg.headers, blake2b, seg.headers[0].hash, cp.hash)} headers`);
|
|
const record = step('the segment record is in the carrier\'s coinbase (hash recomputed, merkle path) and signed by its aggregator', () => {
|
|
const cb = coinbaseTxHash(seg.carrier.coinbase, blake2b, seg.carrier.amount_wire_len || 8);
|
|
const root = merkleRootFromPath(cb, Number(seg.carrier.leaf_index), seg.carrier.merkle_siblings.map(x => hexToBytes(strip(x))), blake2b);
|
|
if (bytesToHex(root) !== strip(seg.headers[0].hash_merkle_root)) throw new Error('the merkle path does not reach the carrier\'s hash_merkle_root');
|
|
const rec = segmentRecordsOf(hexToBytes(strip(seg.carrier.coinbase.payload))).find(r => bytesToHex(r.bytes) === strip(seg.record_hex));
|
|
if (!rec) throw new Error('the named segment record is not in the carrier\'s coinbase');
|
|
if (!verifySegmentRecordSignature(cp.chain_id, rec, bls)) throw new Error('the aggregator\'s signature does not verify');
|
|
return `record for blocks ${rec.first} to ${rec.last}, aggregator ${voteKeyHash(rec.pubkey, blake2b).slice(0, 12)}`;
|
|
}) && segmentRecordsOf(hexToBytes(strip(seg.carrier.coinbase.payload))).find(r => bytesToHex(r.bytes) === strip(seg.record_hex));
|
|
const st = record.statement;
|
|
step('the statement is for the chain block that executed the transaction', () => {
|
|
if (Number(st.number) !== Number(pr.execution.chain_block) || st.block_hash !== strip(pr.execution.chain_block_hash)) throw new Error(`the statement is for chain block ${st.number}, the transaction executed at ${pr.execution.chain_block}`);
|
|
return `chain block ${st.number}, ${st.shard_count} shard(s), receipts commitment ${st.receipts.slice(0, 12)}`;
|
|
});
|
|
step('the shard receipts roots hash to the statement\'s receipts commitment', () => {
|
|
const roots = seg.shard_receipts_roots.map(x => hexToBytes(strip(x)));
|
|
if (roots.length !== Number(st.shard_count)) throw new Error(`${roots.length} roots given, the statement names ${st.shard_count} shards`);
|
|
if (bytesToHex(keccak(concat(roots))) !== st.receipts) throw new Error('keccak over the shard receipts roots is not the statement\'s commitment');
|
|
return `${roots.length} root(s), shard ${seg.shard_index} is ${strip(seg.shard_receipts_roots[seg.shard_index]).slice(0, 12)}`;
|
|
});
|
|
const mine = step('the shard\'s receipts rebuild its receipts trie root, and the transaction\'s receipt sits in it', () => {
|
|
const root = bytesToHex(orderedTrieRoot(seg.receipts.map(receiptEnvelope), keccak));
|
|
if (root !== strip(seg.shard_receipts_roots[seg.shard_index])) throw new Error(`the ${seg.receipts.length} receipts rebuild root ${root.slice(0, 12)}, the committed root is ${strip(seg.shard_receipts_roots[seg.shard_index]).slice(0, 12)}`);
|
|
const r = seg.receipts[Number(seg.receipt_position)];
|
|
if (!r || strip(r.transactionHash) !== strip(pr.tx_hash)) throw new Error('the receipt at the named position is not this transaction\'s');
|
|
return `${seg.receipts.length} receipt(s), this one at position ${seg.receipt_position}, status ${Number(r.status) === 1 ? 'success' : 'failed'}, gas ${parseInt(r.gasUsed, 16)}, ${(r.logs || []).length} log(s)`;
|
|
}) && seg.receipts[Number(seg.receipt_position)];
|
|
const ok = Number(mine.status) === 1;
|
|
const outcome = ok ? { asset: 'IGN (the native coin)', recipient: base.tx.to, amount_wei: base.tx.value, logs: (mine.logs || []).map(l => ({ address: l.address, topics: l.topics, data: l.data })) } : null;
|
|
return done({ verified: true, payment: ok, outcome, receipt_status: ok ? 'success' : 'failed', note: ok ? 'the transfer outcome is authenticated through the proven segment\'s receipts commitment' : 'the transaction executed and FAILED: no transfer took place; authenticated the same way' });
|
|
} catch (e) {
|
|
if (e.soft) return done({ verified: true, payment: false, payment_unavailable: String(e.message || e) });
|
|
return done({ verified: false, payment: false, reason: String(e.message || e) });
|
|
}
|
|
}
|
|
|
|
export function formatIgn(wei, decimals = 18) {
|
|
const v = BigInt(wei); const base = 10n ** BigInt(decimals);
|
|
const whole = v / base; let frac = (v % base).toString().padStart(decimals, '0').replace(/0+$/, '');
|
|
if (frac.length > 6) frac = frac.slice(0, 6);
|
|
return whole.toString() + (frac ? '.' + frac : '');
|
|
}
|
|
const now = () => (typeof performance !== 'undefined' ? performance : Date).now();
|