deviant/scripts/chipnet-asm-bulk-swap.mjs
walerikus 599202a721 chore: checkpoint working tree — shell Tier-B retirement + accumulated WIP
Snapshot of accumulated work-in-progress on top of the baseline commit.

This session — retire Tier B from the wallet shell (single-tier model, glossary
"Tiers & trust"):
- SendFlow.tsx: BCH-only; removed the non-functional Tier-B send path, the D-4/D-5
  raw-BCH co-display, and the ADR-0013 friction ladder.
- ArtemisionScreen.tsx: Create wizard collapsed to a single 5-step on-chain genesis
  flow; tier-selection step + Tier-B branch (prepareTierBImport) deleted.
- QuickActions.tsx / SettingsScreen.tsx: receive note reworded; "Send friction
  thresholds" + "Tier explainer" rows removed; registry-trust note reworded.
- Verified: tsc clean, vitest 74/74, full preview walkthrough, no console errors.

Pre-existing uncommitted work also folded in:
- USDm -> FIATm family rename/reorg (Parameters/FIATm/, design records)
- ~130 chipnet + sim-fiatm scripts (catalytic-converter, hyperinflation backtests)
- snapshots 0153-0168; wiki updates (glossary, decisions, index); ADR-0044/0045
- mockups + additional shell screens (Agora, Apps, MainScreen, AssetDetail, ...)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:01:37 +02:00

393 lines
16 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-bulk-swap.mjs — execute many ASM↔ASM2 swaps in a loop.
*
* Env vars:
* FROM=a|c which wallet executes
* TARGET_SWAPS=100 how many swaps to attempt
* FORWARD_Y=60 target amount per ASM→ASM2 swap (smallest legal)
* REVERSE_X=1000 target amount per ASM2→ASM swap (smallest clean)
* RECHARGE=true if running low on ASM2, claim more from B's contract
*
* Strategy: alternate ASM→ASM2 then ASM2→ASM. When either direction
* runs out of inventory, try the opposite. When ASM2 is too low to
* keep going AND RECHARGE=true, claim a small ASM2 batch from B's
* contract to replenish. Stop when neither direction can proceed and
* recharge is exhausted or disabled.
*
* Each iteration refetches wallet UTXOs so that the chain state from
* the previous broadcast (still in mempool) is visible to the next
* swap. Chipnet electrum reflects mempool immediately.
*/
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 ASM_FILE = join(ROOT, 'ChipnetTests', '.chipnet-asm-token.json');
const ASM2_FILE = join(ROOT, 'ChipnetTests', '.chipnet-asm2-token.json');
const FROM = (process.env.FROM ?? 'a').toLowerCase();
const TARGET_SWAPS = parseInt(process.env.TARGET_SWAPS ?? '100', 10);
const FORWARD_Y = BigInt(process.env.FORWARD_Y ?? '60'); // ASM→ASM2 target
const REVERSE_X = BigInt(process.env.REVERSE_X ?? '1000'); // ASM2→ASM target
const RECHARGE = (process.env.RECHARGE ?? 'true') !== 'false';
const RECHARGE_AMOUNT = BigInt(process.env.RECHARGE_AMOUNT ?? '120'); // ASM2 per recharge
const KEYS = {
a: join(ROOT, '.chipnet-keys.json'),
c: join(ROOT, '.chipnet-keys-c.json'),
};
if (!KEYS[FROM]) throw new Error(`FROM must be a or c`);
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 display = hexToBin(txidHex);
const out = new Uint8Array(display.length);
for (let i = 0; i < display.length; i++) out[i] = display[display.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 asmTok = JSON.parse(readFileSync(ASM_FILE, 'utf8'));
const asm2Tok = JSON.parse(readFileSync(ASM2_FILE, 'utf8'));
const artifact = JSON.parse(readFileSync(ARTIFACT_PATH, 'utf8'));
const { wif } = JSON.parse(readFileSync(KEYS[FROM], 'utf8'));
const provider = new ElectrumNetworkProvider('chipnet');
async function makeContract(tokInfo) {
return new Contract(
artifact,
[
txidToConsensusBytes(tokInfo.categoryId),
BigInt(tokInfo.rateSats),
BigInt(tokInfo.founderFeeBps),
hexToBin(tokInfo.founderLockingBytecodeHex),
],
{ provider },
);
}
async function fetchState(wallet) {
const utxos = await provider.getUtxos(wallet.cashaddr);
const asmFt = utxos.filter((u) => u.token?.category === asmTok.categoryId && !u.token?.nft && u.token?.amount > 0n);
const asm2Ft = utxos.filter((u) => u.token?.category === asm2Tok.categoryId && !u.token?.nft && u.token?.amount > 0n);
const bch = utxos.filter((u) => !u.token);
asmFt.sort((a, b) => Number(b.token.amount - a.token.amount));
asm2Ft.sort((a, b) => Number(b.token.amount - a.token.amount));
bch.sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis)));
const sumFt = (arr) => arr.reduce((s, u) => s + u.token.amount, 0n);
const sumSats = (arr) => arr.reduce((s, u) => s + BigInt(u.satoshis), 0n);
return {
asmFt, asm2Ft, bch,
totalAsm: sumFt(asmFt),
totalAsm2: sumFt(asm2Ft),
totalBch: sumSats(bch),
};
}
async function pickFtUtxos(pool, neededAmount) {
const picked = [];
let total = 0n;
let satsTotal = 0n;
for (const u of pool) {
picked.push(u);
total += u.token.amount;
satsTotal += BigInt(u.satoshis);
if (total >= neededAmount) break;
}
return { picked, total, satsTotal };
}
async function executeSwap(wallet, sigTemplate, sourceTok, targetTok, sourceUtxos, sourceTotalFt, sourceTotalSats, targetAmount, feeInput, label) {
const sourceRate = BigInt(sourceTok.rateSats);
const targetRate = BigInt(targetTok.rateSats);
const targetFeeBps = BigInt(targetTok.founderFeeBps);
const targetBacking = targetAmount * targetRate;
const targetFee = (targetBacking * targetFeeBps) / 10_000n;
if (targetFeeBps > 0n && targetFee < 546n) throw new Error('target fee below dust');
const totalTargetSats = targetBacking + targetFee;
if (totalTargetSats % sourceRate !== 0n) throw new Error('inexact swap');
const sourceAmount = totalTargetSats / sourceRate;
// Pick source FT UTXOs covering sourceAmount.
const { picked: sourceFts, total: sourcePickedFt, satsTotal: sourcePickedSats } = await pickFtUtxos(sourceUtxos, sourceAmount);
if (sourcePickedFt < sourceAmount) throw new Error(`source FT inventory ${sourcePickedFt} < ${sourceAmount}`);
const contract = await makeContract(targetTok);
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
const reservoir = reservoirUtxos.find(
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === targetTok.categoryId && u.token?.amount > 0n,
);
if (!reservoir) throw new Error('reservoir missing');
// If the would-be source remainder is below the CashTokens dust min,
// burn the whole picked source instead of trying to preserve it.
// The extra BCH released flows into the BCH change output.
let sourceRemainder = sourcePickedFt - sourceAmount;
let sourceRemainderBacking = sourceRemainder * sourceRate;
const CT_DUST_MIN = 651n;
if (sourceRemainder > 0n && sourceRemainderBacking < CT_DUST_MIN) {
sourceRemainder = 0n;
sourceRemainderBacking = 0n;
}
const recipientLB = p2pkhLockingBytecode(wallet.publicKeyHash);
const minerFee = 4000n + BigInt(sourceFts.length) * 500n;
const inputTotal = BigInt(reservoir.satoshis) + sourcePickedSats + BigInt(feeInput.satoshis);
const outputsFixed = 1000n + targetBacking + targetFee + (sourceRemainder > 0n ? sourceRemainderBacking : 0n);
const change = inputTotal - outputsFixed - minerFee;
if (change < 546n) throw new Error(`change ${change} < dust`);
const reservoirRemaining = reservoir.token.amount - targetAmount;
const tb = new TransactionBuilder({ provider, allowImplicitFungibleTokenBurn: true });
tb.addInput(reservoir, contract.unlock.claim(targetAmount, recipientLB));
for (const u of sourceFts) tb.addInput(u, sigTemplate.unlockP2PKH());
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
tb.addOutput({
to: contract.tokenAddress,
amount: 1000n,
token: {
amount: reservoirRemaining,
category: targetTok.categoryId,
nft: { capability: 'minting', commitment: '' },
},
});
tb.addOutput({
to: wallet.tokenaddr,
amount: targetBacking,
token: { amount: targetAmount, category: targetTok.categoryId },
});
tb.addOutput({ to: targetTok.founderAddress, amount: targetFee });
if (sourceRemainder > 0n) {
tb.addOutput({
to: wallet.tokenaddr,
amount: sourceRemainderBacking,
token: { amount: sourceRemainder, category: sourceTok.categoryId },
});
}
tb.addOutput({ to: wallet.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
return { txid, sourceAmount, targetAmount, targetFee, minerFee };
}
async function executeClaim(wallet, sigTemplate, tokInfo, amount, feeInput) {
const contract = await makeContract(tokInfo);
const reservoirUtxos = await provider.getUtxos(contract.tokenAddress);
const reservoir = reservoirUtxos.find(
(u) => u.token?.nft?.capability === 'minting' && u.token?.category === tokInfo.categoryId,
);
if (!reservoir) throw new Error('reservoir missing');
const rate = BigInt(tokInfo.rateSats);
const grossSats = amount * rate;
const feeSats = (grossSats * BigInt(tokInfo.founderFeeBps)) / 10_000n;
const recipientLB = p2pkhLockingBytecode(wallet.publicKeyHash);
const minerFee = 3000n;
const inputTotal = BigInt(reservoir.satoshis) + BigInt(feeInput.satoshis);
const change = inputTotal - 1000n - grossSats - feeSats - minerFee;
if (change < 546n) throw new Error(`claim change ${change} < dust`);
const reservoirRemaining = reservoir.token.amount - amount;
const tb = new TransactionBuilder({ provider });
tb.addInput(reservoir, contract.unlock.claim(amount, recipientLB));
tb.addInput(feeInput, sigTemplate.unlockP2PKH());
tb.addOutput({
to: contract.tokenAddress,
amount: 1000n,
token: {
amount: reservoirRemaining,
category: tokInfo.categoryId,
nft: { capability: 'minting', commitment: '' },
},
});
tb.addOutput({
to: wallet.tokenaddr,
amount: grossSats,
token: { amount, category: tokInfo.categoryId },
});
tb.addOutput({ to: tokInfo.founderAddress, amount: feeSats });
tb.addOutput({ to: wallet.cashaddr, amount: change });
const sent = await tb.send();
const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId;
return { txid, amount, feeSats, minerFee };
}
async function main() {
const wallet = await TestNetWallet.fromWIF(wif);
const sigTemplate = new SignatureTemplate(wallet.privateKey);
console.log(`Wallet ${FROM.toUpperCase()} bulk swap`);
console.log(` target swaps: ${TARGET_SWAPS}`);
console.log(` FORWARD: ${FORWARD_Y} ASM2 per ASM→ASM2`);
console.log(` REVERSE: ${REVERSE_X} ASM per ASM2→ASM`);
console.log(` recharge enabled: ${RECHARGE}`);
console.log('-'.repeat(72));
// Forward and reverse source-amount requirements:
// ASM → ASM2: N × R_s = Y × R_t × (10000+f)/10000 → N = Y × 1000 × 10100 / (10000 × 100) = Y × 10.1
// ASM2 → ASM: same shape, swap rates → N_in_ASM2 = X × 100 × 10100 / (10000 × 1000) = X × 0.101
const FWD_SOURCE_ASM = FORWARD_Y * 101n / 10n; // For Y=60: 606 ASM
const REV_SOURCE_ASM2 = REVERSE_X * 101n / 1000n; // For X=1000: 101 ASM2
let count = 0;
let forwardCount = 0;
let reverseCount = 0;
let rechargeCount = 0;
let totalFeesPaid = 0n;
let totalMinerFees = 0n;
let consecutiveBlocked = 0;
let networkRetries = 0;
const MAX_NETWORK_RETRIES = 5;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const isNetworkErr = (err) => {
const m = (err?.message ?? String(err)).toLowerCase();
return m.includes('connection lost') || m.includes('enotfound') || m.includes('econnreset') || m.includes('timeout');
};
while (count < TARGET_SWAPS) {
let state;
try {
state = await fetchState(wallet);
networkRetries = 0;
} catch (err) {
if (isNetworkErr(err)) {
networkRetries++;
if (networkRetries > MAX_NETWORK_RETRIES) {
console.log(` STOPPED: ${MAX_NETWORK_RETRIES} consecutive network failures while fetching state. Last err: ${err?.message ?? err}`);
break;
}
const backoff = 2000 * networkRetries;
console.log(` network err (fetchState attempt ${networkRetries}/${MAX_NETWORK_RETRIES}): ${err?.message ?? err}. Sleeping ${backoff}ms.`);
await sleep(backoff);
continue;
}
throw err;
}
const i = count + 1;
// Decide direction. Alternate when possible; otherwise pick whichever fits.
const canForward = state.totalAsm >= FWD_SOURCE_ASM;
const canReverse = state.totalAsm2 >= REV_SOURCE_ASM2;
const prefer = (i % 2 === 1) ? 'forward' : 'reverse';
let direction = null;
if (prefer === 'forward' && canForward) direction = 'forward';
else if (prefer === 'reverse' && canReverse) direction = 'reverse';
else if (canForward) direction = 'forward';
else if (canReverse) direction = 'reverse';
if (!direction) {
// Both blocked. Try recharge.
if (RECHARGE && state.totalBch >= 70_000n && rechargeCount < 50) {
try {
const feeInputs = state.bch.find((u) => BigInt(u.satoshis) >= 70_000n);
if (!feeInputs) throw new Error('no BCH UTXO ≥ 70K for recharge');
const r = await executeClaim(wallet, sigTemplate, asm2Tok, RECHARGE_AMOUNT, feeInputs);
rechargeCount++;
totalFeesPaid += r.feeSats;
totalMinerFees += r.minerFee;
console.log(` [recharge ${rechargeCount}] claimed ${RECHARGE_AMOUNT} ASM2 (fee ${r.feeSats}, miner ${r.minerFee}) tx=${r.txid.slice(0,10)}…`);
consecutiveBlocked = 0;
continue;
} catch (err) {
console.log(` recharge failed: ${err?.message ?? err}`);
consecutiveBlocked++;
if (consecutiveBlocked > 3) break;
continue;
}
}
console.log(` STOPPED: both directions blocked, recharge unavailable. Final state: ASM=${state.totalAsm} ASM2=${state.totalAsm2} BCH=${state.totalBch}`);
break;
}
const feeInput = state.bch[0];
if (!feeInput || BigInt(feeInput.satoshis) < 10_000n) {
console.log(` STOPPED: no BCH UTXO ≥10K available for miner fee`);
break;
}
try {
let result;
if (direction === 'forward') {
result = await executeSwap(
wallet, sigTemplate, asmTok, asm2Tok,
state.asmFt, state.totalAsm, undefined, FORWARD_Y, feeInput, 'forward',
);
forwardCount++;
} else {
result = await executeSwap(
wallet, sigTemplate, asm2Tok, asmTok,
state.asm2Ft, state.totalAsm2, undefined, REVERSE_X, feeInput, 'reverse',
);
reverseCount++;
}
count++;
totalFeesPaid += result.targetFee;
totalMinerFees += result.minerFee;
console.log(
` [${count}/${TARGET_SWAPS} ${direction.padEnd(7)}] ` +
`${result.sourceAmount} ${direction === 'forward' ? 'ASM' : 'ASM2'} → ${result.targetAmount} ${direction === 'forward' ? 'ASM2' : 'ASM'} ` +
`fee=${result.targetFee} miner=${result.minerFee} tx=${result.txid.slice(0, 10)}…`,
);
consecutiveBlocked = 0;
} catch (err) {
if (isNetworkErr(err)) {
networkRetries++;
if (networkRetries > MAX_NETWORK_RETRIES) {
console.log(` STOPPED: ${MAX_NETWORK_RETRIES} consecutive network failures. Last err: ${err?.message ?? err}`);
break;
}
const backoff = 2000 * networkRetries;
console.log(` network err (swap ${i}, retry ${networkRetries}/${MAX_NETWORK_RETRIES}): ${err?.message ?? err}. Sleeping ${backoff}ms.`);
await sleep(backoff);
continue;
}
console.log(` swap ${i} (${direction}) FAILED: ${err?.message ?? err}`);
consecutiveBlocked++;
if (consecutiveBlocked > 3) {
console.log(` STOPPED: too many consecutive failures`);
break;
}
}
}
console.log('-'.repeat(72));
console.log(`Wallet ${FROM.toUpperCase()} bulk swap summary:`);
console.log(` swaps executed: ${count} (${forwardCount} forward, ${reverseCount} reverse)`);
console.log(` recharges: ${rechargeCount}`);
console.log(` total fees to B: ${totalFeesPaid} sats`);
console.log(` total miner fees: ${totalMinerFees} sats`);
const finalState = await fetchState(wallet);
console.log(` final ASM: ${finalState.totalAsm}`);
console.log(` final ASM2: ${finalState.totalAsm2}`);
console.log(` final BCH: ${finalState.totalBch}`);
}
main().catch((err) => {
console.error('FAILED:', err?.message ?? err);
if (err?.stack) console.error(err.stack);
process.exit(1);
});