@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
665 lines (575 loc) • 20.1 kB
text/typescript
/**
* Blackjack UTXO Selection Algorithm
* Exact value matching algorithm inspired by the card game
* Optimized for finding combinations that match target exactly
*/
import type { UTXO } from '../interfaces/provider.interface.ts';
import type { SelectionOptions } from '../interfaces/selector.interface.ts';
import type { EnhancedSelectionResult } from '../interfaces/selector-result.interface.ts';
import { SelectionFailureReason } from '../interfaces/selector-result.interface.ts';
import { BaseSelector } from './base-selector.ts';
interface BlackjackCandidate {
utxos: UTXO[];
totalValue: number;
exactness: number; // How close to target (lower is better)
}
/**
* Blackjack UTXO Selection Algorithm - Exact value matching optimization
*
* The Blackjack algorithm is inspired by the card game where the goal is to get as close
* to a target value as possible without going over. This selector prioritizes finding UTXO
* combinations that exactly match the target amount plus fees, minimizing change outputs
* and transaction waste.
*
* @remarks
* The algorithm works in two phases:
* 1. **Exact Match Phase**: Systematically searches for combinations that create changeless
* transactions (total input = target + fee exactly)
* 2. **Closest Match Phase**: If no exact match exists, finds the combination closest to the
* target while still covering the required amount
*
* Key features:
* - Prioritizes changeless transactions to minimize fees and UTXO set bloat
* - Uses combinatorial search with configurable limits (MAX_COMBINATIONS = 10,000)
* - Supports both single-output (no change) and dual-output (with change) transactions
* - Implements "exactness" scoring to measure how close combinations are to the target
* - Falls back to subset sum dynamic programming for optimization
* - Handles dust threshold validation to prevent unspendable outputs
*
* Performance characteristics:
* - Excellent for small to medium UTXO sets (< 20 UTXOs)
* - May be slower for large UTXO sets due to combinatorial complexity
* - Optimal when exact matches are likely (e.g., consolidation scenarios)
*
* @example
* ```typescript
* const selector = new BlackjackSelector();
* const result = selector.select(utxos, {
* targetValue: 100000, // 100,000 satoshis
* feeRate: 10, // 10 sat/vB
* maxInputs: 5, // Limit search space
* dustThreshold: 546 // Bitcoin dust threshold
* });
*
* if (result.success) {
* console.log(`Selected ${result.inputCount} UTXOs`);
* console.log(`Change: ${result.change} satoshis`);
* console.log(`Fee: ${result.fee} satoshis`);
* }
* ```
*/
export class BlackjackSelector extends BaseSelector {
private readonly MAX_COMBINATIONS = 10000;
private readonly EXACT_MATCH_TOLERANCE = 0; // Satoshis tolerance for "exact" match
getName(): string {
return 'blackjack';
}
select(utxos: UTXO[], options: SelectionOptions): EnhancedSelectionResult {
const validationFailure = this.checkOptionsValidity(options);
if (validationFailure) return validationFailure;
// Filter and validate UTXOs
const filteredUTXOs = this.filterEligibleUTXOs(utxos, options);
if (filteredUTXOs.length === 0) {
return {
success: false,
reason: SelectionFailureReason.NO_UTXOS_AVAILABLE,
message: 'No UTXOs available after filtering',
details: {
availableBalance: 0,
requiredAmount: options.targetValue,
utxoCount: 0,
},
};
}
// Calculate total available balance for failure reporting
const totalAvailable = this.sumUTXOs(filteredUTXOs);
// Use optimistic fee estimate for initial check (1 input, 1 output)
// The main algorithm will determine if a more complex transaction is needed
const minFee = this.estimateFee(1, 1, options.feeRate);
const minRequiredAmount = options.targetValue + minFee;
// Quick check for insufficient funds with optimistic estimate
if (totalAvailable < minRequiredAmount) {
return {
success: false,
reason: SelectionFailureReason.INSUFFICIENT_FUNDS,
message: `Insufficient funds: have ${totalAvailable}, need at least ${minRequiredAmount}`,
details: {
availableBalance: totalAvailable,
requiredAmount: minRequiredAmount,
utxoCount: filteredUTXOs.length,
targetValue: options.targetValue,
},
};
}
// Sort by value for better search efficiency
const sortedUTXOs = this.sortByValue(filteredUTXOs, false); // Ascending
// Try to find exact match first
const exactMatch = this.findExactMatch(sortedUTXOs, options);
if (exactMatch.success) {
return exactMatch;
}
// If no exact match, find closest match
const closestMatch = this.findClosestMatch(sortedUTXOs, options);
// If still no solution, return enriched failure information
if (!closestMatch.success) {
// Estimate a reasonable required amount for error reporting
const estimatedFee = this.estimateFee(2, 2, options.feeRate);
const estimatedRequired = options.targetValue + estimatedFee;
return {
success: false,
reason: SelectionFailureReason.SELECTION_FAILED,
message: 'Blackjack algorithm failed to find suitable UTXOs',
details: {
availableBalance: totalAvailable,
requiredAmount: estimatedRequired,
utxoCount: filteredUTXOs.length,
targetValue: options.targetValue,
},
};
}
return closestMatch;
}
/**
* Find exact match for changeless transaction
*/
private findExactMatch(
utxos: UTXO[],
options: SelectionOptions,
): EnhancedSelectionResult {
const maxInputs = Math.min(options.maxInputs || 10, utxos.length);
// Try different combination sizes, starting with smaller ones
for (let size = 1; size <= maxInputs; size++) {
const exactMatch = this.findExactCombination(utxos, options, size);
if (exactMatch) {
return exactMatch;
}
}
const totalValue = this.sumUTXOs(utxos);
const estimatedFee = this.estimateFee(1, 1, options.feeRate);
const requiredAmount = options.targetValue + estimatedFee;
return {
success: false,
reason: SelectionFailureReason.NO_SOLUTION_FOUND,
message: 'No exact match found',
details: {
availableBalance: totalValue,
requiredAmount: requiredAmount,
utxoCount: utxos.length,
targetValue: options.targetValue,
},
};
}
/**
* Find exact combination of specific size
*/
private findExactCombination(
utxos: UTXO[],
options: SelectionOptions,
size: number,
): EnhancedSelectionResult {
const combinations = this.generateCombinations(utxos, size);
let bestCandidate: BlackjackCandidate | null = null;
for (const combination of combinations) {
const totalValue = this.sumUTXOs(combination);
const fee = this.estimateFee(combination.length, 1, options.feeRate);
const required = options.targetValue + fee;
const exactness = Math.abs(totalValue - required);
// Check if this is an exact match
if (exactness <= this.EXACT_MATCH_TOLERANCE) {
const candidate: BlackjackCandidate = {
utxos: combination,
totalValue,
exactness,
};
if (!bestCandidate || exactness < bestCandidate.exactness) {
bestCandidate = candidate;
}
}
}
if (bestCandidate) {
return this.createResult(
bestCandidate.utxos,
options.targetValue,
options.feeRate,
false, // No change for exact match
);
}
const totalValue = this.sumUTXOs(utxos);
const estimatedFee = this.estimateFee(1, 1, options.feeRate);
const requiredAmount = options.targetValue + estimatedFee;
return {
success: false,
reason: SelectionFailureReason.NO_SOLUTION_FOUND,
message: 'No exact combination found',
details: {
availableBalance: totalValue,
requiredAmount: requiredAmount,
utxoCount: utxos.length,
targetValue: options.targetValue,
},
};
}
/**
* Find closest match when exact match is not possible
*/
private findClosestMatch(
utxos: UTXO[],
options: SelectionOptions,
): EnhancedSelectionResult {
const maxInputs = Math.min(options.maxInputs || 15, utxos.length);
let bestCandidate: BlackjackCandidate | null = null;
// Try different combination sizes
for (let size = 1; size <= maxInputs; size++) {
const candidate = this.findBestCombinationOfSize(utxos, options, size);
if (candidate && this.isValidCandidate(candidate, options)) {
if (
!bestCandidate ||
this.isBetterCandidate(candidate, bestCandidate, options)
) {
bestCandidate = candidate;
}
}
}
if (!bestCandidate) {
const totalValue = this.sumUTXOs(utxos);
const estimatedFee = this.estimateFee(1, 2, options.feeRate);
const requiredAmount = options.targetValue + estimatedFee;
return {
success: false,
reason: SelectionFailureReason.NO_SOLUTION_FOUND,
message: 'No suitable closest match found',
details: {
availableBalance: totalValue,
requiredAmount: requiredAmount,
utxoCount: utxos.length,
targetValue: options.targetValue,
},
};
}
// Determine if result has change
const fee = this.estimateFee(
bestCandidate.utxos.length,
2,
options.feeRate,
);
const change = bestCandidate.totalValue - options.targetValue - fee;
const dustThreshold = options.dustThreshold || this.DUST_THRESHOLD;
const hasChange = change >= dustThreshold;
if (!hasChange) {
// Recalculate with single output
const singleOutputFee = this.estimateFee(
bestCandidate.utxos.length,
1,
options.feeRate,
);
const requiredForSingleOutput = options.targetValue + singleOutputFee;
if (bestCandidate.totalValue >= requiredForSingleOutput) {
return this.createResult(
bestCandidate.utxos,
options.targetValue,
options.feeRate,
false,
);
}
}
if (hasChange) {
const result = this.createResult(
bestCandidate.utxos,
options.targetValue,
options.feeRate,
true,
);
// Add waste metric
result.wasteMetric = this.calculateWaste(
bestCandidate.utxos,
options.targetValue,
options.feeRate,
);
return result;
}
const totalValue = this.sumUTXOs(utxos);
const estimatedFee = this.estimateFee(1, 2, options.feeRate);
const requiredAmount = options.targetValue + estimatedFee;
return {
success: false,
reason: SelectionFailureReason.DUST_OUTPUT,
message: 'Cannot create valid output - would create dust',
details: {
availableBalance: totalValue,
requiredAmount: requiredAmount,
utxoCount: utxos.length,
targetValue: options.targetValue,
dustThreshold: options.dustThreshold || this.DUST_THRESHOLD,
},
};
}
/**
* Find best combination of specific size
*/
private findBestCombinationOfSize(
utxos: UTXO[],
options: SelectionOptions,
size: number,
): BlackjackCandidate | null {
const combinations = this.generateCombinations(utxos, size);
let bestCandidate: BlackjackCandidate | null = null;
for (const combination of combinations) {
const totalValue = this.sumUTXOs(combination);
// Calculate exactness for both changeless and change scenarios
const changelessFee = this.estimateFee(
combination.length,
1,
options.feeRate,
);
const changelessRequired = options.targetValue + changelessFee;
const changelessExactness = Math.abs(totalValue - changelessRequired);
const withChangeFee = this.estimateFee(
combination.length,
2,
options.feeRate,
);
const withChangeRequired = options.targetValue + withChangeFee;
const withChangeExactness = totalValue >= withChangeRequired
? Math.abs(totalValue - withChangeRequired)
: Infinity;
// Choose better exactness (prefer changeless if close)
const exactness = changelessExactness <= withChangeExactness
? changelessExactness
: withChangeExactness;
const candidate: BlackjackCandidate = {
utxos: combination,
totalValue,
exactness,
};
if (!bestCandidate || exactness < bestCandidate.exactness) {
bestCandidate = candidate;
}
}
return bestCandidate;
}
/**
* Generate combinations of UTXOs
*/
private generateCombinations(utxos: UTXO[], size: number): UTXO[][] {
const combinations: UTXO[][] = [];
const maxCombinations = Math.min(
this.MAX_COMBINATIONS,
this.binomialCoefficient(utxos.length, size),
);
this.generateCombinationsRecursive(
utxos,
size,
0,
[],
combinations,
maxCombinations,
);
return combinations;
}
/**
* Recursive combination generation with limit
*/
private generateCombinationsRecursive(
utxos: UTXO[],
size: number,
startIndex: number,
current: UTXO[],
results: UTXO[][],
maxResults: number,
): void {
if (results.length >= maxResults) return;
if (current.length === size) {
results.push([...current]);
return;
}
const remaining = size - current.length;
const available = utxos.length - startIndex;
if (remaining > available) return;
for (
let i = startIndex;
i <= utxos.length - remaining && results.length < maxResults;
i++
) {
current.push(utxos[i]!);
this.generateCombinationsRecursive(
utxos,
size,
i + 1,
current,
results,
maxResults,
);
current.pop();
}
}
/**
* Calculate binomial coefficient (n choose k)
*/
private binomialCoefficient(n: number, k: number): number {
if (k > n) return 0;
if (k === 0 || k === n) return 1;
k = Math.min(k, n - k); // Take advantage of symmetry
let result = 1;
for (let i = 0; i < k; i++) {
result = (result * (n - i)) / (i + 1);
}
return Math.floor(result);
}
/**
* Check if candidate is valid for transaction
*/
private isValidCandidate(
candidate: BlackjackCandidate,
options: SelectionOptions,
): boolean {
// Check minimum value requirement
const minFee = this.estimateFee(candidate.utxos.length, 1, options.feeRate);
const minRequired = options.targetValue + minFee;
return candidate.totalValue >= minRequired;
}
/**
* Compare two candidates to determine which is better
*/
private isBetterCandidate(
candidate1: BlackjackCandidate,
candidate2: BlackjackCandidate,
options: SelectionOptions,
): boolean {
// Blackjack principle: prefer the candidate that's closest to target but over it
// Check if either candidate is very close to exact (within tolerance)
const isVeryExact1 = candidate1.exactness <= this.EXACT_MATCH_TOLERANCE;
const isVeryExact2 = candidate2.exactness <= this.EXACT_MATCH_TOLERANCE;
// Strongly prefer very exact matches over everything else
if (isVeryExact1 !== isVeryExact2) {
return isVeryExact1; // Exact match wins over non-exact, regardless of change
}
// For blackjack strategy: prefer smaller total values that meet the requirement
// This ensures we pick 51000 over 100000 when target is 50000
// First check if both candidates meet the minimum requirement
const minFee1 = this.estimateFee(candidate1.utxos.length, 1, options.feeRate);
const minRequired1 = options.targetValue + minFee1;
const meetsMin1 = candidate1.totalValue >= minRequired1;
const minFee2 = this.estimateFee(candidate2.utxos.length, 1, options.feeRate);
const minRequired2 = options.targetValue + minFee2;
const meetsMin2 = candidate2.totalValue >= minRequired2;
// If only one meets minimum requirement, prefer that one
if (meetsMin1 !== meetsMin2) {
return meetsMin1;
}
// If both meet minimum requirement, prefer smaller total (closer to target)
if (meetsMin1 && meetsMin2) {
if (candidate1.totalValue !== candidate2.totalValue) {
return candidate1.totalValue < candidate2.totalValue;
}
}
// If both have same exactness category, prefer more exact matches
if (candidate1.exactness !== candidate2.exactness) {
return candidate1.exactness < candidate2.exactness;
}
// If exactness is equal, prefer fewer inputs
if (candidate1.utxos.length !== candidate2.utxos.length) {
return candidate1.utxos.length < candidate2.utxos.length;
}
// Final tiebreaker: prefer smaller total value
return candidate1.totalValue < candidate2.totalValue;
}
/**
* Optimized selection for specific target amounts
* Uses dynamic programming approach for better performance
*/
selectOptimized(
utxos: UTXO[],
options: SelectionOptions,
): EnhancedSelectionResult {
const validationFailure = this.checkOptionsValidity(options);
if (validationFailure) return validationFailure;
const filteredUTXOs = this.filterEligibleUTXOs(utxos, options);
if (filteredUTXOs.length === 0) {
return {
success: false,
reason: SelectionFailureReason.NO_UTXOS_AVAILABLE,
message: 'No UTXOs available after filtering',
details: {
availableBalance: 0,
requiredAmount: options.targetValue,
utxoCount: 0,
},
};
}
// Use subset sum approach for exact target matching
const targetWithFee = options.targetValue +
this.estimateFee(2, 1, options.feeRate); // Estimate
const result = this.subsetSum(
filteredUTXOs,
targetWithFee,
options.maxInputs || 10,
);
if (result.length > 0) {
return this.createResult(
result,
options.targetValue,
options.feeRate,
false,
);
}
// Fallback to regular blackjack algorithm
return this.select(utxos, options);
}
/**
* Subset sum algorithm for exact matching
*/
private subsetSum(
utxos: UTXO[],
target: number,
maxInputs: number,
): UTXO[] {
const n = Math.min(utxos.length, maxInputs);
// DP table: dp[i][sum] = true if sum is possible with first i UTXOs
const dp: boolean[][] = Array(n + 1)
.fill(null)
.map(() => Array(target + 1).fill(false));
// Base case: sum 0 is always possible with 0 UTXOs
for (let i = 0; i <= n; i++) {
dp[i]![0] = true;
}
// Fill DP table
for (let i = 1; i <= n; i++) {
const utxo = utxos[i - 1]!;
for (let sum = 1; sum <= target; sum++) {
// Don't include current UTXO
dp[i]![sum] = dp[i - 1]![sum] || false;
// Include current UTXO if possible
if (sum >= utxo.value && dp[i - 1]![sum - utxo.value]) {
dp[i]![sum] = true;
}
}
}
// If target sum is not possible
if (!dp[n]![target]) {
return [];
}
// Backtrack to find the actual subset
const result: UTXO[] = [];
let currentSum = target;
for (let i = n; i > 0 && currentSum > 0; i--) {
// If current sum was not possible without UTXOs[i-1], include it
if (!dp[i - 1]![currentSum]) {
result.push(utxos[i - 1]!);
currentSum -= utxos[i - 1]!.value;
}
}
return result.reverse(); // Return in original order
}
/**
* Get algorithm statistics
*/
getStats(): {
maxCombinations: number;
exactMatchTolerance: number;
} {
return {
maxCombinations: this.MAX_COMBINATIONS,
exactMatchTolerance: this.EXACT_MATCH_TOLERANCE,
};
}
}