#!/usr/bin/env node /** * capture-proof — freeze the REAL ruling into a replayable artifact. * * This script invents nothing. Every field it writes is read, at run time, from one of three * live sources: * * 1. Creditcoin CC3 testnet RPC — the receipt of the real claim transaction, including the * Attestcoin precompile's own `TransactionVerified` logs and Index41's `SandwichProven` / * `HarmPaid` logs. This is where the indices 14 / 15 / 16 come from. Not from this file. * 2. The Attestcoin proof-gen API — `POST /api/v1/proof-batch/3`, which returns the merkle * authentication path for each of the three legs. The laterality string (`RLLLRRRR`) is * derived from those siblings, one bit per sibling, and nothing else. * 3. An Ethereum mainnet RPC — the source block header, so the demo can state how many * transactions the block held and when it was mined. * * The artifact exists so the demo still shows REAL proven data when a judge is offline or a * public endpoint is rate-limiting. The web app prefers a live read and says, on screen, which * of the two it is showing. It never falls back to invented numbers, because there are none. * * node scripts/capture-proof.mjs # writes data/proof-artifact.json * node scripts/capture-proof.mjs --check # re-capture and diff against the committed file */ import { writeFileSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(REPO_ROOT, 'data', 'proof-artifact.json'); // --- the subjects, and the only literals in this file ----------------------------------------- const CC3_RPC = process.env.CC3_RPC_URL ?? 'https://rpc.cc3-testnet.creditcoin.network'; const ETH_RPC = process.env.ETH_RPC_URL ?? 'https://ethereum-rpc.publicnode.com'; const PROVER = process.env.PROVER_URL ?? 'https://prover.cc3-testnet.creditcoin.network'; /** The claim transaction on Creditcoin. Everything about the ruling is read out of its receipt. */ const CLAIM_TX = '0xd136dea0524b7e0e9eba54bf9724eec78597c2598047a96849af727f4d243810'; /** Attestcoin source-chain key 3 = Ethereum mainnet. */ const CHAIN_KEY = 3; const SOURCE_BLOCK = 25764741; /** The roles, in submission order. The INDICES are not written here — the chain supplies them. */ const ROLES = [ { role: 'front-run', label: 'searcher buy', blurb: 'the searcher moves the pool first' }, { role: 'victim', label: 'the victim', blurb: 'the swap that gets the worse price' }, { role: 'back-run', label: 'searcher sell', blurb: 'the searcher exits into the damage' }, ]; // --- event signatures, and the topic0 each one must hash to ----------------------------------- const EVENTS = { TransactionVerified: { signature: 'TransactionVerified(uint64,uint64,uint64)', topic0: '0x8a8df984523447f746ce8bccdb04c87025c708eb62a2d070bdffb8945c8f391e', emitter: 'the Attestcoin native query verifier precompile', }, SandwichProven: { signature: 'SandwichProven(address,uint64,uint64,uint64,uint64,uint256,uint256)', topic0: '0xbb41c14a4b2500ad9e0d5a583435d6419982a9877d0efd00d1107d6177d9b9ac', emitter: 'Index41', }, HarmPaid: { signature: 'HarmPaid(address,address,uint256)', topic0: '0xdcdf799a3df7388e9f3178281e4f57547a2e7494e14d7d01762d0891eddef0af', emitter: 'Index41', }, }; // ---------------------------------------------------------------------------------------------- async function rpc(url, method, params, timeoutMs = 30_000) { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) throw new Error(`${method} → HTTP ${res.status} from ${url}`); const json = await res.json(); if (json.error) throw new Error(`${method} → ${json.error.message}`); if (json.result == null) throw new Error(`${method} → null result from ${url}`); return json.result; } const n = (hex) => Number(BigInt(hex)); const big = (hex) => BigInt(hex).toString(); const word = (data, i) => '0x' + data.slice(2).slice(i * 64, (i + 1) * 64); const addr = (topic) => '0x' + topic.slice(-40); /** Assert the hardcoded topic0s really are keccak256 of the signatures we print next to them. */ async function assertTopics() { let id; try { ({ id } = await import('ethers')); } catch { console.warn(' ! ethers unavailable — skipping the topic0 self-check'); return 'skipped (ethers not installed)'; } for (const [name, ev] of Object.entries(EVENTS)) { const computed = id(ev.signature); if (computed !== ev.topic0) throw new Error(`${name}: topic0 mismatch, keccak256 says ${computed}`); } return 'keccak256 of every event signature matches its hardcoded topic0'; } async function main() { const startedAt = new Date().toISOString(); console.log('capture-proof — reading the ruling from live sources, inventing nothing\n'); console.log(' topic0 self-check …'); const topicCheck = await assertTopics(); console.log(` ${topicCheck}\n`); // 1 ── Creditcoin: the receipt of the claim --------------------------------------------------- console.log(` CC3 ${CC3_RPC}`); const [receipt, cc3Tx] = await Promise.all([ rpc(CC3_RPC, 'eth_getTransactionReceipt', [CLAIM_TX]), rpc(CC3_RPC, 'eth_getTransactionByHash', [CLAIM_TX]), ]); const cc3Block = await rpc(CC3_RPC, 'eth_getBlockByNumber', [receipt.blockNumber, false]); console.log(` receipt block ${n(receipt.blockNumber)} · status ${n(receipt.status)} · ${receipt.logs.length} logs`); // 2 ── Ethereum mainnet: the source block header ---------------------------------------------- console.log(` ETH ${ETH_RPC}`); const ethBlock = await rpc(ETH_RPC, 'eth_getBlockByNumber', ['0x' + SOURCE_BLOCK.toString(16), false]); console.log(` block ${SOURCE_BLOCK} · ${ethBlock.transactions.length} transactions`); // 3 ── decode the receipt: this is where 14 / 15 / 16 come from ------------------------------ const verified = receipt.logs .filter((l) => l.topics[0] === EVENTS.TransactionVerified.topic0) .map((l, i) => ({ logIndex: n(l.logIndex), emitter: l.address, chainKey: n(l.topics[1]), height: n(l.topics[2]), txIndex: n(word(l.data, 0)), order: i, })); if (verified.length !== 3) throw new Error(`expected 3 TransactionVerified logs, receipt has ${verified.length}`); const proven = receipt.logs.find((l) => l.topics[0] === EVENTS.SandwichProven.topic0); if (!proven) throw new Error('receipt carries no SandwichProven log'); const ruling = { logIndex: n(proven.logIndex), emitter: proven.address, searcher: addr(proven.topics[1]), blockHeight: n(proven.topics[2]), frontIndex: n(word(proven.data, 0)), victimIndex: n(word(proven.data, 1)), backIndex: n(word(proven.data, 2)), harm: big(word(proven.data, 3)), paid: big(word(proven.data, 4)), }; const paid = receipt.logs.find((l) => l.topics[0] === EVENTS.HarmPaid.topic0); if (!paid) throw new Error('receipt carries no HarmPaid log'); const harmPaid = { logIndex: n(paid.logIndex), emitter: paid.address, victim: addr(paid.topics[1]), relay: addr(paid.topics[2]), amount: big(word(paid.data, 0)), }; // 4 ── the prover: ask for exactly the positions the CHAIN reported --------------------------- // Note the direction of trust. The indices are not an input we chose; they came out of the // receipt in step 3. Here we go and ask the proof service to justify them. const positions = verified.map((v) => v.txIndex); console.log(` PROVER ${PROVER} — asking for positions ${positions.join(', ')}`); const t0 = Date.now(); const proverRes = await fetch(`${PROVER}/api/v1/proof-batch/${CHAIN_KEY}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify([{ headerNumber: SOURCE_BLOCK, txIndexes: positions }]), signal: AbortSignal.timeout(180_000), }).then(async (r) => { if (r.ok) return r.json(); throw new Error(`proof-batch → HTTP ${r.status}: ${await r.text()}`); }); const proverMs = Date.now() - t0; console.log(` proof-batch answered in ${proverMs}ms · cached=${proverRes.cached}`); if (proverRes.chainKey !== CHAIN_KEY) throw new Error(`prover answered for chainKey ${proverRes.chainKey}`); const byIndex = proverRes.merkleProofs?.[String(SOURCE_BLOCK)]; if (!byIndex) throw new Error(`prover returned no proofs for header ${SOURCE_BLOCK}`); const legs = verified.map((v, i) => { const entry = byIndex[String(v.txIndex)]; if (!entry) throw new Error(`prover returned no merkle proof for position ${v.txIndex}`); const siblings = entry.merkleProof.siblings.map((s) => ({ hash: s.hash, isLeft: s.isLeft })); const laterality = siblings.map((s) => (s.isLeft ? 'L' : 'R')).join(''); // leaf → root, one bit per sibling, least-significant first. L (sibling on the left) means // this node was the RIGHT child, so the bit is 1. let decoded = 0; siblings.forEach((s, bit) => { if (s.isLeft) decoded |= 1 << bit; }); if (decoded !== v.txIndex) { throw new Error(`laterality ${laterality} decodes to ${decoded}, but the chain emitted ${v.txIndex}`); } return { ...ROLES[i], index: v.txIndex, indexSource: 'TransactionVerified log emitted by the Attestcoin precompile', txHash: entry.txHash, txBytes: (entry.txBytes.length - 2) / 2, laterality, bits: siblings.map((s) => (s.isLeft ? 1 : 0)), binaryLeafToRoot: siblings.map((s) => (s.isLeft ? '1' : '0')).join(''), decodedFromLaterality: decoded, merkleRoot: entry.merkleProof.root, siblings, }; }); // ── cross-check the three legs against Ethereum mainnet itself ------------------------------- const mainnetPositions = {}; for (const leg of legs) { const tx = await rpc(ETH_RPC, 'eth_getTransactionByHash', [leg.txHash]); mainnetPositions[leg.txHash] = { index: n(tx.transactionIndex), blockNumber: n(tx.blockNumber) }; if (mainnetPositions[leg.txHash].index !== leg.index) { throw new Error(`mainnet puts ${leg.txHash} at ${mainnetPositions[leg.txHash].index}, chain emitted ${leg.index}`); } console.log(` mainnet agrees: ${leg.txHash.slice(0, 12)}… is at position ${leg.index}`); } const gasUsed = n(receipt.gasUsed); const MAX_GAS_CAP = 75_000_000; const artifact = { $schema: 'index41 proof artifact v1', capturedAt: startedAt, capturedBy: 'scripts/capture-proof.mjs', honesty: { statement: 'Every number in this file was read from a live source at capture time. Nothing here is ' + 'authored. The indices 14 / 15 / 16 are decoded from the Attestcoin precompile’s own ' + 'TransactionVerified logs inside the receipt of a real Creditcoin transaction, and are ' + 'independently re-derived from the laterality of the merkle authentication paths the ' + 'proof service returned. The two agree, and mainnet agrees with both.', topicSelfCheck: topicCheck, sources: { creditcoin: CC3_RPC, ethereum: ETH_RPC, prover: `${PROVER}/api/v1/proof-batch/${CHAIN_KEY}`, }, }, events: EVENTS, network: { name: 'Creditcoin CC3 testnet', chainId: 102031, rpc: CC3_RPC, explorer: 'https://creditcoin-testnet.blockscout.com', }, claim: { txHash: receipt.transactionHash, from: receipt.from, to: receipt.to, blockNumber: n(receipt.blockNumber), blockHash: receipt.blockHash, blockTimestamp: n(cc3Block.timestamp), status: n(receipt.status), gasUsed, gasLimit: n(cc3Tx.gas), maxGasCap: MAX_GAS_CAP, gasPercentOfCap: Number(((gasUsed / MAX_GAS_CAP) * 100).toFixed(3)), logCount: receipt.logs.length, calldataBytes: (cc3Tx.input.length - 2) / 2, }, contract: { index41: receipt.to, verifierPrecompile: verified[0].emitter, evmV1Decoder: '0x731c345d79Fb8BbDC541f9DF3b6317585F849F9f', relay: receipt.from, }, source: { chainKey: CHAIN_KEY, chain: 'Ethereum mainnet', chainId: 1, blockNumber: SOURCE_BLOCK, blockHash: ethBlock.hash, transactionsRoot: ethBlock.transactionsRoot, transactionCount: ethBlock.transactions.length, timestamp: n(ethBlock.timestamp), merkleRoot: legs[0].merkleRoot, merkleDepth: legs[0].siblings.length, continuityRoots: proverRes.continuityProof.roots.length, continuityLowerEndpointDigest: proverRes.continuityProof.lowerEndpointDigest, proverCached: proverRes.cached, proverGeneratedAt: proverRes.generatedAt, proverElapsedMs: proverMs, mainnetPositions, }, legs, ruling, harmPaid, verifiedLogs: verified, }; if (process.argv.includes('--check')) { const committed = JSON.parse(readFileSync(OUT, 'utf8')); const strip = (o) => { const c = structuredClone(o); delete c.capturedAt; delete c.source.proverCached; delete c.source.proverGeneratedAt; delete c.source.proverElapsedMs; return JSON.stringify(c); }; const same = strip(committed) === strip(artifact); console.log(`\n --check: committed artifact ${same ? 'MATCHES' : 'DIFFERS FROM'} a fresh live capture`); process.exit(same ? 0 : 1); } writeFileSync(OUT, JSON.stringify(artifact, null, 2) + '\n'); console.log(`\n ${legs.map((l) => `${l.laterality} → ${l.index}`).join(' ')}`); console.log(` ${ruling.frontIndex} < ${ruling.victimIndex} < ${ruling.backIndex} harm ${ruling.harm} · paid ${ruling.paid}`); console.log(` gas ${gasUsed.toLocaleString('en-US')} (${artifact.claim.gasPercentOfCap}% of MAX_GAS_CAP)`); console.log(`\n written to ${OUT}`); } main().catch((err) => { console.error(`\ncapture-proof failed: ${err.message}`); console.error('Nothing was written. A capture that cannot reach the chain must not produce a file.'); process.exit(1); });