#!/usr/bin/env node /** * chipnet-asm-batch.mjs — Wallet C batches N small ASM transfers in one tx. * * Env vars: * BATCH=100 (number of output transfers; default 100) * PER=7 (ASM per output; default 7 — the dust minimum at rate 100) * TO=a|b|c (recipient for all outputs; default 'a') * * Demonstrates the "100 transactions in one tx fee" property. Each of * the BATCH outputs is a complete CashTokens FT transfer that, if sent * individually, would consume one tx fee each (BATCH × ~2K = ~200K sats * for 100). Bundled into a single transaction, the entire batch shares * one miner fee (~8K sats for a 100-output tx, plus the per-output dust * which is the same in either case). * * Net saving for 100 outputs: ~190K sats in miner fees not paid. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { TestNetWallet } from 'mainnet-js'; import { ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate, } from 'cashscript'; const ROOT = join(import.meta.dirname, '..'); const ASM_FILE = join(ROOT, 'ChipnetTests', '.chipnet-asm-token.json'); const KEYS_C = join(ROOT, '.chipnet-keys-c.json'); const BATCH = parseInt(process.env.BATCH ?? '100', 10); const PER = BigInt(process.env.PER ?? '7'); const TO = (process.env.TO ?? 'a').toLowerCase(); const KEYS = { a: join(ROOT, '.chipnet-keys.json'), b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json'), }; if (!KEYS[TO]) throw new Error(`TO must be a/b/c`); function divider() { console.log('='.repeat(72)); } async function main() { const asm = JSON.parse(readFileSync(ASM_FILE, 'utf8')); const { wif } = JSON.parse(readFileSync(KEYS_C, 'utf8')); const { tokenaddr: toTokenaddr } = JSON.parse(readFileSync(KEYS[TO], 'utf8')); const sender = await TestNetWallet.fromWIF(wif); const provider = new ElectrumNetworkProvider('chipnet'); const sigTemplate = new SignatureTemplate(sender.privateKey); const rateSats = BigInt(asm.rateSats); const perOutputBacking = PER * rateSats; if (perOutputBacking < 700n) { throw new Error(`PER × rate (${perOutputBacking} sats) below CashTokens dust ~675`); } const totalAsmSent = BigInt(BATCH) * PER; const totalSendBacking = BigInt(BATCH) * perOutputBacking; // Source FT UTXO must have enough ASM. const senderUtxos = await provider.getUtxos(sender.cashaddr); const ftUtxos = senderUtxos.filter( (u) => u.token?.category === asm.categoryId && !u.token?.nft && u.token?.amount > 0n, ); ftUtxos.sort((a, b) => Number(b.token.amount - a.token.amount)); const sourceFt = ftUtxos.find((u) => u.token.amount >= totalAsmSent); if (!sourceFt) { throw new Error(`Wallet C has no ASM UTXO with ≥ ${totalAsmSent} ASM. Claim more first.`); } const bchUtxos = senderUtxos.filter((u) => !u.token).sort((a, b) => Number(b.satoshis) - Number(a.satoshis)); if (bchUtxos.length === 0) throw new Error('Wallet C has no plain BCH UTXO for fee'); const feeInput = bchUtxos[0]; // Miner fee estimate: each output ~40 bytes (P2PKH locking + token bitfield + amount). // Each P2PKH input ~150 bytes. Tx header ~10. So for BATCH+1 outputs + 1 change FT // output + 1 BCH change output + 2 inputs: // ~(BATCH + 3) * 40 + 2 * 150 + 10 ≈ BATCH*40 + 430 // For BATCH=100: ~4430 bytes. At 1.5 sat/byte safety margin: ~6700 sats. const txByteEstimate = BigInt(BATCH) * 40n + 600n; const minerFee = txByteEstimate * 2n; // 2 sats/byte safety const sourceAmount = sourceFt.token.amount; const ftChangeAmount = sourceAmount - totalAsmSent; const ftChangeBacking = ftChangeAmount * rateSats; const bchAvailable = BigInt(sourceFt.satoshis) + BigInt(feeInput.satoshis); const bchUsed = totalSendBacking + (ftChangeAmount > 0n ? ftChangeBacking : 0n) + minerFee; const bchChange = bchAvailable - bchUsed; if (bchChange < 546n) { throw new Error( `BCH change ${bchChange} below dust. avail=${bchAvailable} used=${bchUsed}`, ); } divider(); console.log(`Wallet C batches ${BATCH} ASM transfers in ONE tx`); divider(); console.log(` Source FT: ${sourceFt.txid.slice(0, 16)}:${sourceFt.vout} (${sourceAmount} ASM)`); console.log(` Fee BCH input: ${feeInput.txid.slice(0, 16)}:${feeInput.vout} (${feeInput.satoshis} sat)`); console.log(` Per-output payload: ${PER} ASM + ${perOutputBacking} sat backing`); console.log(` Outputs in batch: ${BATCH}`); console.log(` Total ASM sent: ${totalAsmSent}`); console.log(` All to recipient: ${toTokenaddr.slice(0, 30)}...`); console.log(` Estimated tx bytes: ${txByteEstimate}`); console.log(` Miner fee: ${minerFee} sat (single fee for ${BATCH} transfers)`); console.log(` Equivalent N-tx fee: ${BigInt(BATCH) * 2_000n} sat (if sent individually)`); console.log(` Saving: ~${BigInt(BATCH) * 2_000n - minerFee} sat`); console.log(` FT change to C: ${ftChangeAmount} ASM`); console.log(` BCH change to C: ${bchChange} sat`); divider(); const tb = new TransactionBuilder({ provider }); tb.addInput(sourceFt, sigTemplate.unlockP2PKH()); tb.addInput(feeInput, sigTemplate.unlockP2PKH()); for (let i = 0; i < BATCH; i++) { tb.addOutput({ to: toTokenaddr, amount: perOutputBacking, token: { amount: PER, category: asm.categoryId }, }); } if (ftChangeAmount > 0n) { tb.addOutput({ to: sender.tokenaddr, amount: ftChangeBacking, token: { amount: ftChangeAmount, category: asm.categoryId }, }); } tb.addOutput({ to: sender.cashaddr, amount: bchChange }); const sent = await tb.send(); const txid = typeof sent === 'string' ? sent : sent?.txid ?? sent?.txId; console.log(''); console.log(`Batch TXID: ${txid}`); console.log(`Verify on chipnet: each of ${BATCH} recipients now holds one ${PER}-ASM UTXO.`); } main().catch((err) => { console.error('FAILED:', err?.message ?? err); if (err?.stack) console.error(err.stack); process.exit(1); });