#!/usr/bin/env node /** * regtest-doublespend-sweep.mjs — the topology × first-seen × DSProof sweep. * * PURPOSE (the Karame–Androulaki–Capkun 2012 reproduction, on our terms). * The 2012 paper measured a propagation-race double-spend against 0-conf fast * payments. The open questions we want answered with our OWN hands, not theirs: * * Q1. Does the race need a *malicious modified client*? → NO if it works with * stock BCHN driven only by standard RPC (createrawtransaction / * sendrawtransaction). This harness uses unmodified bitcoind exclusively. * Q2. Does the race need *attacker-controlled topology*? → We run it BOTH ways: * Variant A targets the vendor and the miner separately (KAC topology); * Variant B dumps both conflicting txs into the SAME node (no topology * control). The contrast is the answer. * Q3. Is DSProof the thing that actually protects the merchant? → We record * whether the vendor node receives a Double-Spend Proof for the conflicted * input, i.e. whether a DSProof-aware merchant would have been warned. * * MECHANISM under test (first-seen, no RBF — BCH / whitepaper Bitcoin): * - TRV pays the vendor (B); TRA double-spends the same coin to the attacker (C). * - "first-seen" is LOCAL: each node keeps the first version IT saw and rejects * the conflicting one. So if the VENDOR sees TRV first while the MINER sees * TRA first, the vendor's mempool holds TRV (merchant thinks he's paid) while * the block confirms TRA (attacker keeps the coin). That divergence is the * whole attack — and it is protocol-VALID at every step. DSProof does not * prevent it; it ALERTS the vendor that the input was double-spent. * * HONEST EXPECTATION (so we don't fool ourselves): * - Variant A (targeted) should SUCCEED at the ledger level — vendor holds TRV, * block confirms TRA — which would VINDICATE KAC's mechanism, not debunk it. * - Variant B (same node) should FAIL — first-seen rejects the second tx outright. * - DSProof should fire on the vendor in Variant A → a *waiting, DSProof-aware* * merchant declines; a *naive instant-accept* merchant is fooled. * If A fails too, KAC's real-world reach was thinner than claimed. Either result * is a finding. We report what happens; we don't pre-judge it. * * REQUIREMENTS: Bitcoin Cash Node (BCHN) binaries — bitcoind + bitcoin-cli. * Not installed? See scripts/regtest-doublespend-sweep.README at the bottom of * this file, or run with --help. Point the harness at them via: * BCHN_BIN_DIR=/path/to/bchn/bin node scripts/regtest-doublespend-sweep.mjs * (auto-detected on PATH if present.) * * SAFETY: regtest only. Spawns ephemeral nodes in a temp dir, mines local coins * with no value, and tears everything down at the end. Touches no real network. * * Run: npm run regtest:dsweep (after BCHN is available) */ import { spawn, spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir, platform } from 'node:os'; import { join } from 'node:path'; const EXE = platform() === 'win32' ? '.exe' : ''; const BIN_DIR = process.env.BCHN_BIN_DIR ?? ''; const bin = (name) => (BIN_DIR ? join(BIN_DIR, name + EXE) : name + EXE); const BITCOIND = bin('bitcoind'); const CLI = bin('bitcoin-cli'); const FEE_SATS = 1000n; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const line = (c = '=') => console.log(c.repeat(76)); // ── node definitions: V = vendor (the merchant's node), M = miner (the network). // Distinct rpc/p2p ports so two regtest daemons coexist on one host. const NODES = { V: { name: 'vendor', rpcport: 18510, port: 18511, dir: '', proc: null }, M: { name: 'miner', rpcport: 18520, port: 18521, dir: '', proc: null }, }; const RPC_USER = 'ds'; const RPC_PASS = 'ds'; function preflight() { const r = spawnSync(BITCOIND, ['-version'], { encoding: 'utf8' }); if (r.error || r.status !== 0) { console.error(`\n✗ Cannot run "${BITCOIND}".`); console.error(' This sweep needs Bitcoin Cash Node (BCHN) — bitcoind + bitcoin-cli.'); console.error(' Install options (pick one):'); console.error(' • Native Windows: download the BCHN release zip from'); console.error(' https://bitcoincashnode.org/ , extract, then set'); console.error(' BCHN_BIN_DIR=C:\\path\\to\\bitcoin-cash-node\\bin'); console.error(' • WSL/Linux: install BCHN in WSL and run this script from there'); console.error(' (the bitcoind/bitcoin-cli interface is identical).'); console.error(' • Then: BCHN_BIN_DIR= npm run regtest:dsweep\n'); process.exit(2); } const v = (r.stdout || '').split('\n')[0]; console.log(`Using: ${v}`); } function writeConf(node) { node.dir = mkdtempSync(join(tmpdir(), `dsweep-${node.name}-`)); const conf = [ 'regtest=1', 'server=1', 'listen=1', 'dnsseed=0', 'fixedseeds=0', `rpcuser=${RPC_USER}`, `rpcpassword=${RPC_PASS}`, `fallbackfee=0.00001`, '[regtest]', `rpcport=${node.rpcport}`, `port=${node.port}`, `bind=127.0.0.1:${node.port}`, ].join('\n'); // BCHN reads bitcoin.conf from the datadir. writeFileSync(join(node.dir, 'bitcoin.conf'), conf + '\n'); } function launch(node) { writeConf(node); node.proc = spawn(BITCOIND, [`-datadir=${node.dir}`], { stdio: 'ignore' }); node.proc.on('error', (e) => console.error(`${node.name} spawn error:`, e.message)); } /** bitcoin-cli call against a node; returns trimmed stdout or throws with stderr. */ function cli(node, ...args) { const r = spawnSync( CLI, [`-datadir=${node.dir}`, `-rpcport=${node.rpcport}`, `-rpcuser=${RPC_USER}`, `-rpcpassword=${RPC_PASS}`, ...args], { encoding: 'utf8' }, ); if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'cli failed').trim()); return (r.stdout || '').trim(); } /** Like cli() but returns {ok,value|error} instead of throwing — for the broadcasts. */ function cliTry(node, ...args) { try { return { ok: true, value: cli(node, ...args) }; } catch (e) { return { ok: false, error: e.message }; } } const cliJSON = (node, ...args) => JSON.parse(cli(node, ...args)); async function waitReady(node, timeoutMs = 30000) { const t = Date.now() + timeoutMs; while (Date.now() < t) { const r = cliTry(node, 'getblockchaininfo'); if (r.ok) return; await sleep(500); } throw new Error(`${node.name} RPC not ready within ${timeoutMs}ms`); } /** Connect V and M as a single regtest network (one P2P link). */ function peer() { cli(NODES.V, 'addnode', `127.0.0.1:${NODES.M.port}`, 'onetry'); } async function waitConnected() { const t = Date.now() + 15000; while (Date.now() < t) { const n = Number(cli(NODES.V, 'getconnectioncount')); if (n >= 1) return; await sleep(400); } throw new Error('V never connected to M'); } /** Make a mature spendable coin on M's wallet; return {txid, vout, amount(BTC str)}. */ function freshCoin() { cliTry(NODES.M, 'createwallet', 'w'); // ignore "already exists" const addr = cli(NODES.M, 'getnewaddress'); cli(NODES.M, 'generatetoaddress', '101', addr); // 100-block coinbase maturity + 1 const utxos = cliJSON(NODES.M, 'listunspent', '1'); const u = utxos.find((x) => x.spendable && Number(x.amount) > 1); if (!u) throw new Error('no mature coin on M'); return { txid: u.txid, vout: u.vout, amount: Number(u.amount) }; } /** Build two conflicting signed txs spending `coin` → B (vendor) and → C (attacker). * Uses ONLY standard RPC on stock bitcoind: this is the "no modified client" proof. */ function buildConflict(coin, addrB, addrC) { const out = (coin.amount - Number(FEE_SATS) / 1e8).toFixed(8); const mk = (to) => { const raw = cli(NODES.M, 'createrawtransaction', JSON.stringify([{ txid: coin.txid, vout: coin.vout }]), JSON.stringify({ [to]: out })); const signed = cliJSON(NODES.M, 'signrawtransactionwithwallet', raw); if (!signed.complete) throw new Error('sign incomplete'); return signed.hex; }; const TRV = mk(addrB); // pays the vendor const TRA = mk(addrC); // double-spends to the attacker return { TRV, TRA }; } const txid = (node, hex) => cli(node, 'decoderawtransaction', hex).length ? cliJSON(node, 'decoderawtransaction', hex).txid : ''; /** Did the vendor node receive a DSProof for the conflicted coin? */ function dsproofOnVendor() { const r = cliTry(NODES.V, 'getdsprooflist', '2', 'true'); if (!r.ok) return { supported: false, present: false, note: r.error.slice(0, 80) }; let list; try { list = JSON.parse(r.value); } catch { return { supported: true, present: false }; } return { supported: true, present: Array.isArray(list) && list.length > 0, count: Array.isArray(list) ? list.length : 0 }; } /** One experimental run. `targeted` = Variant A (TRV→V, TRA→M) vs B (both→M). */ async function runVariant(targeted, addrB, addrC) { const label = targeted ? 'A · TARGETED topology (TRV→vendor, TRA→miner)' : 'B · SAME-NODE (both → miner)'; line('-'); console.log(`VARIANT ${label}`); line('-'); const coin = freshCoin(); // sync the funding so V also knows the coin exists before we conflict it await sleep(800); const { TRV, TRA } = buildConflict(coin, addrB, addrC); const idV = txid(NODES.M, TRV); const idA = txid(NODES.M, TRA); console.log(` coin ${coin.txid.slice(0, 16)}…:${coin.vout} (${coin.amount} BCH)`); console.log(` TRV→B ${idV.slice(0, 16)}… TRA→C ${idA.slice(0, 16)}… (same input)`); let rTRV, rTRA; if (targeted) { rTRV = cliTry(NODES.V, 'sendrawtransaction', TRV); // vendor sees TRV first rTRA = cliTry(NODES.M, 'sendrawtransaction', TRA); // miner sees TRA first } else { rTRV = cliTry(NODES.M, 'sendrawtransaction', TRV); rTRA = cliTry(NODES.M, 'sendrawtransaction', TRA); // same node → first-seen should reject } console.log(` send TRV → ${targeted ? 'V' : 'M'}: ${rTRV.ok ? 'ACCEPTED' : 'REJECTED ' + rTRV.error.slice(0, 60)}`); console.log(` send TRA → M: ${rTRA.ok ? 'ACCEPTED' : 'REJECTED ' + rTRA.error.slice(0, 60)}`); await sleep(1500); // let gossip + DSProof propagate const ds = dsproofOnVendor(); const vendorPool = cliJSON(NODES.V, 'getrawmempool'); const vendorHasTRV = vendorPool.includes(idV); // miner confirms its view const mAddr = cli(NODES.M, 'getnewaddress'); cli(NODES.M, 'generatetoaddress', '1', mAddr); await sleep(1000); const confirmed = (id) => { const r = cliTry(NODES.V, 'getrawtransaction', id, 'true'); if (!r.ok) return 0; try { return JSON.parse(r.value).confirmations ?? 0; } catch { return 0; } }; const cTRV = confirmed(idV); const cTRA = confirmed(idA); const merchantFooled = targeted && vendorHasTRV && cTRA >= 1 && cTRV === 0; console.log(''); console.log(` vendor mempool held TRV (merchant would release goods): ${vendorHasTRV ? 'YES' : 'no'}`); console.log(` after 1 block: TRV conf=${cTRV} TRA conf=${cTRA}`); console.log(` DSProof on vendor for the conflict: ${ds.supported ? (ds.present ? `YES (${ds.count}) — a waiting merchant is WARNED` : 'none seen') : 'RPC unsupported: ' + (ds.note || '')}`); if (!targeted) { const firstSeenHeld = rTRV.ok && !rTRA.ok; console.log(` ⇒ result: ${firstSeenHeld ? 'first-seen REJECTED the double-spend (attack fails without topology control)' : rTRV.ok && rTRA.ok ? 'BOTH accepted — first-seen VIOLATED (unexpected!)' : 'inconclusive'}`); } else { console.log(` ⇒ result: ${merchantFooled ? 'naive-merchant FOOLED (TRA confirmed, TRV evaporated) — KAC mechanism reproduced' : 'attack did NOT achieve the divergence'}`); console.log(` a DSProof-aware merchant who waited ~1–2s would have ${ds.present ? 'DECLINED' : 'had no warning'}.`); } return { variant: targeted ? 'A' : 'B', idV, idA, vendorHasTRV, cTRV, cTRA, ds, merchantFooled }; } async function main() { if (process.argv.includes('--help')) { console.log('Topology × first-seen × DSProof double-spend sweep (regtest, stock BCHN).'); console.log('Set BCHN_BIN_DIR to your BCHN bin folder, then: npm run regtest:dsweep'); process.exit(0); } line(); console.log('REGTEST DOUBLE-SPEND SWEEP — KAC 2012 reproduction on stock BCHN'); line(); preflight(); console.log('Launching vendor + miner regtest nodes…'); launch(NODES.V); launch(NODES.M); try { await waitReady(NODES.V); await waitReady(NODES.M); peer(); await waitConnected(); console.log('Nodes up and peered.\n'); const addrB = (cliTry(NODES.V, 'createwallet', 'wv'), cli(NODES.V, 'getnewaddress')); // vendor's payout addr const addrC = cli(NODES.M, 'getnewaddress'); // attacker's addr const results = []; results.push(await runVariant(false, addrB, addrC)); // B: same-node first-seen results.push(await runVariant(true, addrB, addrC)); // A: targeted topology line(); console.log('SWEEP SUMMARY'); for (const r of results) { console.log(` Variant ${r.variant}: TRV=${r.idV.slice(0, 12)}… TRA=${r.idA.slice(0, 12)}… ` + `merchantFooled=${r.merchantFooled} dsproof=${r.ds.present ? 'fired' : (r.ds.supported ? 'none' : 'n/a')}`); } console.log('\n Interpretation:'); console.log(' • B fails + A succeeds ⇒ the race needs TOPOLOGY CONTROL, not a code hack;'); console.log(' first-seen alone stops the naive case; DSProof is what warns the vendor in A.'); console.log(' • A fails too ⇒ even targeted, the race did not divide vendor/miner here.'); line(); } finally { console.log('Tearing down nodes…'); for (const n of Object.values(NODES)) { try { cli(n, 'stop'); } catch { /* ignore */ } } await sleep(1500); for (const n of Object.values(NODES)) { try { n.proc?.kill(); } catch { /* ignore */ } try { if (n.dir) rmSync(n.dir, { recursive: true, force: true }); } catch { /* ignore */ } } } process.exit(0); } main().catch((e) => { console.error('FAILED:', e?.message ?? e); process.exit(1); });