From 32f1561e0057cfe58004d25f24d934ed2594195c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:46:39 -0800 Subject: [PATCH 1/3] 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 0e1d1d1..8d99a59 100644 --- a/coinlib/lib/src/tx/coin_selection.dart +++ b/coinlib/lib/src/tx/coin_selection.dart @@ -9,8 +9,14 @@ import 'locktime.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 { @@ -158,8 +164,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({ @@ -172,20 +179,8 @@ class CoinSelection { required BigInt minChange, Locktime locktime = Locktime.zero, }) { - - 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, @@ -194,9 +189,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] @@ -297,6 +316,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 714d28d..842e5ee 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 d783e0deae11222d0bf4a2d675f1ec7702507ac5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:46:39 -0800 Subject: [PATCH 2/3] 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 8d99a59..0976af6 100644 --- a/coinlib/lib/src/tx/coin_selection.dart +++ b/coinlib/lib/src/tx/coin_selection.dart @@ -410,48 +410,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++) From 28c13032ff9807095e1e490f6375c17d168cc8bc Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:50:22 -0800 Subject: [PATCH 3/3] fix: use Locktime type in BnB coin selection Adapt the branch and bound selection to the Locktime abstraction introduced for v5. The locktime parameter of CoinSelection.branchAndBound and the test call sites still used a raw int. --- coinlib/lib/src/tx/coin_selection.dart | 2 +- coinlib/test/tx/coin_selection_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coinlib/lib/src/tx/coin_selection.dart b/coinlib/lib/src/tx/coin_selection.dart index 0976af6..de95739 100644 --- a/coinlib/lib/src/tx/coin_selection.dart +++ b/coinlib/lib/src/tx/coin_selection.dart @@ -331,7 +331,7 @@ class CoinSelection { required BigInt minFee, required BigInt minChange, int maxCandidates = 6800, - int locktime = 0, + Locktime locktime = Locktime.zero, }) { CoinSelection trySelection(Iterable selected) => CoinSelection( diff --git a/coinlib/test/tx/coin_selection_test.dart b/coinlib/test/tx/coin_selection_test.dart index 842e5ee..dbb5c4a 100644 --- a/coinlib/test/tx/coin_selection_test.dart +++ b/coinlib/test/tx/coin_selection_test.dart @@ -497,7 +497,7 @@ void main() { feePerKb: feePerKb, minFee: minFee, minChange: minChange, - locktime: 0xabcd1234, + locktime: Locktime(0xabcd1234), ); // Single input is an exact-fee match: input = coin + 1910 funds the @@ -592,7 +592,7 @@ void main() { feePerKb: feePerKb, minFee: minFee, minChange: minChange, - locktime: 0xabcd1234, + locktime: Locktime(0xabcd1234), ), throwsA(isA()), );