From f1c8a6124ce0e5eddc86e89942fce4235e9aec0f Mon Sep 17 00:00:00 2001 From: Cyrix126 <58007246+Cyrix126@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:01:59 +0900 Subject: [PATCH 1/2] feat: add BnB algorithm for coin selection --- coinlib/lib/src/tx/coin_selection.dart | 200 +++++++++++++++++++++-- coinlib/test/tx/coin_selection_test.dart | 113 +++++++++++++ 2 files changed, 295 insertions(+), 18 deletions(-) diff --git a/coinlib/lib/src/tx/coin_selection.dart b/coinlib/lib/src/tx/coin_selection.dart index 0472a04..b6922c2 100644 --- a/coinlib/lib/src/tx/coin_selection.dart +++ b/coinlib/lib/src/tx/coin_selection.dart @@ -8,8 +8,14 @@ import 'inputs/taproot_input.dart'; import 'output.dart'; import 'transaction.dart'; +/// Total_Tries in BTC Core: +/// https://github.com/bitcoin/bitcoin/blob/1d9da8da309d1dbf9aef15eb8dc43b4a2dc3d309/src/wallet/coinselection.cpp#L74 +const int bnbMaxTries = 100000; + class InsufficientFunds implements Exception {} +class SolutionNotFound implements Exception {} + /// A candidate input to spend a UTXO with the UTXO value class InputCandidate { @@ -157,8 +163,9 @@ class CoinSelection { } /// A useful default coin selection algorithm. - /// Currently this will first select candidates at random until the required - /// input amount is reached. If the resulting transaction is too large or not + /// This will try to first get a changeless solution before + /// selecting candidates at random until the required input amount is reached. + /// If the resulting transaction is too large or not /// enough funds have been reached it will fall back to adding the largest /// input values first. factory CoinSelection.optimal({ @@ -171,20 +178,8 @@ class CoinSelection { required BigInt minChange, int locktime = 0, }) { - - final randomSelection = CoinSelection.random( - version: version, - candidates: candidates, - recipients: recipients, - changeProgram: changeProgram, - feePerKb: feePerKb, - minFee: minFee, - minChange: minChange, - locktime: locktime, - ); - - return randomSelection.tooLarge || !randomSelection.enoughFunds - ? CoinSelection.largestFirst( + try { + final changelessSelection = CoinSelection.branchAndBound( version: version, candidates: candidates, recipients: recipients, @@ -193,9 +188,33 @@ class CoinSelection { minFee: minFee, minChange: minChange, locktime: locktime, - ) - : randomSelection; + ); + return changelessSelection; + } on Exception { + final randomSelection = CoinSelection.random( + version: version, + candidates: candidates, + recipients: recipients, + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + locktime: locktime, + ); + return randomSelection.tooLarge || !randomSelection.enoughFunds + ? CoinSelection.largestFirst( + version: version, + candidates: candidates, + recipients: recipients, + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + locktime: locktime, + ) + : randomSelection; + } } /// A simple selection algorithm that selects inputs from the [candidates] @@ -296,6 +315,151 @@ class CoinSelection { locktime: locktime, ); + /// A branch and bound coin selection algorithm based on the approach + /// described by Mark Erhardt (used by Bitcoin Core). It performs a depth + /// first search over the [candidates] looking for a subset whose total value + /// exactly funds the [recipients] and the resulting fee without producing a + /// change output. This is desirable as the resulting transaction is smaller, + /// pays no change, and is harder to fingerprint as belonging to this wallet. + factory CoinSelection.branchAndBound({ + int version = Transaction.currentVersion, + required Iterable candidates, + required Iterable recipients, + required Program changeProgram, + required BigInt feePerKb, + required BigInt minFee, + required BigInt minChange, + int maxCandidates = 6800, + int locktime = 0, + }) { + CoinSelection trySelection(Iterable selected) => + CoinSelection( + version: version, + selected: selected, + recipients: recipients, + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + locktime: locktime, + ); + + int signedSizeOf(InputCandidate c) { + final input = c.input; + final size = input is TaprootInput && c.defaultSigHash + ? input.defaultSignedSize + : input.signedSize; + if (size == null) { + throw ArgumentError( + "Cannot select inputs without known max signed size", + ); + } + return size; + } + + BigInt feeForBytes(int bytes) => + feePerKb * BigInt.from(bytes) ~/ BigInt.from(1000); + + // Marginal fee charged for including a single candidate input. + BigInt inputFee(InputCandidate c) => feeForBytes(signedSizeOf(c)); + + // Effective value of a candidate: what it actually contributes after + // paying its own marginal fee. + BigInt effectiveValue(InputCandidate c) => c.value - inputFee(c); + + // Filter out candidates that cost more than they contribute, take only as + // many as will fit, then sort by effective value descending. + final pool = candidates + .take(maxCandidates) + .where((c) => effectiveValue(c) > BigInt.zero) + .toList() + ..sort((a, b) => effectiveValue(b).compareTo(effectiveValue(a))); + + // Pre-compute effective values and a suffix sum to enable cheap pruning. + final effValues = pool.map(effectiveValue).toList(growable: false); + final suffixSum = List.filled(pool.length + 1, BigInt.zero); + for (int i = pool.length - 1; i >= 0; i--) { + suffixSum[i] = suffixSum[i + 1] + effValues[i]; + } + + // target that includes the total of all recipient value + final recipientValue = recipients.fold( + BigInt.zero, + (acc, o) => acc + o.value, + ); + + // Non-input overhead + final recipientSizeSum = recipients.fold(0, (acc, o) => acc + o.size); + final overheadSize = 8 + + MeasureWriter.varIntSizeOfInt(0) + + MeasureWriter.varIntSizeOfInt(recipients.length) + + recipientSizeSum; + + // target which includes the recipient values + non input fee + // The recipients value is effective value + final target = recipientValue + feeForBytes(overheadSize); + + // The changeless tolerance: any excess must be small enough that the + // CoinSelection constructor would drop the change output. The boundary is + // minChange + the marginal fee of a change output (since adding change + // would also add its fee). This is an upper bound. + final changeOutputSize = + Output.fromProgram(BigInt.zero, changeProgram).size; + final costOfChange = minChange + feeForBytes(changeOutputSize); + + if (pool.isEmpty || suffixSum[0] < target) throw InsufficientFunds(); + + // Depth-first branch and bound. At each step we either include + // pool[depth] or skip it. The "include" branch is explored first to bias + // towards larger inputs which tends to find solutions faster. + final selectedFlags = List.filled(pool.length, false); + int tries = 0; + + bool search(int depth, BigInt currentSum) { + if (++tries > bnbMaxTries) return false; + + // Prune: cannot possibly reach target from here. + if (currentSum + suffixSum[depth] < target) return false; + // Prune: overshot the changeless window. + if (currentSum > target + costOfChange) return false; + + if (currentSum >= target) { + // Inside the [target, target + costOfChange] window. Validate the + // candidate solution against the real CoinSelection constructor as it + // is the source of truth for the changeless decision. + final picked = [ + for (int i = 0; i < depth; i++) + if (selectedFlags[i]) pool[i], + ]; + final selection = trySelection(picked); + if (selection.ready && selection.changeless) return true; + // Otherwise continue searching; effective-value heuristics may have + // disagreed with the real fee (e.g. due to minFee or varint changes). + } + + if (depth == pool.length) return false; + + // Include branch first. + selectedFlags[depth] = true; + if (search(depth + 1, currentSum + effValues[depth])) return true; + + // Exclude branch. + selectedFlags[depth] = false; + if (search(depth + 1, currentSum)) return true; + + return false; + } + + if (!search(0, BigInt.zero)) throw SolutionNotFound(); + + final result = [ + for (int i = 0; i < pool.length; i++) + if (selectedFlags[i]) pool[i], + ]; + + return trySelection(result); + } + /// Obtains the transaction with selected inputs and outputs including any /// change at a random location, ready to be signed. Throws /// [InsufficientFunds] if there is not enough input value to meet the output diff --git a/coinlib/test/tx/coin_selection_test.dart b/coinlib/test/tx/coin_selection_test.dart index b5e1c32..519b8ba 100644 --- a/coinlib/test/tx/coin_selection_test.dart +++ b/coinlib/test/tx/coin_selection_test.dart @@ -484,6 +484,119 @@ void main() { }); + test("Branch and Bound", () { + CoinSelection getBnB({ + required List candidates, + required int outValue, + }) => + CoinSelection.branchAndBound( + version: 1234, + candidates: candidates.map(candidateForValue), + recipients: [outputForValue(outValue)], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + locktime: 0xabcd1234, + ); + + // Single input is an exact-fee match: input = coin + 1910 funds the + // coin recipient changelessly. + { + final selection = getBnB( + candidates: [coin + 1910], + outValue: coin, + ); + expectSelectedValues(selection, [coin + 1910]); + expect(selection.changeless, true); + expect(selection.ready, true); + } + + // When some candidates would produce too much change and one is in the + // changeless window, BnB picks the changeless one. + { + final selection = getBnB( + candidates: [coin * 5, coin + 1910, coin * 3], + outValue: coin, + ); + expectSelectedValues(selection, [coin + 1910]); + expect(selection.changeless, true); + } + + // Selecting under the minimum-change boundary still counts as a + // changeless solution. coin + 2250 + minChange - 1 lands just under the + // threshold (the existing constructor vector confirms). + { + final selection = getBnB( + candidates: [coin + 2250 + minChange.toInt() - 1], + outValue: coin, + ); + expect(selection.selected.length, 1); + expect(selection.changeless, true); + expect(selection.ready, true); + } + + // No candidates at all: nothing to find. + expect( + () => getBnB(candidates: [], outValue: coin), + throwsA(isA()), + ); + + // Total candidate value is below the recipient value: cannot fund. + expect( + () => getBnB(candidates: [coin ~/ 2], outValue: coin), + throwsA(isA()), + ); + + // Every candidate produces too much change to be changeless and no + // combination lands in the changeless window. + expect( + () => getBnB(candidates: [coin * 5, coin * 3], outValue: coin), + throwsA(isA()), + ); + + // Just under the exact-fee amount: cannot reach changeless either way. + expect( + () => getBnB( + candidates: [coin + 1910 - 1], + outValue: coin, + ), + throwsA(isA()), + ); + + // Two-input changeless solution. With two P2PKH inputs the signed size + // is 339 bytes giving a fee of 3390. Picking exactly that excess across + // two inputs lands inside the changeless window. + { + final selection = getBnB( + candidates: [coin * 10, coin + 1500, coin + 1900, coin * 5], + outValue: coin * 2, + ); + expect(selection.selected.length, 2); + expect( + selection.selected.map((c) => c.value.toInt()), + unorderedEquals([coin + 1500, coin + 1900]), + ); + expect(selection.changeless, true); + expect(selection.ready, true); + } + + // InsufficientFunds is also thrown when candidates are signable but all + // have effective value <= 0 (their fee exceeds their value). + expect( + () => CoinSelection.branchAndBound( + version: 1234, + candidates: [candidateForValue(1)], + recipients: [outputForValue(coin)], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + locktime: 0xabcd1234, + ), + throwsA(isA()), + ); + }); }); } From 6a8bb2ef1152c58e503ca3ba5ac293a7d64006bb Mon Sep 17 00:00:00 2001 From: Cyrix126 <58007246+Cyrix126@users.noreply.github.com> Date: Tue, 5 May 2026 12:16:58 +0900 Subject: [PATCH 2/2] fix: rewrite bnb algo using iteration instead of recursion - fix: wasm target - fix: allow to increase maxCandidate number without overflowing --- coinlib/lib/src/tx/coin_selection.dart | 93 ++++++++++++++++---------- 1 file changed, 59 insertions(+), 34 deletions(-) diff --git a/coinlib/lib/src/tx/coin_selection.dart b/coinlib/lib/src/tx/coin_selection.dart index b6922c2..c9abcc0 100644 --- a/coinlib/lib/src/tx/coin_selection.dart +++ b/coinlib/lib/src/tx/coin_selection.dart @@ -409,48 +409,73 @@ class CoinSelection { if (pool.isEmpty || suffixSum[0] < target) throw InsufficientFunds(); - // Depth-first branch and bound. At each step we either include + // Iterative depth-first branch and bound. At each depth we either include // pool[depth] or skip it. The "include" branch is explored first to bias // towards larger inputs which tends to find solutions faster. final selectedFlags = List.filled(pool.length, false); + final phase = List.filled(pool.length, 0); + int depth = 0; + BigInt currentSum = BigInt.zero; int tries = 0; + bool found = false; + + outer: + while (true) { + if (++tries > bnbMaxTries) break; + + final reachable = currentSum + suffixSum[depth] >= target; + final withinWindow = currentSum <= target + costOfChange; + + if (reachable && withinWindow) { + if (currentSum >= target) { + // Inside the [target, target + costOfChange] window. Validate the + // candidate solution against the real CoinSelection constructor as + // it is the source of truth for the changeless decision. + final picked = [ + for (int i = 0; i < depth; i++) + if (selectedFlags[i]) pool[i], + ]; + final selection = trySelection(picked); + if (selection.ready && selection.changeless) { + found = true; + break; + } + // Otherwise continue searching; effective-value heuristics may have + // disagreed with the real fee (e.g. due to minFee or varint changes). + } - bool search(int depth, BigInt currentSum) { - if (++tries > bnbMaxTries) return false; - - // Prune: cannot possibly reach target from here. - if (currentSum + suffixSum[depth] < target) return false; - // Prune: overshot the changeless window. - if (currentSum > target + costOfChange) return false; - - if (currentSum >= target) { - // Inside the [target, target + costOfChange] window. Validate the - // candidate solution against the real CoinSelection constructor as it - // is the source of truth for the changeless decision. - final picked = [ - for (int i = 0; i < depth; i++) - if (selectedFlags[i]) pool[i], - ]; - final selection = trySelection(picked); - if (selection.ready && selection.changeless) return true; - // Otherwise continue searching; effective-value heuristics may have - // disagreed with the real fee (e.g. due to minFee or varint changes). + if (depth < pool.length) { + // Descend into the include branch first. + phase[depth] = 0; + selectedFlags[depth] = true; + currentSum += effValues[depth]; + depth++; + continue; + } + // Else depth == pool.length. Leaf reached without solution: fall + // through to backtracking below. + } + // Else: pruned (can't reach target or overshot the window). Fall + // through to backtracking. + + // Backtrack to the most recent depth with an untried branch + while (true) { + if (depth == 0) break outer; + depth--; + if (phase[depth] == 0) { + // Include branch just finished; switch to the exclude branch. + selectedFlags[depth] = false; + currentSum -= effValues[depth]; + phase[depth] = 1; + depth++; + continue outer; + } + // phase[depth] == 1: both branches tried at this depth. Continue + // backtracking up the tree. } - - if (depth == pool.length) return false; - - // Include branch first. - selectedFlags[depth] = true; - if (search(depth + 1, currentSum + effValues[depth])) return true; - - // Exclude branch. - selectedFlags[depth] = false; - if (search(depth + 1, currentSum)) return true; - - return false; } - if (!search(0, BigInt.zero)) throw SolutionNotFound(); + if (!found) throw SolutionNotFound(); final result = [ for (int i = 0; i < pool.length; i++)