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:
parent
b0981741b1
commit
1134024d63
1 changed files with 91 additions and 31 deletions
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue