deviant/scripts/chipnet-asm-parallel-test.mjs
Local Dev ced2eda48d @
chore: checkpoint in-flight work across domains

Sweep of accumulated uncommitted work on design/dex-p2p-architecture into
one checkpoint (multiple sessions share this tree). Main areas:

- anthemus: covenant-wiring follow-ups (engine, ExchangePanel), AgoraScreen seam
- spv: ServerPool + p2p header transport (pool.ts, core/spv/*), wallet.ts wiring
- walletconnect: responder/useConnect updates + connect-responder-compat test
- wallet: send-tier-a rework + swap.ts, plus send-tier-a unit tests
- asm: chipnet throughput/stress harness (scripts/chipnet-asm-*) + ASM_Native docs
- app UI: screens/* and shell.css touch-ups; sat -> bits copy migration
- docs: new snapshots (INDEX regenerated), Parameters (WalletConnect/SSH/ASM),
  KnowledgeBase, WALLET-COORDINATION changelog

Checkpoint only; not a release. Secrets (Keys/, .chipnet-keys*, .env) remain
gitignored and were verified absent from the staged set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-08-14 02:00:43 +02:00

185 lines
8.7 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-asm-parallel-test.mjs — ASM parallel-broadcast contention experiment.
*
* QUESTION (governance rebuttal, CHIP-2025-03): do N concurrent ASM operations
* INTO THE SAME TARGET collide on shared state, or all succeed?
*
* MECHANISM: the canonical ASM swap (chipnet-asm-swap.mjs) and the mint/claim
* (chipnet-asm-claim.mjs) BOTH take the target's single reservoir minting-NFT
* UTXO as input[0] and re-create it (decremented) at output[0]. That reservoir
* outpoint is therefore the contended shared state for EITHER path.
*
* This harness uses the CLAIM path because it needs only BCH (the funded test
* wallets do not currently hold ASM/ASM2 source FTs). The contended input is
* byte-identical to the swap path, so the contention result transfers. Every op
* builds a claim that spends the SAME fetched reservoir outpoint; all fire via
* Promise.all (true parallel). No retries — a failure is data. Chipnet only.
*
* PHASE=a CONVERGENT: 4 wallets all claim the SAME target (all → ASM2)
* PHASE=b DIVERGENT: 2 wallets → ASM2, 2 wallets → ASM (two reservoirs)
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import {
Contract,
ElectrumNetworkProvider,
TransactionBuilder,
SignatureTemplate,
} from 'cashscript';
const ROOT = join(import.meta.dirname, '..');
const ARTIFACT_PATH = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const PHASE = (process.env.PHASE ?? 'a').toLowerCase();
const KEYS = {
a: join(ROOT, '.chipnet-keys.json'),
b: join(ROOT, '.chipnet-keys-b.json'),
c: join(ROOT, '.chipnet-keys-c.json'),
d: join(ROOT, '.chipnet-keys-d.json'),
e: join(ROOT, '.chipnet-keys-e.json'),
};
const TOKEN_FILES = {
asm: join(ROOT, 'ChipnetTests', '.chipnet-asm-token.json'),
asm2: join(ROOT, 'ChipnetTests', '.chipnet-asm2-token.json'),
};
// Claim amounts chosen so the founder fee clears the 546-sat dust floor:
// asm2: rate 1000, 100bps → fee = amount×10 ≥546 ⇒ amount ≥ 55
// asm: rate 100, 100bps → fee = amount×1 ≥546 ⇒ amount ≥ 546
const AMOUNTS = { asm2: 60n, asm: 600n };
const PHASES = {
a: [ { w: 'a', t: 'asm2' }, { w: 'c', t: 'asm2' }, { w: 'd', t: 'asm2' }, { w: 'e', t: 'asm2' } ],
b: [ { w: 'a', t: 'asm2' }, { w: 'c', t: 'asm2' }, { w: 'd', t: 'asm' }, { w: 'e', t: 'asm' } ],
};
const OPS = PHASES[PHASE];
if (!OPS) throw new Error(`PHASE must be a or b, got ${PHASE}`);
function hexToBin(hex) {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
function txidToConsensusBytes(txidHex) {
const d = hexToBin(txidHex);
const out = new Uint8Array(d.length);
for (let i = 0; i < d.length; i++) out[i] = d[d.length - 1 - i];
return out;
}
function p2pkhLockingBytecode(pkh) {
const out = new Uint8Array(25);
out[0] = 0x76; out[1] = 0xa9; out[2] = 0x14; out.set(pkh, 3); out[23] = 0x88; out[24] = 0xac;
return out;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const provider = new ElectrumNetworkProvider('chipnet');
// Chipnet tip at start.
let tip = 0;
try { tip = (await provider.performRequest('blockchain.headers.subscribe'))?.height ?? 0; } catch { /* best effort */ }
// Load the token(s) this phase touches + their reservoirs (fetched ONCE, so
// every op targeting a token spends the SAME reservoir outpoint = contention).
const targets = [...new Set(OPS.map((o) => o.t))];
const tok = {}, contract = {}, reservoir = {};
for (const t of targets) {
tok[t] = JSON.parse(readFileSync(TOKEN_FILES[t], 'utf8'));
contract[t] = new Contract(
artifact,
[txidToConsensusBytes(tok[t].categoryId), BigInt(tok[t].rateSats), BigInt(tok[t].founderFeeBps), hexToBin(tok[t].founderLockingBytecodeHex)],
{ provider },
);
const utxos = await provider.getUtxos(contract[t].tokenAddress);
reservoir[t] = utxos.find((u) => u.token?.nft?.capability === 'minting' && u.token?.category === tok[t].categoryId && u.token?.amount > 0n);
if (!reservoir[t]) throw new Error(`${t} reservoir not found`);
console.log(` reservoir[${t}] outpoint = ${reservoir[t].txid}:${reservoir[t].vout} (FT ${reservoir[t].token.amount})`);
}
// Build one claim tx per op, all spending their target's shared reservoir.
const built = [];
for (const op of OPS) {
const { wif } = JSON.parse(readFileSync(KEYS[op.w], 'utf8'));
const wallet = await TestNetWallet.fromWIF(wif);
const sig = new SignatureTemplate(wallet.privateKey);
const t = tok[op.t];
const amount = AMOUNTS[op.t];
const rate = BigInt(t.rateSats);
const gross = amount * rate;
const fee = (gross * BigInt(t.founderFeeBps)) / 10_000n;
const minerFee = 3000n;
const need = gross + fee + minerFee + 1000n;
const utxos = await provider.getUtxos(wallet.cashaddr);
const bch = utxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis));
const picked = []; let tot = 0n;
for (const u of bch) { picked.push(u); tot += BigInt(u.satoshis); if (tot >= need) break; }
if (tot < need) throw new Error(`Wallet ${op.w.toUpperCase()} BCH ${tot} < ${need}`);
const res = reservoir[op.t];
const change = BigInt(res.satoshis) + tot - 1000n - gross - fee - minerFee;
const recipLB = p2pkhLockingBytecode(wallet.publicKeyHash);
const tb = new TransactionBuilder({ provider });
tb.addInput(res, contract[op.t].unlock.claim(amount, recipLB));
for (const u of picked) tb.addInput(u, sig.unlockP2PKH());
tb.addOutput({ to: contract[op.t].tokenAddress, amount: 1000n, token: { amount: res.token.amount - amount, category: t.categoryId, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: wallet.tokenaddr, amount: gross, token: { amount, category: t.categoryId } });
tb.addOutput({ to: t.founderAddress, amount: fee });
tb.addOutput({ to: wallet.cashaddr, amount: change });
built.push({ label: op.w.toUpperCase(), dir: `BCH→${op.t.toUpperCase()}`, outpoint: `${res.txid}:${res.vout}`, tb });
}
// ── PARALLEL BROADCAST (Promise.all) ────────────────────────────────────────
const batchISO = new Date().toISOString();
const t0 = Date.now();
console.log(`\nBatch broadcast @ ${batchISO} (chipnet tip ${tip})\n`);
const results = await Promise.all(built.map(async (b) => {
const started = Date.now() - t0;
try {
const sent = await b.tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
return { ...b, started, txid, error: null };
} catch (e) {
return { ...b, started, txid: null, error: (e?.message ?? String(e)).slice(0, 300) };
}
}));
// ── STATUS POLL (5s: mempool; ~30s: block) ─────────────────────────────────
await sleep(5000);
for (const r of results) {
r.at5s = 'n/a';
if (r.txid) {
try { await provider.performRequest('blockchain.transaction.get', r.txid, true); r.at5s = 'in-mempool'; }
catch { r.at5s = 'not-found'; }
} else { r.at5s = 'rejected-at-broadcast'; }
}
await sleep(28000);
for (const r of results) {
r.block = '—';
if (r.txid) {
try { const m = await provider.performRequest('blockchain.transaction.get_merkle', r.txid); r.block = m?.block_height ?? 'confirmed'; }
catch { r.block = 'still-mempool'; }
}
}
// ── REPORT ──────────────────────────────────────────────────────────────────
console.log(`\n=== PHASE ${PHASE.toUpperCase()} results (claim path — same contended reservoir as swap) ===`);
console.log(`Batch timestamp: ${batchISO}`);
console.log(`Chipnet tip at start: ${tip}`);
console.log(`\nWallet | Dir | Δms | reservoir outpoint | TXID or error | 5s | block`);
for (const r of results) {
console.log(`${r.label} | ${r.dir} | +${r.started} | ${r.outpoint} | ${r.txid ?? 'ERR: ' + r.error} | ${r.at5s} | ${r.block}`);
}
const accepted = results.filter((r) => r.txid).length;
const rejected = results.filter((r) => !r.txid).length;
console.log(`\nAccepted: ${accepted} Rejected: ${rejected} (of ${results.length})`);
console.log('Rejection reasons:');
for (const r of results.filter((x) => !x.txid)) console.log(` ${r.label}: ${r.error}`);
}
main().then(() => process.exit(0)).catch((err) => { console.error('HARNESS ERROR:', err?.message ?? err); if (err?.stack) console.error(err.stack); process.exit(1); });