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>
309 lines
14 KiB
JavaScript
309 lines
14 KiB
JavaScript
#!/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); });
|