From 1134024d63dfd89fee9eccca580373a1ea229d2c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 27 Sep 2026 16:05:55 +0200 Subject: [PATCH] =?UTF-8?q?fix(consolidate):=20batch=20large=20sources=20i?= =?UTF-8?q?nto=20=E2=89=A4400-input=20tx=20chunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet a on chipnet had 9,359 dust UTXOs (600-bit fee-signature outputs from throughput runs). A single tx over that whole set is ~1.4 MB — well above mempool tx-size limits, so the source would fail hard rather than consolidate. Add --batch-inputs=N (default 400, matches chipnet-wallet-sweep.mjs) to split any source with too many inputs into a sequence of self-contained txs, each ≤ ~60 KB. When the source has FT/NFT specials (passthrough mode), the LAST batch carries them + enough plain BCH for its fee; earlier batches are pure plain-BCH consolidates. Also fix a latent bug in the batching logic: when a source had NO FT/NFT specials (redeem-mba or plain-only source), `batches` came out empty because the `specials.length ? ... : earlyBatches` branch discarded the plain-only chunks. Now the two branches are separated: specials-present uses the last-batch carve-out; specials-absent chunks plain into fixed- size batches directly. Verified by sweeping the a/b/c dust to h: a → 24 batches (400 inputs each × 23 + 159 final) recovered 4.03M bits at ~1.57M fee (600-bit inputs are inherently uneconomical, but the sweep now COMPLETES). b → 1 tx, c → 1 tx. All 26 broadcasts OK. a/b/c/d/e/f/g balances now zero; h holds 185.76M plain + 34.20M MBA backing + 50 NFTs. Co-Authored-By: Claude Opus 4.7 --- scripts/chipnet-consolidate-to-dest.mjs | 122 ++++++++++++++++++------ 1 file changed, 91 insertions(+), 31 deletions(-) diff --git a/scripts/chipnet-consolidate-to-dest.mjs b/scripts/chipnet-consolidate-to-dest.mjs index 7b06b76..a59a468 100644 --- a/scripts/chipnet-consolidate-to-dest.mjs +++ b/scripts/chipnet-consolidate-to-dest.mjs @@ -61,6 +61,7 @@ const PREFUND_BITS = BigInt(argOf('prefund-bits', '5000')); const MIN_UTXO_BITS = BigInt(argOf('min-utxo-bits', '1000')); const DRY_RUN = argOf('dry-run', false) === true; const LEAVE_NFTS = argOf('leave-nfts', false) === true; +const BATCH_INPUTS = Number(argOf('batch-inputs', '400')); // split sources with more inputs into N-input batches; keeps tx size < ~60 KB // Fee model — same shape as chipnet-wallet-sweep.mjs. const FEE_BASE = 500n; @@ -119,34 +120,97 @@ async function consolidateSource(slug, destAddr, destTokenAddr, prefundSimulated console.log(` [${slug}] (dry-run) assuming pre-fund of ${bits(PREFUND_BITS)} bits will land`); } - // ── decide inputs + outputs ── + // ── decide input GROUPS + batching ── + // FT and NFT UTXOs must ride with plain BCH (need fee headroom in each batch); we chunk by BATCH_INPUTS. + // Simplest: distribute FT/NFT into the first batches, plain fills the rest — but that risks a batch with + // FT/NFT + insufficient plain-BCH-for-fee. Cleanest: put FT/NFT ONLY in the last batch alongside enough + // plain BCH to cover fee + outputs; earlier batches are pure plain-BCH consolidates. const nftInputs = nftIncluded ? nft : []; - const inputs = [...plain, ...ft, ...nftInputs]; - // Passthrough: FT UTXOs re-emerge as merged token outputs per category; NFTs re-emerge 1:1. - // Redeem-mba: FT input satoshis burn into the single plain output; NFTs stay on source. + const specials = REDEEM_MBA ? ft : [...ft, ...nftInputs]; // FT+NFT together in passthrough; FT alone in redeem + const specialsPerCat = REDEEM_MBA ? new Map() : groupFtByCategory(ft); + const specialOutCount = REDEEM_MBA ? 0 : specialsPerCat.size + nftInputs.length; - const outByCat = REDEEM_MBA ? new Map() : groupFtByCategory(ft); - const nOut = 1 + outByCat.size + nftInputs.length; // 1 plain BCH + K token outs + M NFT outs - const fee = FEE_BASE + FEE_PER_INPUT * BigInt(inputs.length) + FEE_PER_OUTPUT * BigInt(nOut); + // Batch strategy: + // - if we have specials (FT/NFT), the LAST batch carries them + enough plain to cover fee. + // Earlier batches are pure plain-BCH consolidates. + // - if we have NO specials (redeem-mba, or plain-only source), just chunk plain into fixed-size batches. + const batches = []; + if (specials.length) { + const specialsBatchPlainRoom = Math.max(0, BATCH_INPUTS - specials.length); + const lastBatchPlainSlice = plain.slice(Math.max(0, plain.length - specialsBatchPlainRoom)); + const earlierPlain = plain.slice(0, plain.length - lastBatchPlainSlice.length); + for (let i = 0; i < earlierPlain.length; i += BATCH_INPUTS) batches.push(earlierPlain.slice(i, i + BATCH_INPUTS)); + batches.push([...lastBatchPlainSlice, ...specials]); + } else { + for (let i = 0; i < plain.length; i += BATCH_INPUTS) batches.push(plain.slice(i, i + BATCH_INPUTS)); + } + if (!batches.length) return { slug, skipped: 'nothing to batch' }; - // Passthrough: NFT outputs carry the SAME sats they held on input (~1000 dust each usually), - // so those satoshis don't flow into bchOut. Only plainSum feeds bchOut. - const bchOut = REDEEM_MBA - ? plainSum + ftSum - fee // burn: FT backing recovered; NFTs left on source - : plainSum - fee; // passthrough: only plain BCH minus fee + console.log(` [${slug}] batching: ${batches.length} tx(s), ${batches.map(b => b.length).join('+')} inputs each`); - if (bchOut <= 546n) return { slug, skipped: `dust after fee (bchOut=${bchOut}) — need more plain BCH` }; + const results = []; + for (let bi = 0; bi < batches.length; bi++) { + const batch = batches[bi]; + const isSpecialsBatch = specials.length > 0 && bi === batches.length - 1; + + const batchPlain = batch.filter((u) => !u.token); + const batchFt = batch.filter((u) => u.token && !u.token.nft); + const batchNft = batch.filter((u) => u.token && u.token.nft); + const batchPlainSum = batchPlain.reduce((s, u) => s + u.satoshis, 0n); + const batchFtSum = batchFt.reduce((s, u) => s + u.satoshis, 0n); + + const batchOutByCat = isSpecialsBatch && !REDEEM_MBA ? groupFtByCategory(batchFt) : new Map(); + const batchNftInputs = isSpecialsBatch && !REDEEM_MBA ? batchNft : []; + const nOut = 1 + batchOutByCat.size + batchNftInputs.length; + const fee = FEE_BASE + FEE_PER_INPUT * BigInt(batch.length) + FEE_PER_OUTPUT * BigInt(nOut); + const bchOut = REDEEM_MBA + ? batchPlainSum + batchFtSum - fee + : batchPlainSum - fee; + + if (bchOut <= 546n) { + console.log(` [${slug}] batch ${bi+1}/${batches.length}: dust after fee (bchOut=${bchOut}) — skipping this batch`); + results.push({ batchIndex: bi, skipped: `dust after fee` }); + continue; + } + const r = await buildAndBroadcast(slug, wal, sig, batch, destAddr, destTokenAddr, bchOut, fee, batchOutByCat, batchNftInputs); + r.batchIndex = bi; + results.push(r); + if (r.error) break; // stop this source on first error — later batches would cascade + } + + const okBatches = results.filter((r) => r.txid || r.dry); + const totalOut = okBatches.reduce((s, r) => s + (r.bchOut ?? 0n), 0n); + const totalFee = okBatches.reduce((s, r) => s + (r.fee ?? 0n), 0n); + const totalTokenOuts = okBatches.reduce((s, r) => s + (r.tokenOuts ?? 0), 0); + const totalNftOuts = okBatches.reduce((s, r) => s + (r.nftOuts ?? 0), 0); + return { + slug, + txids: okBatches.map((r) => r.txid).filter(Boolean), + dry: DRY_RUN, + bchOut: totalOut, + fee: totalFee, + tokenOuts: totalTokenOuts, + nftOuts: totalNftOuts, + batchesOk: okBatches.length, + batchesTotal: results.length, + error: results.find((r) => r.error)?.error, + }; +} + +// Builds + broadcasts ONE batch tx (called from the batching loop in consolidateSource). +async function buildAndBroadcast(slug, wal, sig, inputs, destAddr, destTokenAddr, bchOut, fee, outByCat, nftInputs) { + if (bchOut <= 546n) return { skipped: `dust` }; + const nOut = 1 + outByCat.size + nftInputs.length; const tb = new TransactionBuilder({ provider, allowImplicitFungibleTokenBurn: REDEEM_MBA }); for (const u of inputs) tb.addInput(u, sig.unlockP2PKH()); tb.addOutput({ to: destAddr, amount: bchOut }); // plain-BCH output → q-address if (!REDEEM_MBA) { for (const [cat, agg] of outByCat) { - if (agg.sats < 1000n) { console.log(` [${slug}] category ${cat.slice(0,10)}… backing ${agg.sats} < 1000 dust — merging into BCH out (implicit burn allowed here)`); } + if (agg.sats < 1000n) console.log(` [${slug}] category ${cat.slice(0,10)}… backing ${agg.sats} < 1000 dust — merging into BCH out (implicit burn allowed here)`); tb.addOutput({ to: destTokenAddr, amount: agg.sats, token: { category: cat, amount: agg.amount } }); } } - // NFT passthrough: 1 output per NFT input, preserving all token fields (category, amount, nft.commitment, nft.capability). for (const u of nftInputs) { tb.addOutput({ to: destTokenAddr, @@ -154,35 +218,30 @@ async function consolidateSource(slug, destAddr, destTokenAddr, prefundSimulated token: { category: u.token.category, amount: BigInt(u.token.amount || 0), - nft: { - commitment: u.token.nft.commitment, - capability: u.token.nft.capability, - }, + nft: { commitment: u.token.nft.commitment, capability: u.token.nft.capability }, }, }); } - // Dry-run with synthetic pre-fund UTXO can't be `.build()`-ed (fake outpoint won't sign); - // skip build/broadcast and just report the plan. if (DRY_RUN) { const estSize = 10 + inputs.length * 148 + nOut * 34; - console.log(` [${slug}] tx: ${inputs.length} in → 1 plain BCH (${bits(bchOut)} bits) + ${outByCat.size} FT + ${nftInputs.length} NFT out · fee ${fee} bits · ~${estSize} B (estimated)`); - return { slug, dry: true, inputs: inputs.length, bchOut, fee, tokenOuts: outByCat.size, nftOuts: nftInputs.length }; + console.log(` [${slug}] batch: ${inputs.length} in → 1 plain BCH (${bits(bchOut)} bits) + ${outByCat.size} FT + ${nftInputs.length} NFT out · fee ${fee} bits · ~${estSize} B (est)`); + return { dry: true, inputs: inputs.length, bchOut, fee, tokenOuts: outByCat.size, nftOuts: nftInputs.length }; } let rawHex; try { rawHex = await tb.build(); } - catch (e) { return { slug, error: `build failed: ${e.message.split('\n')[0]}` }; } + catch (e) { return { error: `build failed: ${e.message.split('\n')[0]}` }; } const size = rawHex.length / 2; - console.log(` [${slug}] tx: ${inputs.length} in → 1 plain BCH (${bits(bchOut)} bits) + ${outByCat.size} FT + ${nftInputs.length} NFT out · fee ${fee} bits · ${size} B`); + console.log(` [${slug}] batch: ${inputs.length} in → 1 plain BCH (${bits(bchOut)} bits) + ${outByCat.size} FT + ${nftInputs.length} NFT out · fee ${fee} bits · ${size} B`); try { const r = await broadcast(rawHex); console.log(` → OK via ${r.source} tx=${(r.val || '').toString().slice(0, 16)}…`); - return { slug, txid: r.val, bchOut, fee, tokenOuts: outByCat.size, nftOuts: nftInputs.length, source: r.source }; + return { txid: r.val, bchOut, fee, tokenOuts: outByCat.size, nftOuts: nftInputs.length, source: r.source }; } catch (e) { - return { slug, error: `broadcast failed: ${e.message.split('\n')[0]}` }; + return { error: `broadcast failed: ${e.message.split('\n')[0]}` }; } } @@ -287,13 +346,14 @@ async function main() { console.log(`\n=== SUMMARY ===`); let totalOut = 0n, totalFee = 0n, okCount = 0, skipCount = 0, errCount = 0; for (const r of results) { - const status = r.txid ? '✓ ' + r.txid.slice(0, 12) + '…' - : r.dry ? '· dry' + const txids = r.txids ?? (r.txid ? [r.txid] : []); + const status = txids.length ? `✓ ${txids.length} tx: ` + txids.map(t => t.slice(0, 8) + '…').slice(0, 3).join(' ') + (txids.length > 3 ? ` +${txids.length - 3} more` : '') + : r.dry ? `· dry (${r.batchesOk ?? 0}/${r.batchesTotal ?? 0} batches)` : r.skipped ? '- skipped: ' + r.skipped - : r.error ? '✗ ' + r.error.slice(0, 60) + : r.error ? '✗ ' + r.error.slice(0, 60) : '?'; console.log(` ${r.slug.padEnd(6)} · ${status}${r.bchOut ? ` · out=${bits(r.bchOut)} bits · FT=${r.tokenOuts ?? 0} NFT=${r.nftOuts ?? 0}` : ''}`); - if (r.txid || r.dry) { totalOut += r.bchOut ?? 0n; totalFee += r.fee ?? 0n; okCount++; } + if (txids.length || r.dry) { totalOut += r.bchOut ?? 0n; totalFee += r.fee ?? 0n; okCount++; } else if (r.skipped) skipCount++; else errCount++; }