Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 207 additions & 18 deletions coinlib/lib/src/tx/coin_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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({
Expand All @@ -171,20 +178,20 @@ 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,
changeProgram: changeProgram,
feePerKb: feePerKb,
minFee: minFee,
minChange: minChange,
locktime: locktime,
);
return changelessSelection;
} on Exception {
final randomSelection = CoinSelection.random(
version: version,
candidates: candidates,
recipients: recipients,
Expand All @@ -193,9 +200,21 @@ class CoinSelection {
minFee: minFee,
minChange: minChange,
locktime: locktime,
)
: randomSelection;
);

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]
Expand Down Expand Up @@ -296,6 +315,176 @@ 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<InputCandidate> candidates,
required Iterable<Output> recipients,
required Program changeProgram,
required BigInt feePerKb,
required BigInt minFee,
required BigInt minChange,
int maxCandidates = 6800,
int locktime = 0,
}) {
CoinSelection trySelection(Iterable<InputCandidate> 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<BigInt>.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>(
BigInt.zero,
(acc, o) => acc + o.value,
);

// Non-input overhead
final recipientSizeSum = recipients.fold<int>(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();

// 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<bool>.filled(pool.length, false);
final phase = List<int>.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 = <InputCandidate>[
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).
}

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 (!found) throw SolutionNotFound();

final result = <InputCandidate>[
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
Expand Down
113 changes: 113 additions & 0 deletions coinlib/test/tx/coin_selection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,119 @@ void main() {

});

test("Branch and Bound", () {
CoinSelection getBnB({
required List<int> 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<InsufficientFunds>()),
);

// Total candidate value is below the recipient value: cannot fund.
expect(
() => getBnB(candidates: [coin ~/ 2], outValue: coin),
throwsA(isA<InsufficientFunds>()),
);

// 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<SolutionNotFound>()),
);

// Just under the exact-fee amount: cannot reach changeless either way.
expect(
() => getBnB(
candidates: [coin + 1910 - 1],
outValue: coin,
),
throwsA(isA<InsufficientFunds>()),
);

// 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<InsufficientFunds>()),
);
});
});

}
Loading