fix(consolidate): batch large sources into ≤400-input tx chunks

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 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-09-27 16:05:55 +02:00
parent b0981741b1
commit 1134024d63

View file

@ -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++;
}