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