deviant/scripts/chipnet-doublespend-test.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

233 lines
11 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
/**
* chipnet-doublespend-test.mjs — EMPIRICAL double-spend test on chipnet.
*
* Settles, with real broadcasts, the claim that BCH (whitepaper / no-RBF /
* first-seen-safe Bitcoin) rejects 0-conf double-spends. We build two
* transactions that spend the SAME outpoint to two DIFFERENT recipients
* (B vs C) — a genuine conflict — and try to get both onto the chain.
*
* TEST 1 — FIRST-SEEN REJECTION (single node).
* Broadcast txA, then conflicting txB, to the SAME server.
* Hypothesis (whitepaper/BCH): the node already holds txA's input in its
* mempool and rejects txB ("txn-mempool-conflict" / inputs already spent).
* A PASS here means the easy/RBF-style double-spend is impossible.
*
* TEST 2 — PROPAGATION RACE (two nodes, simultaneous).
* Broadcast txA to node1 and conflicting txB to node2 at the same instant
* (the Karame 2012 attack). Each node accepts the first version IT sees;
* they then gossip and first-seen decides the winner. We poll both nodes
* for which txid confirms and whether a Double-Spend Proof (DSProof) fires.
* Hypothesis: exactly ONE confirms; the other is permanently rejected.
*
* This does NOT and CANNOT test a Finney / miner-assisted reversal — that needs
* mining control, which chipnet does not give us. See the regtest follow-up.
*
* Run: node --import tsx scripts/chipnet-doublespend-test.mjs
* Env: FEE=500 POLL_SECONDS=240
*
* No audit gate, no covenant — bare P2PKH, built with cashscript's
* TransactionBuilder.build() (signs locally, does NOT broadcast), then pushed
* to specific servers via raw @electrum-cash/network clients.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import {
ElectrumNetworkProvider,
TransactionBuilder,
SignatureTemplate,
SignatureAlgorithm,
} from 'cashscript';
import { ElectrumClient } from '@electrum-cash/network';
import { hashTransactionUiOrder, hexToBin, binToHex } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const FEE = BigInt(process.env.FEE ?? '500');
const POLL_SECONDS = Number(process.env.POLL_SECONDS ?? '240');
// Two DISTINCT chipnet Electrum servers — the race needs two entry points.
const HOST_1 = 'chipnet.bch.ninja';
const HOST_2 = 'chipnet.imaginary.cash';
const line = (c = '=') => console.log(c.repeat(74));
const txidOf = (hex) => binToHex(hashTransactionUiOrder(hexToBin(hex)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Raw electrum-cash request. The client RESOLVES with an Error on RPC failure
* (it does not throw), so normalise into {ok, value|error}. */
async function rpc(client, method, ...params) {
try {
const res = await client.request(method, ...params);
if (res instanceof Error) return { ok: false, error: res.message ?? String(res) };
return { ok: true, value: res };
} catch (err) {
return { ok: false, error: err?.message ?? String(err) };
}
}
async function makeClient(host) {
const c = new ElectrumClient('Deviant-DStest', '1.4.1', host, {
disableBrowserVisibilityHandling: true,
});
await c.connect();
return c;
}
/** Build + locally sign a P2PKH tx spending exactly `input` to `toAddr`, no
* change (fee = input.satoshis - output). Returns signed raw hex. */
async function buildSpend(provider, privateKey, input, toAddr) {
const template = new SignatureTemplate(privateKey, undefined, SignatureAlgorithm.ECDSA);
const out = input.satoshis - FEE;
if (out < 546n) throw new Error(`output ${out} below dust; pick a bigger UTXO`);
const tb = new TransactionBuilder({ provider });
tb.addInput(input, template.unlockP2PKH());
tb.addOutput({ to: toAddr, amount: out });
return tb.build(); // signs locally; does NOT broadcast
}
async function main() {
line();
console.log('CHIPNET DOUBLE-SPEND TEST — first-seen rejection + propagation race');
line();
// ---- keys / addresses ----
const { wif: wifA } = JSON.parse(readFileSync(join(ROOT, '.chipnet-keys.json'), 'utf8'));
const { wif: wifB } = JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-b.json'), 'utf8'));
const { wif: wifC } = JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-c.json'), 'utf8'));
const A = await TestNetWallet.fromWIF(wifA);
const B = await TestNetWallet.fromWIF(wifB);
const C = await TestNetWallet.fromWIF(wifC);
console.log(`Spender A : ${A.cashaddr}`);
console.log(`Target B : ${B.cashaddr}`);
console.log(`Target C : ${C.cashaddr}`);
const provider = new ElectrumNetworkProvider('chipnet');
// ---- pick two distinct clean plain-BCH UTXOs ----
const utxos = await provider.getUtxos(A.cashaddr);
const clean = utxos
.filter((u) => !u.token && u.satoshis >= 5000n && u.satoshis <= 200000n)
.sort((a, b) => Number(a.satoshis - b.satoshis));
if (clean.length < 2) throw new Error('Need >=2 clean plain-BCH UTXOs (5k–200k sats).');
const utxo1 = clean[0];
const utxo2 = clean[1];
console.log(`\nUTXO #1 (Test 1): ${utxo1.txid}:${utxo1.vout} ${utxo1.satoshis} sats`);
console.log(`UTXO #2 (Test 2): ${utxo2.txid}:${utxo2.vout} ${utxo2.satoshis} sats`);
// ---- build the four conflicting txs ----
// Test 1: A1 (utxo1->B) vs B1 (utxo1->C). Test 2: A2 (utxo2->B) vs B2 (utxo2->C).
const hexA1 = await buildSpend(provider, A.privateKey, utxo1, B.cashaddr);
const hexB1 = await buildSpend(provider, A.privateKey, utxo1, C.cashaddr);
const hexA2 = await buildSpend(provider, A.privateKey, utxo2, B.cashaddr);
const hexB2 = await buildSpend(provider, A.privateKey, utxo2, C.cashaddr);
const idA1 = txidOf(hexA1), idB1 = txidOf(hexB1);
const idA2 = txidOf(hexA2), idB2 = txidOf(hexB2);
console.log(`\nTest1 txA1=${idA1}`);
console.log(`Test1 txB1=${idB1} (spends the SAME outpoint as A1)`);
console.log(`Test2 txA2=${idA2}`);
console.log(`Test2 txB2=${idB2} (spends the SAME outpoint as A2)`);
const c1 = await makeClient(HOST_1);
const c2 = await makeClient(HOST_2);
console.log(`\nConnected: node1=${HOST_1} node2=${HOST_2}`);
// =====================================================================
// TEST 1 — first-seen rejection on a single node
// =====================================================================
line('-');
console.log('TEST 1 — FIRST-SEEN REJECTION (both broadcasts to node1)');
line('-');
const r1a = await rpc(c1, 'blockchain.transaction.broadcast', hexA1);
console.log(` broadcast txA1 -> ${r1a.ok ? 'ACCEPTED ' + r1a.value : 'REJECTED ' + r1a.error}`);
const r1b = await rpc(c1, 'blockchain.transaction.broadcast', hexB1);
console.log(` broadcast txB1 -> ${r1b.ok ? 'ACCEPTED ' + r1b.value : 'REJECTED ' + r1b.error}`);
const test1Pass = r1a.ok && !r1b.ok;
console.log('');
if (test1Pass) {
console.log(' ✅ TEST 1 PASS: node accepted txA1 and REJECTED the conflicting txB1.');
console.log(' The same-node 0-conf double-spend is impossible (first-seen-safe).');
} else if (r1a.ok && r1b.ok) {
console.log(' ❌ TEST 1 FAIL: node accepted BOTH conflicting txs — first-seen violated!');
} else {
console.log(' ⚠️ TEST 1 INCONCLUSIVE: txA1 was not accepted; cannot judge first-seen.');
}
// =====================================================================
// TEST 2 — propagation race across two nodes, simultaneous
// =====================================================================
line('-');
console.log('TEST 2 — PROPAGATION RACE (txA2 -> node1, txB2 -> node2, simultaneously)');
line('-');
const [r2a, r2b] = await Promise.all([
rpc(c1, 'blockchain.transaction.broadcast', hexA2),
rpc(c2, 'blockchain.transaction.broadcast', hexB2),
]);
console.log(` node1 <- txA2 : ${r2a.ok ? 'ACCEPTED ' + r2a.value : 'REJECTED ' + r2a.error}`);
console.log(` node2 <- txB2 : ${r2b.ok ? 'ACCEPTED ' + r2b.value : 'REJECTED ' + r2b.error}`);
console.log(' (each node accepts the first version IT sees; first-seen now decides the winner)');
// DSProof probe (not all servers implement it).
for (const [label, client] of [['node1', c1], ['node2', c2]]) {
for (const id of [idA2, idB2]) {
const ds = await rpc(client, 'blockchain.transaction.dsproof.get', id);
if (ds.ok && ds.value) {
console.log(` 🛰️ DSProof present on ${label} for ${id.slice(0, 12)}…: ${JSON.stringify(ds.value).slice(0, 120)}`);
}
}
}
// Poll for a confirmation of either tx on either node.
console.log(`\n Polling up to ${POLL_SECONDS}s for a block to settle the race…`);
const deadline = Date.now() + POLL_SECONDS * 1000;
let winner = null, loser = null;
while (Date.now() < deadline) {
let confirmed = null;
for (const [label, client] of [['node1', c1], ['node2', c2]]) {
for (const [tag, id] of [['A2', idA2], ['B2', idB2]]) {
const g = await rpc(client, 'blockchain.transaction.get', id, true);
if (g.ok && g.value && typeof g.value === 'object' && g.value.confirmations >= 1) {
confirmed = { tag, id, label, conf: g.value.confirmations };
}
}
}
if (confirmed) {
winner = confirmed;
loser = confirmed.tag === 'A2' ? { tag: 'B2', id: idB2 } : { tag: 'A2', id: idA2 };
break;
}
await sleep(15000);
process.stdout.write('.');
}
console.log('');
if (winner) {
// Verify the loser is now gone (rejected / unconfirmed / unknown).
const lg = await rpc(c1, 'blockchain.transaction.get', loser.id, true);
const loserConf = lg.ok && lg.value && typeof lg.value === 'object' ? lg.value.confirmations ?? 0 : 'unknown';
console.log(` ✅ TEST 2 RESULT: tx${winner.tag} (${winner.id.slice(0, 12)}…) CONFIRMED (${winner.conf} conf).`);
console.log(` The conflicting tx${loser.tag} (${loser.id.slice(0, 12)}…) is now ${loserConf === 'unknown' ? 'UNKNOWN to the node (dropped)' : loserConf + ' conf'}.`);
console.log(' Exactly one survived — the 0-conf double-spend did NOT succeed.');
} else {
console.log(` ⚠️ No block within ${POLL_SECONDS}s. Both txs are in mempool; first-seen will`);
console.log(' still resolve to one winner at the next block. Re-run the get() checks later:');
console.log(` txA2=${idA2}`);
console.log(` txB2=${idB2}`);
}
line();
console.log('SUMMARY');
console.log(` Test 1 (single-node first-seen): ${test1Pass ? 'PASS — double-spend rejected' : r1a.ok && r1b.ok ? 'FAIL — both accepted' : 'INCONCLUSIVE'}`);
console.log(` Test 2 (two-node race): ${winner ? 'one tx confirmed, conflict dropped' : 'pending next block'}`);
line();
await c1.disconnect();
await c2.disconnect();
process.exit(0);
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});