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> @
185 lines
8.7 KiB
JavaScript
185 lines
8.7 KiB
JavaScript
#!/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); });
|