UNPKG

@xchainjs/xchain-utxo

Version:
1,157 lines (1,140 loc) 76.5 kB
'use strict'; var xchainClient = require('@xchainjs/xchain-client'); var xchainUtil = require('@xchainjs/xchain-util'); /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * UTXO-specific error codes for better error handling and debugging */ exports.UtxoErrorCode = void 0; (function (UtxoErrorCode) { UtxoErrorCode["INSUFFICIENT_BALANCE"] = "INSUFFICIENT_BALANCE"; UtxoErrorCode["INVALID_ADDRESS"] = "INVALID_ADDRESS"; UtxoErrorCode["INVALID_AMOUNT"] = "INVALID_AMOUNT"; UtxoErrorCode["INVALID_FEE_RATE"] = "INVALID_FEE_RATE"; UtxoErrorCode["INVALID_MEMO"] = "INVALID_MEMO"; UtxoErrorCode["INVALID_UTXO"] = "INVALID_UTXO"; UtxoErrorCode["PROVIDER_ERROR"] = "PROVIDER_ERROR"; UtxoErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR"; UtxoErrorCode["VALIDATION_ERROR"] = "VALIDATION_ERROR"; UtxoErrorCode["TRANSACTION_TOO_LARGE"] = "TRANSACTION_TOO_LARGE"; UtxoErrorCode["UTXO_SELECTION_FAILED"] = "UTXO_SELECTION_FAILED"; UtxoErrorCode["BROADCAST_ERROR"] = "BROADCAST_ERROR"; UtxoErrorCode["SIGNING_ERROR"] = "SIGNING_ERROR"; })(exports.UtxoErrorCode || (exports.UtxoErrorCode = {})); /** * Enhanced error class for UTXO operations with detailed context */ class UtxoError extends Error { constructor(code, message, details) { super(message); this.code = code; this.details = details; this.isUtxoError = true; this.name = 'UtxoError'; // Ensure proper prototype chain for instanceof checks Object.setPrototypeOf(this, UtxoError.prototype); } /** * Create an insufficient balance error */ static insufficientBalance(required, available, chain) { return new UtxoError(exports.UtxoErrorCode.INSUFFICIENT_BALANCE, `Insufficient balance: required ${required}, available ${available}`, { required, available, chain }); } /** * Create an invalid address error */ static invalidAddress(address, network) { return new UtxoError(exports.UtxoErrorCode.INVALID_ADDRESS, `Invalid address: ${address}${network ? ` for network ${network}` : ''}`, { address, network }); } /** * Create an invalid amount error */ static invalidAmount(amount, reason) { const message = `Invalid amount: ${amount}${reason ? ` (${reason})` : ''}`; return new UtxoError(exports.UtxoErrorCode.INVALID_AMOUNT, message, { amount, reason }); } /** * Create an invalid fee rate error */ static invalidFeeRate(feeRate, reason) { const message = `Invalid fee rate: ${feeRate}${reason ? ` (${reason})` : ''}`; return new UtxoError(exports.UtxoErrorCode.INVALID_FEE_RATE, message, { feeRate, reason }); } /** * Create an invalid memo error */ static invalidMemo(memo, reason) { return new UtxoError(exports.UtxoErrorCode.INVALID_MEMO, `Invalid memo: ${reason}`, { memo, reason }); } /** * Create a provider error */ static providerError(providerName, originalError) { return new UtxoError(exports.UtxoErrorCode.PROVIDER_ERROR, `Provider ${providerName} error: ${originalError.message}`, { providerName, originalError: originalError.stack, }); } /** * Create a network error */ static networkError(operation, originalError) { return new UtxoError(exports.UtxoErrorCode.NETWORK_ERROR, `Network error during ${operation}: ${originalError.message}`, { operation, originalError: originalError.stack, }); } /** * Create a validation error */ static validationError(message, details) { return new UtxoError(exports.UtxoErrorCode.VALIDATION_ERROR, `Validation failed: ${message}`, details); } /** * Create a transaction too large error */ static transactionTooLarge(currentSize, maxSize) { return new UtxoError(exports.UtxoErrorCode.TRANSACTION_TOO_LARGE, `Transaction size ${currentSize} bytes exceeds maximum ${maxSize} bytes`, { currentSize, maxSize }); } /** * Create a UTXO selection failed error */ static utxoSelectionFailed(targetAmount, availableAmount, strategy) { const message = `UTXO selection failed: need ${targetAmount}, have ${availableAmount}${strategy ? ` using ${strategy}` : ''}`; return new UtxoError(exports.UtxoErrorCode.UTXO_SELECTION_FAILED, message, { targetAmount, availableAmount, strategy }); } /** * Create a broadcast error */ static broadcastError(txHash, originalError) { return new UtxoError(exports.UtxoErrorCode.BROADCAST_ERROR, `Failed to broadcast transaction ${txHash}: ${originalError.message}`, { txHash, originalError: originalError.stack }); } /** * Create a signing error */ static signingError(reason, details) { return new UtxoError(exports.UtxoErrorCode.SIGNING_ERROR, `Transaction signing failed: ${reason}`, details); } /** * Convert unknown errors to typed UTXO errors */ static fromUnknown(error, context) { if (error instanceof UtxoError) { return error; } if (error instanceof Error) { // Try to categorize common error patterns const message = error.message.toLowerCase(); if (message.includes('insufficient')) { return UtxoError.insufficientBalance('unknown', 'unknown'); } if (message.includes('invalid address')) { return UtxoError.invalidAddress('unknown'); } if (message.includes('fee') && (message.includes('low') || message.includes('high'))) { return UtxoError.invalidFeeRate(0, error.message); } if (message.includes('network') || message.includes('timeout') || message.includes('connection')) { return UtxoError.networkError(context || 'unknown', error); } return new UtxoError(exports.UtxoErrorCode.VALIDATION_ERROR, context ? `${context}: ${error.message}` : error.message, { originalError: error.stack, }); } return new UtxoError(exports.UtxoErrorCode.VALIDATION_ERROR, context ? `${context}: ${String(error)}` : String(error), { originalError: error, }); } /** * Check if an error is a UTXO error */ static isUtxoError(error) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return error instanceof Error && error.isUtxoError === true; } /** * Get a user-friendly error message */ getUserFriendlyMessage() { switch (this.code) { case exports.UtxoErrorCode.INSUFFICIENT_BALANCE: return 'Insufficient balance to complete this transaction. Please check your balance and try again.'; case exports.UtxoErrorCode.INVALID_ADDRESS: return 'The provided address is not valid. Please check the address format and network.'; case exports.UtxoErrorCode.INVALID_AMOUNT: return 'The transaction amount is not valid. Amount must be greater than zero.'; case exports.UtxoErrorCode.INVALID_FEE_RATE: return 'The fee rate is not valid. Please provide a fee rate between 1 and 1000 sat/byte.'; case exports.UtxoErrorCode.INVALID_UTXO: return 'One or more UTXOs are invalid or inconsistent. Please refresh UTXOs and try again.'; case exports.UtxoErrorCode.UTXO_SELECTION_FAILED: return 'Could not select UTXOs for this transaction. You may have insufficient funds or too many small UTXOs.'; case exports.UtxoErrorCode.NETWORK_ERROR: return 'Network error occurred. Please check your connection and try again.'; case exports.UtxoErrorCode.PROVIDER_ERROR: return 'Blockchain data provider error. Please try again later.'; case exports.UtxoErrorCode.TRANSACTION_TOO_LARGE: return 'Transaction is too large. Try reducing the number of inputs or splitting into multiple transactions.'; default: return this.message; } } /** * Convert to JSON for logging */ toJSON() { return { name: this.name, code: this.code, message: this.message, details: this.details, stack: this.stack, }; } } /** * Standard UTXO constants used across the library */ /** * Bitcoin's standard dust threshold (546 satoshis) * * This is the minimum value for a UTXO to be considered economically spendable. * The value comes from Bitcoin Core's calculation: * - P2PKH output size: 34 bytes * - Cost to spend: 34 bytes × 3 sat/byte = 102 satoshis * - Dust threshold: 102 × 3 = 306 satoshis (theoretical) * - Bitcoin Core uses 546 for safety margin and worst-case scenarios * * Note: Different UTXO chains may have different dust thresholds */ const DUST_THRESHOLD = 546; // satoshis /** * Transaction size constants for fee calculation */ const TX_SIZE_CONSTANTS = { /** Base transaction overhead in virtual bytes */ BASE_TX_SIZE: 10, /** Approximate virtual bytes per P2WPKH input */ BYTES_PER_INPUT: 68, /** Virtual bytes per P2WPKH output */ BYTES_PER_OUTPUT: 31, }; /** * Maximum reasonable amount (21M BTC equivalent in satoshis) * Used as a sanity check for transaction amounts */ const MAX_REASONABLE_AMOUNT = '2100000000000000'; /** * Maximum decimal precision for UTXO-based cryptocurrencies * * Most UTXO chains (Bitcoin, Litecoin, Dogecoin, etc.) use 8 decimal places: * - 1 BTC = 100,000,000 satoshis (10^8) * - 1 LTC = 100,000,000 litoshi (10^8) * - 1 DOGE = 100,000,000 koinu (10^8) * * This differs from Ethereum tokens which can have up to 18 decimals */ const MAX_UTXO_DECIMALS = 8; /** * Comprehensive input validation for UTXO transactions */ class UtxoTransactionValidator { /** * Validate transaction parameters */ static validateTransferParams(params, feeBounds) { const errors = []; // Address validation - basic checks only // Chain-specific validation should be done by the respective client packages try { this.validateAddressBasic(params.recipient); } catch (error) { if (error instanceof UtxoError) { errors.push(`Invalid recipient address: ${error.message}`); } } if (params.sender) { try { this.validateAddressBasic(params.sender); } catch (error) { if (error instanceof UtxoError) { errors.push(`Invalid sender address: ${error.message}`); } } } // Amount validation if (!params.amount) { errors.push('Amount is required'); } else { try { const amount = params.amount.amount(); if (amount.lte(0)) { errors.push('Amount must be greater than zero'); } // Check for reasonable maximum (21M BTC equivalent in satoshis) if (amount.gt(MAX_REASONABLE_AMOUNT)) { errors.push('Amount exceeds maximum possible value'); } // Check for dust amount using the standard UTXO dust threshold if (amount.lt(DUST_THRESHOLD)) { errors.push(`Amount is below dust threshold (${DUST_THRESHOLD} satoshis)`); } // Validate decimal precision for UTXO chains const decimals = params.amount.decimal; if (decimals < 0 || decimals > MAX_UTXO_DECIMALS) { errors.push(`Invalid decimal precision: ${decimals} (UTXO chains support max ${MAX_UTXO_DECIMALS} decimals)`); } } catch (error) { errors.push(`Invalid amount format: ${error instanceof Error ? error.message : String(error)}`); } } // Fee rate validation if (params.feeRate !== undefined) { if (!Number.isFinite(params.feeRate)) { errors.push('Fee rate must be a finite number'); } else { const bounds = feeBounds || { lower: 1, upper: 1000 }; if (params.feeRate < bounds.lower) { errors.push(`Fee rate must be at least ${bounds.lower} sat/byte`); } else if (params.feeRate > bounds.upper) { errors.push(`Fee rate is unreasonably high (max ${bounds.upper} sat/byte)`); } } } // Memo validation (TypeScript ensures string type at compile time) if (params.memo !== undefined) { const memoBytes = Buffer.byteLength(params.memo, 'utf8'); if (memoBytes > 80) { errors.push(`Memo too long: ${memoBytes} bytes (max 80 bytes)`); } // Check for potentially problematic characters if (this.containsControlCharacters(params.memo)) { errors.push('Memo contains invalid control characters'); } // Check for null bytes if (params.memo.includes('\0')) { errors.push('Memo cannot contain null bytes'); } } // Wallet index validation if (params.walletIndex !== undefined) { if (!Number.isInteger(params.walletIndex)) { errors.push('Wallet index must be an integer'); } else if (params.walletIndex < 0) { errors.push('Wallet index must be non-negative'); } else if (params.walletIndex > 2147483647) { errors.push('Wallet index exceeds maximum non-hardened index (2^31-1)'); } } if (errors.length > 0) { throw UtxoError.validationError(errors.join('; '), { validationErrors: errors, params: this.sanitizeParamsForLogging(params), }); } } /** * Validate UTXO set for consistency and correctness */ static validateUtxoSet(utxos) { if (!Array.isArray(utxos)) { throw UtxoError.validationError('UTXOs must be an array'); } const errors = []; const seenOutpoints = new Set(); for (const [index, utxo] of utxos.entries()) { const prefix = `UTXO[${index}]`; // Validate UTXO structure if (!utxo || typeof utxo !== 'object') { errors.push(`${prefix}: UTXO must be an object`); continue; } // Validate transaction hash if (!utxo.hash) { errors.push(`${prefix}: Transaction hash is required`); } else if (typeof utxo.hash !== 'string') { errors.push(`${prefix}: Transaction hash must be a string`); } else if (!/^[a-fA-F0-9]{64}$/.test(utxo.hash)) { errors.push(`${prefix}: Invalid transaction hash format`); } // Validate output index if (utxo.index === undefined || utxo.index === null) { errors.push(`${prefix}: Output index is required`); } else if (!Number.isInteger(utxo.index)) { errors.push(`${prefix}: Output index must be an integer`); } else if (utxo.index < 0) { errors.push(`${prefix}: Output index must be non-negative`); } else if (utxo.index > 4294967295) { // uint32 max errors.push(`${prefix}: Output index exceeds maximum value`); } // Check for duplicate UTXOs const outpoint = `${utxo.hash}:${utxo.index}`; if (seenOutpoints.has(outpoint)) { errors.push(`${prefix}: Duplicate UTXO ${outpoint}`); } seenOutpoints.add(outpoint); // Validate value if (utxo.value === undefined || utxo.value === null) { errors.push(`${prefix}: UTXO value is required`); } else if (!Number.isInteger(utxo.value)) { errors.push(`${prefix}: UTXO value must be an integer`); } else if (utxo.value <= 0) { errors.push(`${prefix}: UTXO value must be positive`); } else if (utxo.value > 2100000000000000) { // 21M BTC in satoshis errors.push(`${prefix}: UTXO value exceeds maximum`); } // Note: height property not available in current UTXO type definition // Validate witness UTXO if present if (utxo.witnessUtxo) { if (typeof utxo.witnessUtxo !== 'object') { errors.push(`${prefix}: witnessUtxo must be an object`); } else { if (!Buffer.isBuffer(utxo.witnessUtxo.script)) { errors.push(`${prefix}: witnessUtxo script must be a Buffer`); } if (!Number.isInteger(utxo.witnessUtxo.value) || utxo.witnessUtxo.value !== utxo.value) { errors.push(`${prefix}: witnessUtxo value mismatch`); } } } // Note: nonWitnessUtxo property not available in current UTXO type definition } if (errors.length > 0) { throw UtxoError.validationError(`UTXO validation failed: ${errors.join('; ')}`, { utxoCount: utxos.length, validationErrors: errors, }); } } /** * Basic address validation - checks for null/empty and basic format * NOTE: Chain-specific validation should be done by the respective client packages */ static validateAddressBasic(address) { if (!address) { throw UtxoError.validationError('Address cannot be empty'); } if (address.trim() !== address) { throw UtxoError.validationError('Address cannot have leading or trailing whitespace'); } // Basic length check - most crypto addresses are between 25-100 characters if (address.length < 10 || address.length > 100) { throw UtxoError.validationError('Address length is invalid'); } } /** * Validate fee rate against network conditions */ static validateFeeRate(feeRate, networkConditions) { if (!Number.isFinite(feeRate)) { throw UtxoError.invalidFeeRate(feeRate, 'Fee rate must be a finite number'); } const conditions = networkConditions || {}; const minFee = conditions.minFeeRate || 1; const maxFee = conditions.maxFeeRate || 1000; if (feeRate < minFee) { throw UtxoError.invalidFeeRate(feeRate, `Below minimum fee rate ${minFee} sat/byte`); } if (feeRate > maxFee) { throw UtxoError.invalidFeeRate(feeRate, `Above maximum fee rate ${maxFee} sat/byte`); } // Warn if outside recommended range if (conditions.recommendedRange) { const [recMin, recMax] = conditions.recommendedRange; if (feeRate < recMin || feeRate > recMax) { console.warn(`Fee rate ${feeRate} is outside recommended range ${recMin}-${recMax} sat/byte`); } } } /** * Validate transaction size limits */ static validateTransactionSize(estimatedSize, maxSize = 100000) { if (estimatedSize <= 0) { throw UtxoError.validationError('Transaction size must be positive'); } if (estimatedSize > maxSize) { throw UtxoError.transactionTooLarge(estimatedSize, maxSize); } // Warn for large transactions if (estimatedSize > maxSize * 0.8) { console.warn(`Transaction size ${estimatedSize} bytes is approaching limit ${maxSize} bytes`); } } // Private helper methods static containsControlCharacters(str) { // Check for control characters that might cause issues // Ranges: 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, 0x7F for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if ((code >= 0x00 && code <= 0x08) || // \x00-\x08 code === 0x0b || // \x0B (vertical tab) code === 0x0c || // \x0C (form feed) (code >= 0x0e && code <= 0x1f) || // \x0E-\x1F code === 0x7f // \x7F (DEL) ) { return true; } } return false; } static sanitizeParamsForLogging(params) { // Remove sensitive data from params for safe logging const sanitized = Object.assign({}, params); if (params.amount) { sanitized.amount = params.amount.amount().toString(); } return sanitized; } } /** * Branch and Bound strategy - optimal for minimizing fees and change */ class BranchAndBoundStrategy { constructor() { this.name = 'BranchAndBound'; } select(utxos, targetValue, feeRate, extraOutputs = 1) { // Sort UTXOs by descending value for better branch and bound performance const sortedUtxos = [...utxos].sort((a, b) => b.value - a.value); // Try to find exact match first (pass targetValue, not target+fee to avoid double counting) const exactResult = this.findExactMatch(sortedUtxos, targetValue, feeRate, extraOutputs); if (exactResult) return exactResult; // Run branch and bound algorithm return this.branchAndBound(sortedUtxos, targetValue, feeRate, extraOutputs); } findExactMatch(utxos, targetValue, feeRate, extraOutputs) { // Calculate fee for single input (no change output) const singleInputFee = this.calculateFee(1, extraOutputs, feeRate); const requiredWithFee = targetValue + singleInputFee; // Sort by how close they are to the exact target const sortedByExactness = [...utxos].sort((a, b) => { const aDiff = Math.abs(a.value - requiredWithFee); const bDiff = Math.abs(b.value - requiredWithFee); return aDiff - bDiff; }); // Look for single UTXO that matches exactly or closely for (const utxo of sortedByExactness) { const change = utxo.value - requiredWithFee; // If change is exactly 0 or less than dust (which we absorb into fee) if (change >= 0 && change <= BranchAndBoundStrategy.DUST_THRESHOLD) { return { inputs: [utxo], changeAmount: 0, fee: singleInputFee + change, // Absorb dust into fee efficiency: 1.0, // Perfect efficiency for exact match strategy: this.name, }; } // If we have reasonable change (not too much excess) if (change > BranchAndBoundStrategy.DUST_THRESHOLD && change < targetValue * 0.1) { // Add change output fee const feeWithChange = this.calculateFee(1, extraOutputs + 1, feeRate); const finalChange = utxo.value - targetValue - feeWithChange; if (finalChange > BranchAndBoundStrategy.DUST_THRESHOLD) { return { inputs: [utxo], changeAmount: finalChange, fee: feeWithChange, efficiency: 0.95, // Very good efficiency strategy: this.name, }; } } } return null; } branchAndBound(utxos, targetValue, feeRate, extraOutputs) { const target = targetValue; let bestResult = null; let tries = 0; const search = (index, currentInputs, currentValue, depth) => { if (tries++ >= BranchAndBoundStrategy.MAX_TRIES) return; // Calculate current fee and requirements const inputCount = currentInputs.length; const outputCount = extraOutputs + (currentValue > target + this.calculateFee(inputCount, extraOutputs, feeRate) + BranchAndBoundStrategy.DUST_THRESHOLD ? 1 : 0); const fee = this.calculateFee(inputCount, outputCount, feeRate); const required = target + fee; // Check if we have a solution if (currentValue >= required) { const change = currentValue - required; const finalFee = change > BranchAndBoundStrategy.DUST_THRESHOLD ? fee : fee + change; const result = { inputs: [...currentInputs], changeAmount: change > BranchAndBoundStrategy.DUST_THRESHOLD ? change : 0, fee: finalFee, efficiency: this.calculateEfficiency(currentInputs, target, finalFee, change, change <= BranchAndBoundStrategy.DUST_THRESHOLD), strategy: this.name, }; if (!bestResult || result.fee < bestResult.fee) { bestResult = result; } return; } // Pruning conditions if (index >= utxos.length) return; if (depth > 50) return; // Prevent too deep recursion (supports wallets with many UTXOs) const remainingValue = utxos.slice(index).reduce((sum, utxo) => sum + utxo.value, 0); if (currentValue + remainingValue < required) return; // Not enough remaining value // Branch 1: Include current UTXO search(index + 1, [...currentInputs, utxos[index]], currentValue + utxos[index].value, depth + 1); // Branch 2: Exclude current UTXO (if we haven't found a good solution yet) if (!bestResult || currentInputs.length < 3) { search(index + 1, currentInputs, currentValue, depth + 1); } }; search(0, [], 0, 0); return bestResult; } calculateEfficiency(inputs, target, fee, change, changeAbsorbed = false) { const totalValue = inputs.reduce((sum, utxo) => sum + utxo.value, 0); const wastedValue = changeAbsorbed ? 0 : change > BranchAndBoundStrategy.DUST_THRESHOLD ? 0 : change; const efficiency = target / (totalValue + fee + wastedValue); return Math.min(1, efficiency); } /** * Calculate estimated transaction fee */ calculateFee(inputCount, outputCount, feeRate) { const txSize = BranchAndBoundStrategy.BASE_TX_SIZE + inputCount * BranchAndBoundStrategy.BYTES_PER_INPUT + outputCount * BranchAndBoundStrategy.BYTES_PER_OUTPUT; return Math.ceil(txSize * feeRate); } } BranchAndBoundStrategy.MAX_TRIES = 100000; BranchAndBoundStrategy.DUST_THRESHOLD = DUST_THRESHOLD; BranchAndBoundStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT; BranchAndBoundStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT; BranchAndBoundStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE; /** * Single Random Draw strategy - good for privacy */ class SingleRandomDrawStrategy { constructor() { this.name = 'SingleRandomDraw'; } select(utxos, targetValue, feeRate, extraOutputs = 1) { // Shuffle UTXOs using Fisher-Yates for unbiased randomness const shuffledUtxos = [...utxos]; for (let i = shuffledUtxos.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffledUtxos[i], shuffledUtxos[j]] = [shuffledUtxos[j], shuffledUtxos[i]]; } for (const utxo of shuffledUtxos) { // Calculate fee without change output first const feeNoChange = this.calculateFee(1, extraOutputs, feeRate); const requiredNoChange = targetValue + feeNoChange; if (utxo.value >= requiredNoChange) { const potentialChange = utxo.value - requiredNoChange; // Check if change would be above dust threshold if (potentialChange > SingleRandomDrawStrategy.DUST_THRESHOLD) { // Recalculate fee WITH change output const feeWithChange = this.calculateFee(1, extraOutputs + 1, feeRate); const requiredWithChange = targetValue + feeWithChange; const actualChange = utxo.value - requiredWithChange; // Verify change is still above dust after recalculation if (actualChange > SingleRandomDrawStrategy.DUST_THRESHOLD) { return { inputs: [utxo], changeAmount: actualChange, fee: feeWithChange, efficiency: targetValue / utxo.value, strategy: this.name, }; } } // No change output - add dust to fee return { inputs: [utxo], changeAmount: 0, fee: feeNoChange + potentialChange, efficiency: targetValue / utxo.value, strategy: this.name, }; } } return null; } /** * Calculate estimated transaction fee */ calculateFee(inputCount, outputCount, feeRate) { const txSize = SingleRandomDrawStrategy.BASE_TX_SIZE + inputCount * SingleRandomDrawStrategy.BYTES_PER_INPUT + outputCount * SingleRandomDrawStrategy.BYTES_PER_OUTPUT; return Math.ceil(txSize * feeRate); } } SingleRandomDrawStrategy.DUST_THRESHOLD = DUST_THRESHOLD; SingleRandomDrawStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT; SingleRandomDrawStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT; SingleRandomDrawStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE; /** * Accumulative strategy - simple and reliable fallback */ class AccumulativeStrategy { constructor() { this.name = 'Accumulative'; } select(utxos, targetValue, feeRate, extraOutputs = 1) { // Sort by value descending for faster accumulation const sortedUtxos = [...utxos].sort((a, b) => b.value - a.value); const selectedInputs = []; let currentValue = 0; for (const utxo of sortedUtxos) { selectedInputs.push(utxo); currentValue += utxo.value; // First check with no change output (minimum fee) const feeNoChange = this.calculateFee(selectedInputs.length, extraOutputs, feeRate); const requiredNoChange = targetValue + feeNoChange; if (currentValue >= requiredNoChange) { const potentialChange = currentValue - requiredNoChange; // Check if we need a change output if (potentialChange > AccumulativeStrategy.DUST_THRESHOLD) { // Recalculate with change output const feeWithChange = this.calculateFee(selectedInputs.length, extraOutputs + 1, feeRate); const finalChange = currentValue - targetValue - feeWithChange; // Verify change is still above dust after recalculation if (finalChange > AccumulativeStrategy.DUST_THRESHOLD) { return { inputs: [...selectedInputs], changeAmount: finalChange, fee: feeWithChange, efficiency: targetValue / currentValue, strategy: this.name, }; } } // No change output - absorb dust into fee return { inputs: [...selectedInputs], changeAmount: 0, fee: feeNoChange + potentialChange, efficiency: targetValue / currentValue, strategy: this.name, }; } } return null; } /** * Calculate estimated transaction fee */ calculateFee(inputCount, outputCount, feeRate) { const txSize = AccumulativeStrategy.BASE_TX_SIZE + inputCount * AccumulativeStrategy.BYTES_PER_INPUT + outputCount * AccumulativeStrategy.BYTES_PER_OUTPUT; return Math.ceil(txSize * feeRate); } } AccumulativeStrategy.DUST_THRESHOLD = DUST_THRESHOLD; AccumulativeStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT; AccumulativeStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT; AccumulativeStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE; /** * Largest First strategy - good for consolidation */ class LargestFirstStrategy { constructor() { this.name = 'LargestFirst'; } select(utxos, targetValue, feeRate, extraOutputs = 1) { // Sort by value descending const sortedUtxos = [...utxos].sort((a, b) => b.value - a.value); return new AccumulativeStrategy().select(sortedUtxos, targetValue, feeRate, extraOutputs); } } /** * Small First strategy - good for consolidating many small UTXOs */ class SmallFirstStrategy { constructor() { this.name = 'SmallFirst'; } select(utxos, targetValue, feeRate, extraOutputs = 1) { // Sort by value ascending to prioritize small UTXOs const sortedUtxos = [...utxos].sort((a, b) => a.value - b.value); const selectedInputs = []; let currentValue = 0; for (const utxo of sortedUtxos) { selectedInputs.push(utxo); currentValue += utxo.value; const fee = this.calculateFee(selectedInputs.length, extraOutputs + 1, feeRate); // Assume change output const required = targetValue + fee; if (currentValue >= required) { const change = currentValue - required; const hasChange = change > SmallFirstStrategy.DUST_THRESHOLD; const finalOutputs = hasChange ? extraOutputs + 1 : extraOutputs; const finalFee = this.calculateFee(selectedInputs.length, finalOutputs, feeRate); const finalChange = currentValue - targetValue - finalFee; return { inputs: [...selectedInputs], changeAmount: finalChange > SmallFirstStrategy.DUST_THRESHOLD ? finalChange : 0, fee: finalChange > SmallFirstStrategy.DUST_THRESHOLD ? finalFee : finalFee + finalChange, efficiency: targetValue / (currentValue + finalFee), strategy: this.name, }; } } return null; } /** * Calculate estimated transaction fee */ calculateFee(inputCount, outputCount, feeRate) { const txSize = SmallFirstStrategy.BASE_TX_SIZE + inputCount * SmallFirstStrategy.BYTES_PER_INPUT + outputCount * SmallFirstStrategy.BYTES_PER_OUTPUT; return Math.ceil(txSize * feeRate); } } SmallFirstStrategy.DUST_THRESHOLD = DUST_THRESHOLD; SmallFirstStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT; SmallFirstStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT; SmallFirstStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE; /** * Enhanced UTXO selector with multiple strategies */ class UtxoSelector { constructor() { this.strategies = [ new BranchAndBoundStrategy(), new SingleRandomDrawStrategy(), new AccumulativeStrategy(), new LargestFirstStrategy(), new SmallFirstStrategy(), ]; } /** * Select optimal UTXOs for a transaction */ selectOptimal(utxos, targetValue, feeRate, preferences = {}, extraOutputs = 1) { if (utxos.length === 0) { throw UtxoError.utxoSelectionFailed(targetValue, 0, 'no UTXOs available'); } // Validate inputs this.validateInputs(utxos, targetValue, feeRate); // Filter out dust UTXOs if requested const filteredUtxos = preferences.avoidDust ? utxos.filter((utxo) => utxo.value >= UtxoSelector.DUST_THRESHOLD) : utxos; if (filteredUtxos.length === 0) { throw UtxoError.utxoSelectionFailed(targetValue, 0, 'no non-dust UTXOs available'); } // Try each strategy and collect results const results = []; for (const strategy of this.strategies) { try { const result = strategy.select(filteredUtxos, targetValue, feeRate, extraOutputs); if (result && this.isValidResult(result, targetValue)) { results.push(result); } } catch (_a) { // Strategy failed, continue to next one } } if (results.length === 0) { const totalValue = filteredUtxos.reduce((sum, utxo) => sum + utxo.value, 0); throw UtxoError.utxoSelectionFailed(targetValue, totalValue); } // Select the best result based on preferences return this.selectBestResult(results, preferences); } /** * Select the best result based on preferences */ selectBestResult(results, preferences) { return results.reduce((best, current) => { const bestScore = this.calculateScore(best, preferences); const currentScore = this.calculateScore(current, preferences); return currentScore > bestScore ? current : best; }); } /** * Calculate a score for a result based on preferences */ calculateScore(result, preferences) { // Adjust base efficiency weight based on active preferences const hasSpecialPreference = preferences.minimizeInputs || preferences.minimizeChange; let score = result.efficiency * (hasSpecialPreference ? 0.1 : 0.3); // Minimize fee preference if (preferences.minimizeFee) { const feeScore = Math.max(0, 1 - result.fee / 100000); // Normalize fee score += feeScore * 0.3; } // Minimize inputs preference (privacy and transaction size) if (preferences.minimizeInputs) { if (result.inputs.length === 1) { score += 0.8; // Very high bonus for single input } else if (result.inputs.length === 2) { score += 0.3; // Moderate bonus for 2 inputs } else if (result.inputs.length === 3) { score += 0.1; // Small bonus for 3 inputs } else { // Heavily penalize many inputs when minimizeInputs is requested const inputPenalty = Math.min(0.7, (result.inputs.length - 1) * 0.2); score -= inputPenalty; } } // Minimize change preference (exact or minimal change) if (preferences.minimizeChange) { if (result.changeAmount === 0) { score += 0.8; // Perfect - no change needed (very high bonus) } else if (result.changeAmount < UtxoSelector.DUST_THRESHOLD) { score += 0.6; // Small change that might be added to fee } else if (result.changeAmount < 1000) { score += 0.4; // Very small change (< 1000 sats) } else if (result.changeAmount < 5000) { score += 0.1; // Small change (< 5000 sats) } else { // Heavily penalize large change amounts when minimizeChange is requested // Use much stronger penalty for larger change amounts const changePenalty = Math.min(0.9, result.changeAmount / 20000); // Very strong penalty score -= changePenalty; } } // Consolidate small UTXOs preference if (preferences.consolidateSmallUtxos) { const smallUtxoCount = result.inputs.filter((utxo) => utxo.value < 10000).length; if (smallUtxoCount > 0) { score += smallUtxoCount * 0.2; // Large bonus for each small UTXO consolidated } // Additional bonus for using multiple small UTXOs instead of one large one if (smallUtxoCount >= 3) { score += 0.3; // Extra bonus for consolidating 3+ small UTXOs } } return Math.max(0, Math.min(1, score)); } /** * Validate inputs for UTXO selection */ validateInputs(utxos, targetValue, feeRate) { if (targetValue <= 0) { throw UtxoError.invalidAmount(targetValue, 'Target value must be positive'); } if (feeRate <= 0) { throw UtxoError.invalidFeeRate(feeRate, 'Fee rate must be positive'); } const totalValue = utxos.reduce((sum, utxo) => sum + utxo.value, 0); if (totalValue < targetValue) { throw UtxoError.insufficientBalance(targetValue.toString(), totalValue.toString()); } } /** * Validate that a result is correct */ isValidResult(result, targetValue) { const inputSum = result.inputs.reduce((sum, utxo) => sum + utxo.value, 0); const expectedTotal = targetValue + result.fee + result.changeAmount; // Allow for small rounding differences return Math.abs(inputSum - expectedTotal) <= 1; } /** * Calculate estimated transaction fee */ static calculateFee(inputCount, outputCount, feeRate) { const txSize = UtxoSelector.BASE_TX_SIZE + inputCount * UtxoSelector.BYTES_PER_INPUT + outputCount * UtxoSelector.BYTES_PER_OUTPUT; return Math.ceil(txSize * feeRate); } } // Constants for calculations - re-exported from shared constants UtxoSelector.DUST_THRESHOLD = DUST_THRESHOLD; UtxoSelector.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT; UtxoSelector.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT; UtxoSelector.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE; /** * Abstract base class for creating blockchain clients in the UTXO model. */ class Client extends xchainClient.BaseXChainClient { /** * Constructor for creating a UTXO client instance. * * @param {Chain} chain The blockchain chain type. * @param {UtxoClientParams} params The parameters required for client initialization. */ constructor(chain, params) { super(chain, { network: params.network, rootDerivationPaths: params.rootDerivationPaths, phrase: params.phrase, feeBounds: params.feeBounds, }); this.explorerProviders = params.explorerProviders; this.dataProviders = params.dataProviders; } /** * Get the explorer URL based on the network. * * @returns {string} The explorer URL. */ getExplorerUrl() { return this.explorerProviders[this.network].getExplorerUrl(); } /** * Get the explorer URL for a given address based on the network. * * @param {string} address The address to query. * @returns {string} The explorer URL for the address. */ getExplorerAddressUrl(address) { return this.explorerProviders[this.network].getExplorerAddressUrl(address); } /** * Get the explorer URL for a given transaction ID based on the network. * * @param {string} txID The transaction ID. * @returns {string} The explorer URL for the transaction. */ getExplorerTxUrl(txID) { return this.explorerProviders[this.network].getExplorerTxUrl(txID); } /** * Get the transaction history of a given address with pagination options. * * @param {TxHistoryParams} params The options to get transaction history. * @returns {TxsPage} The transaction history. */ getTransactions(params) { return __awaiter(this, void 0, void 0, function* () { // Filter the parameters for transaction history const filteredParams = { address: (params === null || params === void 0 ? void 0 : params.address) || (yield this.getAddressAsync()), offset: params === null || params === void 0 ? void 0 : params.offset, limit: params === null || params === void 0 ? void 0 : params.limit, startTime: params === null || params === void 0 ? void 0 : params.startTime, asset: params === null || params === void 0 ? void 0 : params.asset, }; return yield this.roundRobinGetTransactions(filteredParams); }); } /** * Get the transaction details of a given transaction ID. * * @param {string} txId The transaction ID. * @returns {Tx} The transaction details. */ getTransactionData(txId) { return __awaiter(this, void 0, void 0, function* () { return yield this.roundRobinGetTransactionData(txId); }); } /** * Gets balance of a given address. * * @param {Address} address The address to get balances from * @param {undefined} Needed for legacy only to be in common with `XChainClient` interface - will be removed by a next version * @param {confirmedOnly} Flag to get balances of confirmed txs only * * @returns {Balance[]} BTC balances */ // TODO (@xchain-team|@veado) Change params to be an object to be extendable more easily // see changes for `xchain-bitcoin` https://github.com/xchainjs/xchainjs-lib/pull/490 getBalance(address, _assets /* not used */, _confirmedOnly) { return __awaiter(this, void 0, void 0, function* () { return yield this.roundRobinGetBalance(address); }); } /** * Scan UTXOs for a given address. * * @param {string} address The address to scan. * @param {boolean} confirmedOnly Flag to scan only confirmed UTXOs. * @returns {UTXO[]} The UTXOs found. */ scanUTXOs(address_1) { return __awaiter(this, arguments, void 0, function* (address, confirmedOnly = true) { return this.roundRobinGetUnspentTxs(address, confirmedOnly); }); } /** * Get UTXOs for a given address. * Public wrapper around the protected scanUTXOs method for coin control. * * @param {string} address The address to get UTXOs for. * @param {boolean} confirmedOnly Whether to only return confirmed UTXOs. Default: true. * @returns {Promise<UTXO[]>} The UTXOs for the address. */ getUTXOs(address_1) { return __awaiter(this, arguments, void 0, function* (address, confirmedOnly = true) { return this.scanUTXOs(address, confirmedOnly); }); } /** * Get estimated fees with fee rates. * * @param {FeeEstimateOptions} options Options for fee estimation. * @returns {Promise<FeesWithRates>} Estimated fees along with fee rates. */ getFeesWithRates(options) { return __awaiter(this, void 0, void 0, function* () { // Scan UTXOs if sender address is provided const utxos = (options === null || options === void 0 ? void 0 : options.sender) ? yield this.scanUTXOs(options.sender, false) : []; // Compile memo if memo is provided const compiledMemo = (options === null || options === void 0 ? void 0 : options.memo) ? this.compileMemo(options.memo) : null; // Get fee rates const r