388 lines
26 KiB
JavaScript
388 lines
26 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 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] : [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 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), 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`;
|
|
});
|
|
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), 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() };
|
|
}
|
|
|
|
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();
|