Reference apps: the balance proof answers with the earliest certificate above the carrier (the certificate travels in the answer), the real Devnet 3 balance fixture (26631, 97 KB) and its negative cases under Node; the oracle's first real root on Sepolia (block 26631, 404,376 gas, 41 cases pass); the account object captured in the balance verifier

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 10:45:37 +00:00
parent 107b79cea7
commit 19ded59751
9 changed files with 62 additions and 8 deletions

View file

@ -42,6 +42,8 @@ export async function runLight(address) {
const cp = await getJson(`${API}/checkpoint`);
setStatus(`checkpoint ${cp.index} fetched; fetching the proof for ${short(address)}…`, 'busy');
const proof = await getJson(`${API}/balance?address=${address}&checkpoint=${cp.hash}&index=${cp.index}`);
// the service may answer with an earlier certificate (the first checkpoint above the carrier: the smallest proof); verified like any other
if (proof.checkpoint_certificate) { Object.assign(cp, proof.checkpoint_certificate); }
setStatus('verifying in this tab…', 'busy');
await new Promise(r => setTimeout(r, 20));
// known-failed first: the same proof with one byte of the last account-proof node altered

View file

@ -307,10 +307,12 @@ export function verifyBalance(cp, proof, deps) {
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}`;
});
const acct = step('account proof under post_root (keccak-keyed Merkle Patricia trie, every node hashed)', () => {
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)'}`;

View file

@ -60,6 +60,7 @@ try {
if (!address) { const b = await rpc('eth_getBlockByNumber', ['latest', false]); address = b.igneum.rewards[0].miner; }
let proof = null;
try { proof = await get(`${API}/balance?address=${address}&checkpoint=${cp.hash}&index=${cp.index}`); } catch (e) { document.querySelector('#b tbody').innerHTML = `<tr><td colspan="4" class="bad">balance proof not available: ${e.message}</td></tr>`; }
if (proof && proof.checkpoint_certificate) Object.assign(cp, proof.checkpoint_certificate);
if (proof) {
const B = [
['account proof: a node altered', d => { const i = d.account.accountProof.length - 1; d.account.accountProof[i] = flipHex(d.account.accountProof[i], 30); }],
@ -68,7 +69,7 @@ try {
['post_root altered inside the segment record', d => { d.segment_record_hex = flipHex(d.segment_record_hex, 2 * (2 + 8 + 8 + 32 + 48 + 20 + 148) + 3); }],
['aggregator signature altered', d => { d.segment_record_hex = flipHex(d.segment_record_hex, d.segment_record_hex.length - 10); }],
['coinbase payload altered', d => { d.carrier.coinbase.payload = flipHex(d.carrier.coinbase.payload, 30); }],
['a merkle sibling altered', d => { d.carrier.merkle_siblings[0] = flipHex(d.carrier.merkle_siblings[0], 1); }],
['a merkle sibling altered (or added to a one-leaf body)', d => { if (d.carrier.merkle_siblings.length) d.carrier.merkle_siblings[0] = flipHex(d.carrier.merkle_siblings[0], 1); else d.carrier.merkle_siblings.push('00'.repeat(32)); }],
['a header removed from the path', d => { if (d.headers.length > 2) d.headers.splice(1, 1); else d.headers[0].nonce = String(BigInt(d.headers[0].nonce) ^ 1n); }],
['another address with this proof', d => { d.address = '0x' + '11'.repeat(20); }],
];

View file

@ -239,6 +239,7 @@
<div class="k">Oracle</div><div class="mono" data-oracle-address>0xefe9879de29c401eeff195d5bf71c86b9caaa1b2 <small>IgneumStateOracle, deployed 8 October 2026 (tx 0xb8650f5b…d644, block 11869608)</small></div>
<div class="k">Verifier</div><div class="mono" data-verifier-address>0xa197ef31d5d5613125779179668e2482ac69a6f6 <small>StubCertificateVerifier, the stand-in: it records any well-formed certificate and does not check the BLS signature; the shared IgneumCertificateVerifier replaces it through setVerifier the day it lands</small></div>
<div class="k">Chain</div><div>Sepolia, chain id 11155111; Devnet 3 (igneum-devnet-3), no value</div>
<div class="k">First proven root</div><div class="mono">Devnet 3 chain block 26631, post_root 0xf567a84f…a82fd, stored 8 October 2026 (tx 0xf360e54c…224b, 404,376 gas); provenBalance read 92.029323 IGN for 0x636de2dc…44ea on Sepolia, equal to the Devnet 3 node's eth_getProof</div>
</div>
<p class="note">How to read a balance from another contract: <code>IIgneumStateOracle(oracle).provenBalance(number, account, accountProof)</code>, where <code>number</code> is a Devnet 3 chain block whose state root the oracle holds and <code>accountProof</code> is the <code>eth_getProof</code> account proof at that block. A storage slot: <code>provenStorage(number, account, slot, accountProof, storageProof)</code>. Both revert on any mismatch.</p>
</section>

File diff suppressed because one or more lines are too long

View file

@ -177,12 +177,18 @@ async function balance(q) {
const records = segmentRecordsOf(hexToBytes(strip(coinbase.payload)));
const rec = records.find(r => Number(r.first) === chosen.first && Number(r.last) === chosen.last);
if (!rec) throw httpError(500, `the carrier's coinbase holds ${records.length} segment record(s), none for segment ${chosen.first}`);
const headers = await headerPath(chosen.carrier, chosen.carrierNumber, cpNumber, cpHash);
// the smallest proof: the earliest certified checkpoint at or above the carrier (the certificate used travels in the answer)
let certificate = null;
if (process.env.DATABASE_URL) certificate = await earliestCheckpointAbove(neon(), chosen.carrierNumber, chainNumberOf).catch(() => null);
let cpN = cpNumber, cpH = cpHash, cpI = cpIndex;
if (certificate) { cpH = certificate.hash; cpI = Number(certificate.index); cpN = await chainNumberOf(cpH); }
const headers = await headerPath(chosen.carrier, chosen.carrierNumber, cpN, cpH);
const account = await exec('eth_getProof', [address, [], '0x' + chosen.last.toString(16)]);
const chainId = await exec('eth_chainId', []);
return {
ok: true, now: new Date().toISOString(), chain_id: 'igneum-devnet-3', address,
checkpoint: { hash: strip(cpHash), index: cpIndex, chain_block: cpNumber },
checkpoint: { hash: strip(cpH), index: cpI, chain_block: cpN },
checkpoint_certificate: certificate ? { ok: true, ...certificate } : undefined,
segment: { first: chosen.first, last: chosen.last, carrier: chosen.carrier, carrier_chain_block: chosen.carrierNumber, aggregator_key_hash: chosen.keyHash, paid_wei: chosen.paidWei, statement: chosen.statement },
headers,
carrier: { coinbase, evm_tx_hashes: evmHashes.map(bytesToHex), leaf_index: 0, merkle_siblings: siblings(leaves, 0), amount_wire_len: 8 },
@ -231,7 +237,7 @@ async function receipt(q) {
const headers = await headerPath(including, chainNumber, cpNumber, cpHash);
return {
ok: true, now: new Date().toISOString(), chain_id: 'igneum-devnet-3', tx_hash: strip(tx), raw_tx_hex: strip(raws[idx]),
checkpoint: { hash: strip(cpHash), index: cpIndex, chain_block: cpNumber, certificate: certificate || undefined },
checkpoint: { hash: strip(cpHash), index: cpIndex, chain_block: cpNumber, certificate: certificate ? { ok: true, ...certificate } : undefined },
including_block: { header: headers[0], leaf_index: idx + 1, leaf_count: leaves.length, merkle_siblings: siblings(leaves, idx + 1) },
headers,
execution: rcpt ? { chain_block: chainNumber, chain_block_hash: strip(rcpt.blockHash), status: rcpt.status, gas_used: rcpt.gasUsed, from: rcpt.from, to: rcpt.to, contract_address: rcpt.contractAddress, logs: (rcpt.logs || []).length, effective_gas_price: rcpt.effectiveGasPrice, as_reported_by: 'the node (not proven here)' } : null,

View file

@ -43,7 +43,7 @@ if (r.verified) console.log(` ${r.tx.type === 2 ? 'EIP-1559' : 'type ' + r.tx.
const balancePath = process.argv[3] || here + '../fixtures/dn3-balance.json';
let balance = null; try { balance = load(balancePath); } catch { console.log('no balance fixture yet (' + balancePath + '), skipping the balance cases'); }
if (balance) {
const cp = balance.checkpoint_certificate || load(process.argv[4] || here + '../fixtures/dn3-checkpoint.json');
const cp = balance.checkpoint_certificate || load(process.argv[4] || here + '../fixtures/dn3-checkpoint.json'); // the certificate the service answered with
console.log(`balance of ${balance.address} at chain block ${balance.segment.last}, checkpoint ${balance.checkpoint.index}, ${balance.headers.length} headers`);
{ const d = clone(balance); d.account.accountProof[d.account.accountProof.length - 1] = flipHex(d.account.accountProof[d.account.accountProof.length - 1], 30); expect('account proof: a node altered', verifyBalance(cp, d, deps), false); }
{ const d = clone(balance); d.account.balance = '0x' + (BigInt(d.account.balance) + 1n).toString(16); expect('reported balance raised by one wei', verifyBalance(cp, d, deps), false); }
@ -51,7 +51,7 @@ if (balance) {
{ const d = clone(balance); d.segment_record_hex = flipHex(d.segment_record_hex, 2 * (2 + 8 + 8 + 32 + 48 + 20 + 148) + 3); expect('post_root altered inside the segment record', verifyBalance(cp, d, deps), false); }
{ const d = clone(balance); d.segment_record_hex = flipHex(d.segment_record_hex, d.segment_record_hex.length - 10); expect('aggregator signature altered', verifyBalance(cp, d, deps), false); }
{ const d = clone(balance); d.carrier.coinbase.payload = flipHex(d.carrier.coinbase.payload, 30); expect('coinbase payload altered', verifyBalance(cp, d, deps), false); }
{ const d = clone(balance); d.carrier.merkle_siblings[0] = flipHex(d.carrier.merkle_siblings[0], 1); expect('a merkle sibling altered', verifyBalance(cp, d, deps), false); }
{ const d = clone(balance); if (d.carrier.merkle_siblings.length) { d.carrier.merkle_siblings[0] = flipHex(d.carrier.merkle_siblings[0], 1); expect('a merkle sibling altered', verifyBalance(cp, d, deps), false); } else { d.carrier.merkle_siblings.push('00'.repeat(32)); expect('a merkle sibling added to a one-leaf body', verifyBalance(cp, d, deps), false); } }
{ const d = clone(balance); if (d.headers.length > 2) { d.headers.splice(1, 1); expect('a header removed from the path', verifyBalance(cp, d, deps), false); } }
{ const d = clone(balance); d.checkpoint.hash = flipHex(d.checkpoint.hash, 1); expect('proof names another checkpoint than the certificate', verifyBalance(cp, d, deps), false); }
{ const c = clone(cp); c.certificate.aggregate_signature_hex = flipHex(c.certificate.aggregate_signature_hex, 20); expect('certificate signature altered', verifyBalance(c, balance, deps), false); }

View file

@ -81,6 +81,42 @@
"gasUsed": 580281,
"calldataBytes": 1863,
"headers": 3
},
{
"at": "2026-10-08T10:44:27.702Z",
"what": "submitCertificate",
"index": "2060",
"checkpoint": "0xbbf3757d7f7bd6b718affcb9f2e15f59da55f7129d6885d7e26d40dd9ea1d934",
"verifier": "0xa197ef31D5D5613125779179668E2482AC69a6F6",
"tx": "0xa23da37f02ad28e6d9b371a3d2f07490be06283b718933bf47f900429bd55c9b",
"block": 11869644,
"gasUsed": 240856
},
{
"at": "2026-10-08T10:44:36.578Z",
"what": "submitStateRoot",
"vector": "devnet-3",
"number": "26631",
"postRoot": "0xf567a84fa10dcabab5f68c712f9553f2f2032cb675bdaba1d13c7901a67a82fd",
"certIndex": "2060",
"tx": "0xb3875bbd311a91457c3f4b139d6eff38cfb5970e3260d9096f5d8070d1d62e85",
"block": 11869645,
"gasUsed": 642331,
"calldataBytes": 5701,
"headers": 1
},
{
"at": "2026-10-08T10:44:39.214Z",
"what": "submitStateRoot",
"vector": "devnet-3",
"number": "26631",
"postRoot": "0xf567a84fa10dcabab5f68c712f9553f2f2032cb675bdaba1d13c7901a67a82fd",
"certIndex": "2060",
"tx": "0xf360e54c8f8c40a73c25c8fdfd214ff559ec0ee281beda452e7b56e2a2cf224b",
"block": 11869645,
"gasUsed": 404376,
"calldataBytes": 5701,
"headers": 1
}
]
}

View file

@ -116,7 +116,12 @@ if (existsSync(L.fixtures + '/dn3-balance.json')) {
const bal = await read('provenBalance', [V.number, L.hex0x(V.address), V.accountProof.map(L.hex0x)]);
ok('C2 provenBalance equals the node\'s eth_getProof balance', bal === V.balance, bal + ' wei');
await expectRevert('C3 a flipped byte in the real account proof', () => read('provenBalance', [V.number, L.hex0x(V.address), V.accountProof.map((n, i) => (i === V.accountProof.length - 1 ? flipByte(L.hex0x(n), 10) : L.hex0x(n)))]), 'mpt');
await expectRevert('C4 the real path with a header removed', () => read('verifyStateRoot', [args[0], args[1].filter((_, i) => i !== 1), ...args.slice(2)]), 'does not name');
const realAltered = clone(V.headers[0]); realAltered.nonce = String(BigInt(realAltered.nonce) + 1n);
await expectRevert('C4 the real carrier header with its nonce altered', () => read('verifyStateRoot', [args[0], [L.hex0x(L.serializeHeader(realAltered)), ...args[1].slice(1)], ...args.slice(2)]), V.headers.length === 1 ? 'not the certified checkpoint' : 'does not name');
await expectRevert('C5 the real path emptied', () => read('verifyStateRoot', [args[0], [], ...args.slice(2)]), 'headers: none');
await expectRevert('C6 the real certificate index off by one', () => read('verifyStateRoot', [args[0] + 1n, ...args.slice(1)]), 'no certificate');
const cbReal = L.hexToBytes(L.strip(args[2])); const off = L.bytesToHex(cbReal).indexOf(L.bytesToHex(V.postRoot)) / 2; cbReal[off + 3] ^= 0x80;
await expectRevert('C7 the real record\'s post_root altered inside the coinbase (offset ' + off + ')', () => read('verifyStateRoot', [...args.slice(0, 2), L.hex0x(cbReal), ...args.slice(3)]), 'hash_merkle_root');
} else {
console.log(' Part C skipped: fixtures/dn3-balance.json is not there yet');
}