UNPKG

@btc-stamps/tx-builder

Version:

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

456 lines (394 loc) 14.7 kB
import type { UTXO } from '../interfaces/provider.interface.ts'; import type { SelectionOptions, SelectionResult as _SelectionResult, } 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'; /** * Output grouping criteria */ export interface OutputGroup { id: string; scriptType: string; address?: string; origin?: string; // Transaction that created these UTXOs utxos: UTXO[]; totalValue: number; effectiveValue: number; // Total value minus estimated fees } /** * Output Group UTXO Selection Algorithm * * Privacy-focused selection algorithm that groups UTXOs by common characteristics * to prevent address clustering attacks and maintain transaction graph privacy. * * This is the modern approach used by Bitcoin Core since 2018. * * Groups UTXOs by: * - Address/Script type (P2PKH, P2WPKH, P2SH, P2WSH, P2TR) * - Transaction origin (same transaction) * - Value ranges (dust, small, medium, large) * * Selection strategies by privacy level: * - High: Only use complete groups (highest privacy) * - Medium: Prefer complete groups, mix if necessary * - Low: Optimize for fees while respecting grouping */ export class OutputGroupSelector extends BaseSelector { private privacyLevel: 'high' | 'medium' | 'low'; private fallbackSelector?: BaseSelector; constructor( privacyLevel: 'high' | 'medium' | 'low' = 'medium', fallbackSelector?: BaseSelector, ) { super(); this.privacyLevel = privacyLevel; this.fallbackSelector = fallbackSelector; } getName(): string { return 'output-group'; } select(utxos: UTXO[], options: SelectionOptions): EnhancedSelectionResult { const targetValue = options.targetValue; const feeRate = options.feeRate; const dust = options.dustThreshold ?? this.DUST_THRESHOLD; const minConf = options.minConfirmations ?? 0; if (utxos.length === 0) { return this.createFailureResult( SelectionFailureReason.NO_UTXOS_AVAILABLE, 'No UTXOs available for selection', { utxoCount: 0 }, ); } // Filter unusable UTXOs const usableUtxos = utxos.filter((utxo) => utxo.value >= dust && (utxo.confirmations ?? 0) >= minConf ); if (usableUtxos.length === 0) { return this.createFailureResult( SelectionFailureReason.NO_UTXOS_AVAILABLE, 'No usable UTXOs after filtering', { originalCount: utxos.length, dustThreshold: options.dustThreshold }, ); } // Group UTXOs by characteristics const groups = this.createOutputGroups(usableUtxos, feeRate); // Sort groups by effective value (descending) const sortedGroups = groups.sort((a, b) => b.effectiveValue - a.effectiveValue); // Try different selection strategies based on privacy level let result: EnhancedSelectionResult; switch (this.privacyLevel) { case 'high': // High privacy: Try to use complete groups only result = this.selectCompleteGroups(sortedGroups, targetValue, options); break; case 'medium': // Medium privacy: Prefer complete groups, but allow partial if needed result = this.selectCompleteGroups(sortedGroups, targetValue, options); if (!result.success) { result = this.selectMixedGroups(sortedGroups, targetValue, options); } break; case 'low': // Low privacy: Optimize for fees while respecting groups result = this.selectOptimalGroups(sortedGroups, targetValue, options); break; default: result = this.createFailureResult( SelectionFailureReason.INVALID_OPTIONS, `Invalid privacy level: ${this.privacyLevel}`, { privacyLevel: this.privacyLevel }, ); } // Fallback to standard selection if grouping fails if (!result.success && this.fallbackSelector) { console.log('OutputGroupSelector: Falling back to standard selection'); result = this.fallbackSelector.select(usableUtxos, options); } return result; } /** * Create output groups from UTXOs */ private createOutputGroups(utxos: UTXO[], feeRate: number): OutputGroup[] { const groupMap = new Map<string, OutputGroup>(); for (const utxo of utxos) { // Determine group key based on characteristics const groupKey = this.getGroupKey(utxo); if (!groupMap.has(groupKey)) { const scriptType = this.getScriptType(utxo); groupMap.set(groupKey, { id: groupKey, scriptType, address: utxo.address, origin: utxo.txid, utxos: [], totalValue: 0, effectiveValue: 0, }); } const group = groupMap.get(groupKey)!; group.utxos.push(utxo); group.totalValue += utxo.value; // Calculate effective value (value minus cost to spend) const inputCost = this.estimateInputCost(utxo, feeRate); group.effectiveValue += Math.max(0, utxo.value - inputCost); } return Array.from(groupMap.values()).filter((group) => group.effectiveValue > 0); } /** * Generate group key for UTXO classification */ private getGroupKey(utxo: UTXO): string { // Group by script type and origin transaction const scriptType = this.getScriptType(utxo); const valueCategory = this.getValueCategory(utxo.value); // For maximum privacy, group by transaction // For efficiency, could also group by address or script type return `${scriptType}-${valueCategory}-${utxo.txid}`; } /** * Determine script type from UTXO */ private getScriptType(utxo: UTXO): string { // Simplified script type detection // In practice, would analyze the actual script if (utxo.address?.startsWith('bc1q')) return 'P2WPKH'; if (utxo.address?.startsWith('bc1p')) return 'P2TR'; if (utxo.address?.startsWith('3')) return 'P2SH'; if (utxo.address?.startsWith('1')) return 'P2PKH'; return 'unknown'; } /** * Categorize UTXO value for grouping */ private getValueCategory(value: number): string { if (value < 10000) return 'dust'; if (value < 100000) return 'small'; if (value < 1000000) return 'medium'; return 'large'; } /** * Estimate cost to spend an input */ private estimateInputCost(utxo: UTXO, feeRate: number): number { const scriptType = this.getScriptType(utxo); // Estimated input sizes in vbytes const inputSizes: Record<string, number> = { 'P2PKH': 148, 'P2WPKH': 68, 'P2SH': 91, // P2WPKH-in-P2SH 'P2WSH': 104, 'P2TR': 57, 'unknown': 100, // Conservative estimate }; const inputSize: number = (inputSizes[scriptType] ?? inputSizes.unknown) as number; return inputSize * feeRate; } /** * Select complete groups only (highest privacy) */ private selectCompleteGroups( groups: OutputGroup[], targetValue: number, options: SelectionOptions, ): EnhancedSelectionResult { const dust = options.dustThreshold ?? this.DUST_THRESHOLD; const selectedGroups: OutputGroup[] = []; let totalValue = 0; let totalInputs = 0; for (const group of groups) { if (totalValue >= targetValue) break; if (totalInputs + group.utxos.length > (options.maxInputs || 100)) break; selectedGroups.push(group); totalValue += group.totalValue; totalInputs += group.utxos.length; } if (totalValue < targetValue) { return this.createFailureResult( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Cannot meet target using complete groups only', { totalAvailable: totalValue, targetValue, privacyLevel: 'high' }, ); } // Calculate fee and change const allInputs = selectedGroups.flatMap((g) => g.utxos); const estimatedFee = this.estimateFee(allInputs.length, 2, options.feeRate); // 2 outputs (target + change) const change = totalValue - targetValue - estimatedFee; if (change < 0) { return this.createFailureResult( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Insufficient funds after fee calculation', { totalValue, targetValue, estimatedFee, shortfall: -change }, ); } return { success: true, inputs: allInputs, totalValue, change: Math.max(0, change), fee: estimatedFee, wasteMetric: this.computeWasteMetric(change, allInputs.length, options.feeRate), inputCount: allInputs.length, outputCount: change > dust ? 2 : 1, estimatedVSize: this.estimateVSize(allInputs.length, change > dust ? 2 : 1), effectiveFeeRate: estimatedFee / this.estimateVSize(allInputs.length, 2), }; } /** * Select mixed groups (medium privacy) */ private selectMixedGroups( groups: OutputGroup[], targetValue: number, options: SelectionOptions, ): EnhancedSelectionResult { const dust = options.dustThreshold ?? this.DUST_THRESHOLD; // Try combination of whole groups first, then add individual UTXOs if needed const selectedGroups: OutputGroup[] = []; const selectedUtxos: UTXO[] = []; let totalValue = 0; // First, add complete groups for (const group of groups) { if (totalValue >= targetValue) break; if (selectedUtxos.length + group.utxos.length > (options.maxInputs || 100)) break; selectedGroups.push(group); selectedUtxos.push(...group.utxos); totalValue += group.totalValue; } // If still not enough, add individual UTXOs from remaining groups if (totalValue < targetValue) { const remainingGroups = groups.filter((g) => !selectedGroups.includes(g)); const remainingUtxos = remainingGroups.flatMap((g) => g.utxos) .sort((a, b) => b.value - a.value); // Sort by value descending for (const utxo of remainingUtxos) { if (totalValue >= targetValue) break; if (selectedUtxos.length >= (options.maxInputs || 100)) break; selectedUtxos.push(utxo); totalValue += utxo.value; } } if (totalValue < targetValue) { return this.createFailureResult( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Cannot meet target with mixed group selection', { totalAvailable: totalValue, targetValue, privacyLevel: 'medium' }, ); } // Calculate fee and change const estimatedFee = this.estimateFee(selectedUtxos.length, 2, options.feeRate); const change = totalValue - targetValue - estimatedFee; if (change < 0) { return this.createFailureResult( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Insufficient funds after fee calculation', { totalValue, targetValue, estimatedFee, shortfall: -change }, ); } return { success: true, inputs: selectedUtxos, totalValue, change: Math.max(0, change), fee: estimatedFee, wasteMetric: this.computeWasteMetric(change, selectedUtxos.length, options.feeRate), inputCount: selectedUtxos.length, outputCount: change > dust ? 2 : 1, estimatedVSize: this.estimateVSize(selectedUtxos.length, change > dust ? 2 : 1), effectiveFeeRate: estimatedFee / this.estimateVSize(selectedUtxos.length, 2), }; } /** * Select optimal groups (low privacy, optimized for fees) */ private selectOptimalGroups( groups: OutputGroup[], targetValue: number, options: SelectionOptions, ): EnhancedSelectionResult { const dust = options.dustThreshold ?? this.DUST_THRESHOLD; // Sort groups by efficiency (effective value per UTXO) const efficientGroups = groups .map((group) => ({ ...group, efficiency: group.effectiveValue / group.utxos.length, })) .sort((a, b) => b.efficiency - a.efficiency); const selectedUtxos: UTXO[] = []; let totalValue = 0; for (const group of efficientGroups) { if (totalValue >= targetValue) break; // Add UTXOs from this group, but only what we need for (const utxo of group.utxos.sort((a, b) => b.value - a.value)) { if (totalValue >= targetValue) break; if (selectedUtxos.length >= (options.maxInputs || 100)) break; selectedUtxos.push(utxo); totalValue += utxo.value; } } if (totalValue < targetValue) { return this.createFailureResult( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Cannot meet target with optimal group selection', { totalAvailable: totalValue, targetValue, privacyLevel: 'low' }, ); } // Calculate fee and change const estimatedFee = this.estimateFee(selectedUtxos.length, 2, options.feeRate); const change = totalValue - targetValue - estimatedFee; if (change < 0) { return this.createFailureResult( SelectionFailureReason.INSUFFICIENT_FUNDS, 'Insufficient funds after fee calculation', { totalValue, targetValue, estimatedFee, shortfall: -change }, ); } return { success: true, inputs: selectedUtxos, totalValue, change: Math.max(0, change), fee: estimatedFee, wasteMetric: this.computeWasteMetric(change, selectedUtxos.length, options.feeRate), inputCount: selectedUtxos.length, outputCount: change > dust ? 2 : 1, estimatedVSize: this.estimateVSize(selectedUtxos.length, change > dust ? 2 : 1), effectiveFeeRate: estimatedFee / this.estimateVSize(selectedUtxos.length, 2), }; } /** * Create a structured failure result */ private createFailureResult( reason: SelectionFailureReason, message: string, details: Record<string, any> = {}, ): EnhancedSelectionResult { return { success: false, reason, message, details, }; } /** * Calculate waste metric */ private computeWasteMetric(change: number, _inputCount: number, feeRate: number): number { const changeCost = change > 0 ? 34 * feeRate : 0; // Cost of change output const excessCost = change * 0.01; // Small penalty for excess value return changeCost + excessCost; } /** * Estimate transaction virtual size */ private estimateVSize(inputCount: number, outputCount: number): number { // Simplified estimation assuming P2WPKH inputs and outputs const baseSize = 10; const inputSize = 68; // P2WPKH input const outputSize = 31; // P2WPKH output return baseSize + (inputCount * inputSize) + (outputCount * outputSize); } }