deviant/scripts/regtest-doublespend-sweep.mjs
walerikus 518a3ce174 chore: initial project baseline — full Deviant codebase under version control
Brings the whole project into git (previously only the on-ramp feature and
a few parallel files were tracked). Includes: wallet core (src/), CashScript
contracts/, the Capacitor shell (shells/), scripts/, tests, and the docs
corpus (wiki/, snapshots/, Parameters/, Archive/, KnowledgeBase text).

Excluded via .gitignore: node_modules/, dist/, .env (secrets), testnet WIF
key files (.chipnet-*.json, devWallets.ts), KnowledgeBase/RAW media (~9.6 GB),
transcripts/ (session logs), and machine-local config (.claude/*.local,
*.lock). Secret-scanned before commit — no credentials in the tree.

Note: src/swapMath.ts, src/swapBuilder.ts, contracts/OpenMintToken.cash are
legacy (OP_RETURN era) but still imported; committed as-is, flagged for a
separate removal once de-referenced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:38:07 +02:00

309 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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=<bin> 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); });