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