UNPKG

@btc-stamps/tx-builder

Version:

Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection

333 lines (291 loc) 11.4 kB
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 { createSelectionFailure, createSelectionSuccess, SelectionFailureReason, } from '../interfaces/selector-result.interface.ts'; import { BaseSelector } from './base-selector.ts'; /** * Knapsack UTXO Selection Algorithm - Legacy stochastic approximation * * The Knapsack selector implements Bitcoin Core's legacy UTXO selection algorithm * (pre-2018) using a stochastic approximation approach. It runs multiple random * iterations to find good solutions, making it highly reliable and capable of * finding valid selections even when more sophisticated algorithms fail. * * @remarks * The algorithm operates through multiple phases: * 1. **Exact Match Search**: First attempts to find precise combinations for changeless transactions * 2. **Stochastic Iteration**: Runs up to 1000 random trials, each selecting UTXOs with 50% probability * 3. **Accumulative Fallback**: If stochastic approach fails, uses simple largest-first accumulation * * Each iteration processes UTXOs from largest to smallest value, randomly including each with * a configurable probability (default 50%). The algorithm tracks the best solution found across * all iterations, preferring selections that minimize excess value over the target amount. * * The algorithm includes intelligent early exit conditions and prefers solutions that avoid * creating dust outputs (change below 1000 satoshis threshold). * * Key features: * - Highly reliable - always finds a solution when sufficient funds are available * - Stochastic approach avoids local optima that deterministic algorithms might encounter * - Configurable iteration count and inclusion probability for fine-tuning * - Built-in exact match optimization for small UTXO combinations * - Dust threshold handling to prevent unspendable change outputs * - Accumulative fallback ensures solution availability * - Maximum input constraints respected throughout selection process * * Performance characteristics: * - Moderate performance, scales well with UTXO set size * - Consistent execution time due to fixed iteration limit * - Less optimal than modern algorithms but more predictable * - Excellent fallback algorithm when others fail due to constraints * * @example * ```typescript * const selector = new KnapsackSelector(); * const result = selector.select(utxos, { * targetValue: 250000, // 250,000 satoshis * feeRate: 20, // 20 sat/vB * maxInputs: 8, // Limit to 8 inputs max * minConfirmations: 1 // Require confirmed UTXOs * }); * * if (result.success) { * console.log(`Selected ${result.inputCount} UTXOs`); * console.log(`Total value: ${result.totalValue} satoshis`); * console.log(`Change: ${result.change} satoshis`); * } * * // Configurable version with custom parameters * const customSelector = new ConfigurableKnapsackSelector({ * iterations: 2000, // More iterations for better results * inclusionProbability: 0.3 // Lower probability for tighter selection * }); * ``` */ export class KnapsackSelector extends BaseSelector { protected MAX_ITERATIONS = 1000; private readonly MIN_CHANGE_THRESHOLD = 1000; // Dust threshold for change select(utxos: UTXO[], options: SelectionOptions): EnhancedSelectionResult { // Validate options first const validationFailure = this.checkOptionsValidity(options); if (validationFailure) return validationFailure; const { targetValue, feeRate, longTermFeeRate } = options; console.debug( `Knapsack Selection: fee=${feeRate}, longTermFee=${longTermFeeRate}`, ); // Use the unused parameters // Handle empty UTXO set if (utxos.length === 0) { return createSelectionFailure( SelectionFailureReason.NO_UTXOS_AVAILABLE, 'No UTXOs available for selection', { utxoCount: 0, targetValue }, ); } // Filter out zero-value UTXOs and apply confirmation/protection filter const validUtxos = utxos.filter((utxo) => utxo.value > 0); const eligibleUtxos = this.filterEligibleUTXOs(validUtxos, options); if (eligibleUtxos.length === 0) { return createSelectionFailure( SelectionFailureReason.NO_UTXOS_AVAILABLE, 'No eligible UTXOs available (confirmations/protection)', { utxoCount: utxos.length, minConfirmations: options.minConfirmations }, ); } const sortedUtxos = [...eligibleUtxos].sort((a, b) => b.value - a.value); // Calculate total available value const totalAvailable = sortedUtxos.reduce( (sum, utxo) => sum + utxo.value, 0, ); // Quick check if we have enough funds if (totalAvailable < targetValue) { return createSelectionFailure( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Insufficient funds to cover target amount', { availableBalance: totalAvailable, requiredAmount: targetValue, utxoCount: sortedUtxos.length, }, ); } let bestSelection: UTXO[] = []; let bestValue = 0; let bestExcess = Number.MAX_SAFE_INTEGER; // Try exact match first (no change) const exactMatch = this.findExactMatch(sortedUtxos, targetValue, options.feeRate); if (exactMatch.length > 0) { const totalSpent = this.sumValues(exactMatch); const fee = this.estimateFee(exactMatch.length, 2, options.feeRate); const hasChange = totalSpent > targetValue + fee; return createSelectionSuccess( exactMatch, totalSpent, hasChange ? totalSpent - targetValue - fee : 0, fee, { outputCount: hasChange ? 2 : 1 }, ); } // Run stochastic iterations for (let iteration = 0; iteration < this.MAX_ITERATIONS; iteration++) { const selected: UTXO[] = []; let currentValue = 0; // Randomly include each UTXO with 50% probability // Process from largest to smallest for better convergence for (const utxo of sortedUtxos) { // Check max inputs constraint if (options.maxInputs && selected.length >= options.maxInputs) { break; } // Include with configured probability or if we still need more const probability = this instanceof ConfigurableKnapsackSelector ? (this as ConfigurableKnapsackSelector).inclusionProbability : 0.5; if (Math.random() < probability || currentValue < targetValue) { selected.push(utxo); currentValue += utxo.value; // Early exit if we've exceeded target significantly if (currentValue >= targetValue * 3) { break; } } } // Calculate fee for this selection const fee = this.estimateFee(selected.length, 2, options.feeRate); const requiredTotal = targetValue + fee; // Check if this selection meets our requirements if (currentValue >= requiredTotal) { const excess = currentValue - requiredTotal; // Prefer selections with less excess (less change) // But only if the change is above dust threshold const isUsableChange = excess === 0 || excess >= this.MIN_CHANGE_THRESHOLD; if (isUsableChange && excess < bestExcess) { bestSelection = [...selected]; // Create a copy bestValue = currentValue; bestExcess = excess; // Perfect match found, no need to continue if (excess === 0) { break; } // Good enough match (within 5% of target) if (excess < targetValue * 0.05) { break; } } } } // Fallback: If no good solution found, try accumulative approach if (bestSelection.length === 0) { bestSelection = this.accumulativeSelection( sortedUtxos, targetValue + this.estimateFee(3, 2, options.feeRate), ); if (bestSelection.length > 0) { bestValue = this.sumValues(bestSelection); const fee = this.estimateFee(bestSelection.length, 2, options.feeRate); bestExcess = bestValue - (targetValue + fee); } } // Return best solution found if (bestSelection.length > 0 && bestValue > 0) { const fee = this.estimateFee(bestSelection.length, 2, options.feeRate); const hasChange = bestExcess >= this.MIN_CHANGE_THRESHOLD; const change = hasChange ? bestExcess : 0; return createSelectionSuccess( bestSelection, bestValue, change, fee, { outputCount: hasChange ? 2 : 1 }, ); } return createSelectionFailure( SelectionFailureReason.NO_SOLUTION_FOUND, 'Knapsack algorithm could not find a suitable UTXO combination', { availableBalance: totalAvailable, requiredAmount: targetValue, utxoCount: sortedUtxos.length, attemptedStrategies: ['exact_match', 'stochastic', 'accumulative'], }, ); } /** * Try to find an exact match for the target amount plus fees */ private findExactMatch(utxos: UTXO[], target: number, feeRate: number): UTXO[] { // Try single UTXO exact match (target + fee for 1 input, 1 output) const fee1In1Out = this.estimateFee(1, 1, feeRate); const fee1In2Out = this.estimateFee(1, 2, feeRate); const exactUtxo = utxos.find((utxo) => utxo.value === target + fee1In1Out || utxo.value === target + fee1In2Out ); if (exactUtxo) { return [exactUtxo]; } // Try two UTXO exact match (common case) const fee2In1Out = this.estimateFee(2, 1, feeRate); const fee2In2Out = this.estimateFee(2, 2, feeRate); for (let i = 0; i < utxos.length; i++) { for (let j = i + 1; j < utxos.length; j++) { const utxoI = utxos[i]; const utxoJ = utxos[j]; if (utxoI && utxoJ) { const combined = utxoI.value + utxoJ.value; if (combined === target + fee2In1Out || combined === target + fee2In2Out) { return [utxoI, utxoJ]; } } } } return []; } /** * Simple accumulative selection as fallback */ private accumulativeSelection(utxos: UTXO[], target: number): UTXO[] { const selected: UTXO[] = []; let total = 0; for (const utxo of utxos) { selected.push(utxo); total += utxo.value; if (total >= target) { return selected; } } return total >= target ? selected : []; } /** * Sum values of UTXOs */ private sumValues(utxos: UTXO[]): number { return this.sumUTXOs(utxos); } getName(): string { return 'knapsack'; } } /** * Enhanced Knapsack with configurable parameters */ export class ConfigurableKnapsackSelector extends KnapsackSelector { public readonly inclusionProbability: number; constructor( options: { iterations?: number; inclusionProbability?: number; } = {}, ) { super(); this.MAX_ITERATIONS = options.iterations || 1000; this.inclusionProbability = options.inclusionProbability || 0.5; } override getName(): string { return `knapsack-${this.MAX_ITERATIONS}-${this.inclusionProbability}`; } }