@xchainjs/xchain-utxo 2.0.10 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.d.ts +118 -7
- package/lib/constants.d.ts +43 -0
- package/lib/errors.d.ts +91 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.esm.js +1232 -4
- package/lib/index.js +1243 -3
- package/lib/strategies/accumulative.d.ts +17 -0
- package/lib/strategies/branch-and-bound.d.ts +21 -0
- package/lib/strategies/index.d.ts +6 -0
- package/lib/strategies/largest-first.d.ts +9 -0
- package/lib/strategies/single-random-draw.d.ts +17 -0
- package/lib/strategies/small-first.d.ts +17 -0
- package/lib/strategies/types.d.ts +28 -0
- package/lib/utxo-selector.d.ts +37 -0
- package/lib/validators.d.ts +38 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -35,6 +35,995 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
|
|
|
35
35
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* UTXO-specific error codes for better error handling and debugging
|
|
40
|
+
*/
|
|
41
|
+
exports.UtxoErrorCode = void 0;
|
|
42
|
+
(function (UtxoErrorCode) {
|
|
43
|
+
UtxoErrorCode["INSUFFICIENT_BALANCE"] = "INSUFFICIENT_BALANCE";
|
|
44
|
+
UtxoErrorCode["INVALID_ADDRESS"] = "INVALID_ADDRESS";
|
|
45
|
+
UtxoErrorCode["INVALID_AMOUNT"] = "INVALID_AMOUNT";
|
|
46
|
+
UtxoErrorCode["INVALID_FEE_RATE"] = "INVALID_FEE_RATE";
|
|
47
|
+
UtxoErrorCode["INVALID_MEMO"] = "INVALID_MEMO";
|
|
48
|
+
UtxoErrorCode["INVALID_UTXO"] = "INVALID_UTXO";
|
|
49
|
+
UtxoErrorCode["PROVIDER_ERROR"] = "PROVIDER_ERROR";
|
|
50
|
+
UtxoErrorCode["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
51
|
+
UtxoErrorCode["VALIDATION_ERROR"] = "VALIDATION_ERROR";
|
|
52
|
+
UtxoErrorCode["TRANSACTION_TOO_LARGE"] = "TRANSACTION_TOO_LARGE";
|
|
53
|
+
UtxoErrorCode["UTXO_SELECTION_FAILED"] = "UTXO_SELECTION_FAILED";
|
|
54
|
+
UtxoErrorCode["BROADCAST_ERROR"] = "BROADCAST_ERROR";
|
|
55
|
+
UtxoErrorCode["SIGNING_ERROR"] = "SIGNING_ERROR";
|
|
56
|
+
})(exports.UtxoErrorCode || (exports.UtxoErrorCode = {}));
|
|
57
|
+
/**
|
|
58
|
+
* Enhanced error class for UTXO operations with detailed context
|
|
59
|
+
*/
|
|
60
|
+
class UtxoError extends Error {
|
|
61
|
+
constructor(code, message, details) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.details = details;
|
|
65
|
+
this.isUtxoError = true;
|
|
66
|
+
this.name = 'UtxoError';
|
|
67
|
+
// Ensure proper prototype chain for instanceof checks
|
|
68
|
+
Object.setPrototypeOf(this, UtxoError.prototype);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Create an insufficient balance error
|
|
72
|
+
*/
|
|
73
|
+
static insufficientBalance(required, available, chain) {
|
|
74
|
+
return new UtxoError(exports.UtxoErrorCode.INSUFFICIENT_BALANCE, `Insufficient balance: required ${required}, available ${available}`, { required, available, chain });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create an invalid address error
|
|
78
|
+
*/
|
|
79
|
+
static invalidAddress(address, network) {
|
|
80
|
+
return new UtxoError(exports.UtxoErrorCode.INVALID_ADDRESS, `Invalid address: ${address}${network ? ` for network ${network}` : ''}`, { address, network });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Create an invalid amount error
|
|
84
|
+
*/
|
|
85
|
+
static invalidAmount(amount, reason) {
|
|
86
|
+
const message = `Invalid amount: ${amount}${reason ? ` (${reason})` : ''}`;
|
|
87
|
+
return new UtxoError(exports.UtxoErrorCode.INVALID_AMOUNT, message, { amount, reason });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create an invalid fee rate error
|
|
91
|
+
*/
|
|
92
|
+
static invalidFeeRate(feeRate, reason) {
|
|
93
|
+
const message = `Invalid fee rate: ${feeRate}${reason ? ` (${reason})` : ''}`;
|
|
94
|
+
return new UtxoError(exports.UtxoErrorCode.INVALID_FEE_RATE, message, { feeRate, reason });
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Create an invalid memo error
|
|
98
|
+
*/
|
|
99
|
+
static invalidMemo(memo, reason) {
|
|
100
|
+
return new UtxoError(exports.UtxoErrorCode.INVALID_MEMO, `Invalid memo: ${reason}`, { memo, reason });
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Create a provider error
|
|
104
|
+
*/
|
|
105
|
+
static providerError(providerName, originalError) {
|
|
106
|
+
return new UtxoError(exports.UtxoErrorCode.PROVIDER_ERROR, `Provider ${providerName} error: ${originalError.message}`, {
|
|
107
|
+
providerName,
|
|
108
|
+
originalError: originalError.stack,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Create a network error
|
|
113
|
+
*/
|
|
114
|
+
static networkError(operation, originalError) {
|
|
115
|
+
return new UtxoError(exports.UtxoErrorCode.NETWORK_ERROR, `Network error during ${operation}: ${originalError.message}`, {
|
|
116
|
+
operation,
|
|
117
|
+
originalError: originalError.stack,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Create a validation error
|
|
122
|
+
*/
|
|
123
|
+
static validationError(message, details) {
|
|
124
|
+
return new UtxoError(exports.UtxoErrorCode.VALIDATION_ERROR, `Validation failed: ${message}`, details);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Create a transaction too large error
|
|
128
|
+
*/
|
|
129
|
+
static transactionTooLarge(currentSize, maxSize) {
|
|
130
|
+
return new UtxoError(exports.UtxoErrorCode.TRANSACTION_TOO_LARGE, `Transaction size ${currentSize} bytes exceeds maximum ${maxSize} bytes`, { currentSize, maxSize });
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Create a UTXO selection failed error
|
|
134
|
+
*/
|
|
135
|
+
static utxoSelectionFailed(targetAmount, availableAmount, strategy) {
|
|
136
|
+
const message = `UTXO selection failed: need ${targetAmount}, have ${availableAmount}${strategy ? ` using ${strategy}` : ''}`;
|
|
137
|
+
return new UtxoError(exports.UtxoErrorCode.UTXO_SELECTION_FAILED, message, { targetAmount, availableAmount, strategy });
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Create a broadcast error
|
|
141
|
+
*/
|
|
142
|
+
static broadcastError(txHash, originalError) {
|
|
143
|
+
return new UtxoError(exports.UtxoErrorCode.BROADCAST_ERROR, `Failed to broadcast transaction ${txHash}: ${originalError.message}`, { txHash, originalError: originalError.stack });
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Create a signing error
|
|
147
|
+
*/
|
|
148
|
+
static signingError(reason, details) {
|
|
149
|
+
return new UtxoError(exports.UtxoErrorCode.SIGNING_ERROR, `Transaction signing failed: ${reason}`, details);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Convert unknown errors to typed UTXO errors
|
|
153
|
+
*/
|
|
154
|
+
static fromUnknown(error, context) {
|
|
155
|
+
if (error instanceof UtxoError) {
|
|
156
|
+
return error;
|
|
157
|
+
}
|
|
158
|
+
if (error instanceof Error) {
|
|
159
|
+
// Try to categorize common error patterns
|
|
160
|
+
const message = error.message.toLowerCase();
|
|
161
|
+
if (message.includes('insufficient')) {
|
|
162
|
+
return UtxoError.insufficientBalance('unknown', 'unknown');
|
|
163
|
+
}
|
|
164
|
+
if (message.includes('invalid address')) {
|
|
165
|
+
return UtxoError.invalidAddress('unknown');
|
|
166
|
+
}
|
|
167
|
+
if (message.includes('fee') && (message.includes('low') || message.includes('high'))) {
|
|
168
|
+
return UtxoError.invalidFeeRate(0, error.message);
|
|
169
|
+
}
|
|
170
|
+
if (message.includes('network') || message.includes('timeout') || message.includes('connection')) {
|
|
171
|
+
return UtxoError.networkError(context || 'unknown', error);
|
|
172
|
+
}
|
|
173
|
+
return new UtxoError(exports.UtxoErrorCode.VALIDATION_ERROR, context ? `${context}: ${error.message}` : error.message, {
|
|
174
|
+
originalError: error.stack,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return new UtxoError(exports.UtxoErrorCode.VALIDATION_ERROR, context ? `${context}: ${String(error)}` : String(error), {
|
|
178
|
+
originalError: error,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Check if an error is a UTXO error
|
|
183
|
+
*/
|
|
184
|
+
static isUtxoError(error) {
|
|
185
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
186
|
+
return error instanceof Error && error.isUtxoError === true;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get a user-friendly error message
|
|
190
|
+
*/
|
|
191
|
+
getUserFriendlyMessage() {
|
|
192
|
+
switch (this.code) {
|
|
193
|
+
case exports.UtxoErrorCode.INSUFFICIENT_BALANCE:
|
|
194
|
+
return 'Insufficient balance to complete this transaction. Please check your balance and try again.';
|
|
195
|
+
case exports.UtxoErrorCode.INVALID_ADDRESS:
|
|
196
|
+
return 'The provided address is not valid. Please check the address format and network.';
|
|
197
|
+
case exports.UtxoErrorCode.INVALID_AMOUNT:
|
|
198
|
+
return 'The transaction amount is not valid. Amount must be greater than zero.';
|
|
199
|
+
case exports.UtxoErrorCode.INVALID_FEE_RATE:
|
|
200
|
+
return 'The fee rate is not valid. Please provide a fee rate between 1 and 1000 sat/byte.';
|
|
201
|
+
case exports.UtxoErrorCode.INVALID_UTXO:
|
|
202
|
+
return 'One or more UTXOs are invalid or inconsistent. Please refresh UTXOs and try again.';
|
|
203
|
+
case exports.UtxoErrorCode.UTXO_SELECTION_FAILED:
|
|
204
|
+
return 'Could not select UTXOs for this transaction. You may have insufficient funds or too many small UTXOs.';
|
|
205
|
+
case exports.UtxoErrorCode.NETWORK_ERROR:
|
|
206
|
+
return 'Network error occurred. Please check your connection and try again.';
|
|
207
|
+
case exports.UtxoErrorCode.PROVIDER_ERROR:
|
|
208
|
+
return 'Blockchain data provider error. Please try again later.';
|
|
209
|
+
case exports.UtxoErrorCode.TRANSACTION_TOO_LARGE:
|
|
210
|
+
return 'Transaction is too large. Try reducing the number of inputs or splitting into multiple transactions.';
|
|
211
|
+
default:
|
|
212
|
+
return this.message;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Convert to JSON for logging
|
|
217
|
+
*/
|
|
218
|
+
toJSON() {
|
|
219
|
+
return {
|
|
220
|
+
name: this.name,
|
|
221
|
+
code: this.code,
|
|
222
|
+
message: this.message,
|
|
223
|
+
details: this.details,
|
|
224
|
+
stack: this.stack,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Standard UTXO constants used across the library
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* Bitcoin's standard dust threshold (546 satoshis)
|
|
234
|
+
*
|
|
235
|
+
* This is the minimum value for a UTXO to be considered economically spendable.
|
|
236
|
+
* The value comes from Bitcoin Core's calculation:
|
|
237
|
+
* - P2PKH output size: 34 bytes
|
|
238
|
+
* - Cost to spend: 34 bytes × 3 sat/byte = 102 satoshis
|
|
239
|
+
* - Dust threshold: 102 × 3 = 306 satoshis (theoretical)
|
|
240
|
+
* - Bitcoin Core uses 546 for safety margin and worst-case scenarios
|
|
241
|
+
*
|
|
242
|
+
* Note: Different UTXO chains may have different dust thresholds
|
|
243
|
+
*/
|
|
244
|
+
const DUST_THRESHOLD = 546; // satoshis
|
|
245
|
+
/**
|
|
246
|
+
* Transaction size constants for fee calculation
|
|
247
|
+
*/
|
|
248
|
+
const TX_SIZE_CONSTANTS = {
|
|
249
|
+
/** Base transaction overhead in virtual bytes */
|
|
250
|
+
BASE_TX_SIZE: 10,
|
|
251
|
+
/** Approximate virtual bytes per P2WPKH input */
|
|
252
|
+
BYTES_PER_INPUT: 68,
|
|
253
|
+
/** Virtual bytes per P2WPKH output */
|
|
254
|
+
BYTES_PER_OUTPUT: 31,
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Maximum reasonable amount (21M BTC equivalent in satoshis)
|
|
258
|
+
* Used as a sanity check for transaction amounts
|
|
259
|
+
*/
|
|
260
|
+
const MAX_REASONABLE_AMOUNT = '2100000000000000';
|
|
261
|
+
/**
|
|
262
|
+
* Maximum decimal precision for UTXO-based cryptocurrencies
|
|
263
|
+
*
|
|
264
|
+
* Most UTXO chains (Bitcoin, Litecoin, Dogecoin, etc.) use 8 decimal places:
|
|
265
|
+
* - 1 BTC = 100,000,000 satoshis (10^8)
|
|
266
|
+
* - 1 LTC = 100,000,000 litoshi (10^8)
|
|
267
|
+
* - 1 DOGE = 100,000,000 koinu (10^8)
|
|
268
|
+
*
|
|
269
|
+
* This differs from Ethereum tokens which can have up to 18 decimals
|
|
270
|
+
*/
|
|
271
|
+
const MAX_UTXO_DECIMALS = 8;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Comprehensive input validation for UTXO transactions
|
|
275
|
+
*/
|
|
276
|
+
class UtxoTransactionValidator {
|
|
277
|
+
/**
|
|
278
|
+
* Validate transaction parameters
|
|
279
|
+
*/
|
|
280
|
+
static validateTransferParams(params, feeBounds) {
|
|
281
|
+
const errors = [];
|
|
282
|
+
// Address validation - basic checks only
|
|
283
|
+
// Chain-specific validation should be done by the respective client packages
|
|
284
|
+
try {
|
|
285
|
+
this.validateAddressBasic(params.recipient);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error instanceof UtxoError) {
|
|
289
|
+
errors.push(`Invalid recipient address: ${error.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (params.sender) {
|
|
293
|
+
try {
|
|
294
|
+
this.validateAddressBasic(params.sender);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof UtxoError) {
|
|
298
|
+
errors.push(`Invalid sender address: ${error.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Amount validation
|
|
303
|
+
if (!params.amount) {
|
|
304
|
+
errors.push('Amount is required');
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
try {
|
|
308
|
+
const amount = params.amount.amount();
|
|
309
|
+
if (amount.lte(0)) {
|
|
310
|
+
errors.push('Amount must be greater than zero');
|
|
311
|
+
}
|
|
312
|
+
// Check for reasonable maximum (21M BTC equivalent in satoshis)
|
|
313
|
+
if (amount.gt(MAX_REASONABLE_AMOUNT)) {
|
|
314
|
+
errors.push('Amount exceeds maximum possible value');
|
|
315
|
+
}
|
|
316
|
+
// Check for dust amount using the standard UTXO dust threshold
|
|
317
|
+
if (amount.lt(DUST_THRESHOLD)) {
|
|
318
|
+
errors.push(`Amount is below dust threshold (${DUST_THRESHOLD} satoshis)`);
|
|
319
|
+
}
|
|
320
|
+
// Validate decimal precision for UTXO chains
|
|
321
|
+
const decimals = params.amount.decimal;
|
|
322
|
+
if (decimals < 0 || decimals > MAX_UTXO_DECIMALS) {
|
|
323
|
+
errors.push(`Invalid decimal precision: ${decimals} (UTXO chains support max ${MAX_UTXO_DECIMALS} decimals)`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
errors.push(`Invalid amount format: ${error instanceof Error ? error.message : String(error)}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Fee rate validation
|
|
331
|
+
if (params.feeRate !== undefined) {
|
|
332
|
+
if (!Number.isFinite(params.feeRate)) {
|
|
333
|
+
errors.push('Fee rate must be a finite number');
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
const bounds = feeBounds || { lower: 1, upper: 1000 };
|
|
337
|
+
if (params.feeRate < bounds.lower) {
|
|
338
|
+
errors.push(`Fee rate must be at least ${bounds.lower} sat/byte`);
|
|
339
|
+
}
|
|
340
|
+
else if (params.feeRate > bounds.upper) {
|
|
341
|
+
errors.push(`Fee rate is unreasonably high (max ${bounds.upper} sat/byte)`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Memo validation (TypeScript ensures string type at compile time)
|
|
346
|
+
if (params.memo !== undefined) {
|
|
347
|
+
const memoBytes = Buffer.byteLength(params.memo, 'utf8');
|
|
348
|
+
if (memoBytes > 80) {
|
|
349
|
+
errors.push(`Memo too long: ${memoBytes} bytes (max 80 bytes)`);
|
|
350
|
+
}
|
|
351
|
+
// Check for potentially problematic characters
|
|
352
|
+
if (this.containsControlCharacters(params.memo)) {
|
|
353
|
+
errors.push('Memo contains invalid control characters');
|
|
354
|
+
}
|
|
355
|
+
// Check for null bytes
|
|
356
|
+
if (params.memo.includes('\0')) {
|
|
357
|
+
errors.push('Memo cannot contain null bytes');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Wallet index validation
|
|
361
|
+
if (params.walletIndex !== undefined) {
|
|
362
|
+
if (!Number.isInteger(params.walletIndex)) {
|
|
363
|
+
errors.push('Wallet index must be an integer');
|
|
364
|
+
}
|
|
365
|
+
else if (params.walletIndex < 0) {
|
|
366
|
+
errors.push('Wallet index must be non-negative');
|
|
367
|
+
}
|
|
368
|
+
else if (params.walletIndex > 2147483647) {
|
|
369
|
+
errors.push('Wallet index exceeds maximum non-hardened index (2^31-1)');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (errors.length > 0) {
|
|
373
|
+
throw UtxoError.validationError(errors.join('; '), {
|
|
374
|
+
validationErrors: errors,
|
|
375
|
+
params: this.sanitizeParamsForLogging(params),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Validate UTXO set for consistency and correctness
|
|
381
|
+
*/
|
|
382
|
+
static validateUtxoSet(utxos) {
|
|
383
|
+
if (!Array.isArray(utxos)) {
|
|
384
|
+
throw UtxoError.validationError('UTXOs must be an array');
|
|
385
|
+
}
|
|
386
|
+
const errors = [];
|
|
387
|
+
const seenOutpoints = new Set();
|
|
388
|
+
for (const [index, utxo] of utxos.entries()) {
|
|
389
|
+
const prefix = `UTXO[${index}]`;
|
|
390
|
+
// Validate UTXO structure
|
|
391
|
+
if (!utxo || typeof utxo !== 'object') {
|
|
392
|
+
errors.push(`${prefix}: UTXO must be an object`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
// Validate transaction hash
|
|
396
|
+
if (!utxo.hash) {
|
|
397
|
+
errors.push(`${prefix}: Transaction hash is required`);
|
|
398
|
+
}
|
|
399
|
+
else if (typeof utxo.hash !== 'string') {
|
|
400
|
+
errors.push(`${prefix}: Transaction hash must be a string`);
|
|
401
|
+
}
|
|
402
|
+
else if (!/^[a-fA-F0-9]{64}$/.test(utxo.hash)) {
|
|
403
|
+
errors.push(`${prefix}: Invalid transaction hash format`);
|
|
404
|
+
}
|
|
405
|
+
// Validate output index
|
|
406
|
+
if (utxo.index === undefined || utxo.index === null) {
|
|
407
|
+
errors.push(`${prefix}: Output index is required`);
|
|
408
|
+
}
|
|
409
|
+
else if (!Number.isInteger(utxo.index)) {
|
|
410
|
+
errors.push(`${prefix}: Output index must be an integer`);
|
|
411
|
+
}
|
|
412
|
+
else if (utxo.index < 0) {
|
|
413
|
+
errors.push(`${prefix}: Output index must be non-negative`);
|
|
414
|
+
}
|
|
415
|
+
else if (utxo.index > 4294967295) {
|
|
416
|
+
// uint32 max
|
|
417
|
+
errors.push(`${prefix}: Output index exceeds maximum value`);
|
|
418
|
+
}
|
|
419
|
+
// Check for duplicate UTXOs
|
|
420
|
+
const outpoint = `${utxo.hash}:${utxo.index}`;
|
|
421
|
+
if (seenOutpoints.has(outpoint)) {
|
|
422
|
+
errors.push(`${prefix}: Duplicate UTXO ${outpoint}`);
|
|
423
|
+
}
|
|
424
|
+
seenOutpoints.add(outpoint);
|
|
425
|
+
// Validate value
|
|
426
|
+
if (utxo.value === undefined || utxo.value === null) {
|
|
427
|
+
errors.push(`${prefix}: UTXO value is required`);
|
|
428
|
+
}
|
|
429
|
+
else if (!Number.isInteger(utxo.value)) {
|
|
430
|
+
errors.push(`${prefix}: UTXO value must be an integer`);
|
|
431
|
+
}
|
|
432
|
+
else if (utxo.value <= 0) {
|
|
433
|
+
errors.push(`${prefix}: UTXO value must be positive`);
|
|
434
|
+
}
|
|
435
|
+
else if (utxo.value > 2100000000000000) {
|
|
436
|
+
// 21M BTC in satoshis
|
|
437
|
+
errors.push(`${prefix}: UTXO value exceeds maximum`);
|
|
438
|
+
}
|
|
439
|
+
// Note: height property not available in current UTXO type definition
|
|
440
|
+
// Validate witness UTXO if present
|
|
441
|
+
if (utxo.witnessUtxo) {
|
|
442
|
+
if (typeof utxo.witnessUtxo !== 'object') {
|
|
443
|
+
errors.push(`${prefix}: witnessUtxo must be an object`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
if (!Buffer.isBuffer(utxo.witnessUtxo.script)) {
|
|
447
|
+
errors.push(`${prefix}: witnessUtxo script must be a Buffer`);
|
|
448
|
+
}
|
|
449
|
+
if (!Number.isInteger(utxo.witnessUtxo.value) || utxo.witnessUtxo.value !== utxo.value) {
|
|
450
|
+
errors.push(`${prefix}: witnessUtxo value mismatch`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Note: nonWitnessUtxo property not available in current UTXO type definition
|
|
455
|
+
}
|
|
456
|
+
if (errors.length > 0) {
|
|
457
|
+
throw UtxoError.validationError(`UTXO validation failed: ${errors.join('; ')}`, {
|
|
458
|
+
utxoCount: utxos.length,
|
|
459
|
+
validationErrors: errors,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Basic address validation - checks for null/empty and basic format
|
|
465
|
+
* NOTE: Chain-specific validation should be done by the respective client packages
|
|
466
|
+
*/
|
|
467
|
+
static validateAddressBasic(address) {
|
|
468
|
+
if (!address) {
|
|
469
|
+
throw UtxoError.validationError('Address cannot be empty');
|
|
470
|
+
}
|
|
471
|
+
if (address.trim() !== address) {
|
|
472
|
+
throw UtxoError.validationError('Address cannot have leading or trailing whitespace');
|
|
473
|
+
}
|
|
474
|
+
// Basic length check - most crypto addresses are between 25-100 characters
|
|
475
|
+
if (address.length < 10 || address.length > 100) {
|
|
476
|
+
throw UtxoError.validationError('Address length is invalid');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Validate fee rate against network conditions
|
|
481
|
+
*/
|
|
482
|
+
static validateFeeRate(feeRate, networkConditions) {
|
|
483
|
+
if (!Number.isFinite(feeRate)) {
|
|
484
|
+
throw UtxoError.invalidFeeRate(feeRate, 'Fee rate must be a finite number');
|
|
485
|
+
}
|
|
486
|
+
const conditions = networkConditions || {};
|
|
487
|
+
const minFee = conditions.minFeeRate || 1;
|
|
488
|
+
const maxFee = conditions.maxFeeRate || 1000;
|
|
489
|
+
if (feeRate < minFee) {
|
|
490
|
+
throw UtxoError.invalidFeeRate(feeRate, `Below minimum fee rate ${minFee} sat/byte`);
|
|
491
|
+
}
|
|
492
|
+
if (feeRate > maxFee) {
|
|
493
|
+
throw UtxoError.invalidFeeRate(feeRate, `Above maximum fee rate ${maxFee} sat/byte`);
|
|
494
|
+
}
|
|
495
|
+
// Warn if outside recommended range
|
|
496
|
+
if (conditions.recommendedRange) {
|
|
497
|
+
const [recMin, recMax] = conditions.recommendedRange;
|
|
498
|
+
if (feeRate < recMin || feeRate > recMax) {
|
|
499
|
+
console.warn(`Fee rate ${feeRate} is outside recommended range ${recMin}-${recMax} sat/byte`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Validate transaction size limits
|
|
505
|
+
*/
|
|
506
|
+
static validateTransactionSize(estimatedSize, maxSize = 100000) {
|
|
507
|
+
if (estimatedSize <= 0) {
|
|
508
|
+
throw UtxoError.validationError('Transaction size must be positive');
|
|
509
|
+
}
|
|
510
|
+
if (estimatedSize > maxSize) {
|
|
511
|
+
throw UtxoError.transactionTooLarge(estimatedSize, maxSize);
|
|
512
|
+
}
|
|
513
|
+
// Warn for large transactions
|
|
514
|
+
if (estimatedSize > maxSize * 0.8) {
|
|
515
|
+
console.warn(`Transaction size ${estimatedSize} bytes is approaching limit ${maxSize} bytes`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Private helper methods
|
|
519
|
+
static containsControlCharacters(str) {
|
|
520
|
+
// Check for control characters that might cause issues
|
|
521
|
+
// Ranges: 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, 0x7F
|
|
522
|
+
for (let i = 0; i < str.length; i++) {
|
|
523
|
+
const code = str.charCodeAt(i);
|
|
524
|
+
if ((code >= 0x00 && code <= 0x08) || // \x00-\x08
|
|
525
|
+
code === 0x0b || // \x0B (vertical tab)
|
|
526
|
+
code === 0x0c || // \x0C (form feed)
|
|
527
|
+
(code >= 0x0e && code <= 0x1f) || // \x0E-\x1F
|
|
528
|
+
code === 0x7f // \x7F (DEL)
|
|
529
|
+
) {
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
static sanitizeParamsForLogging(params) {
|
|
536
|
+
// Remove sensitive data from params for safe logging
|
|
537
|
+
const sanitized = Object.assign({}, params);
|
|
538
|
+
if (params.amount) {
|
|
539
|
+
sanitized.amount = params.amount.amount().toString();
|
|
540
|
+
}
|
|
541
|
+
return sanitized;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Branch and Bound strategy - optimal for minimizing fees and change
|
|
547
|
+
*/
|
|
548
|
+
class BranchAndBoundStrategy {
|
|
549
|
+
constructor() {
|
|
550
|
+
this.name = 'BranchAndBound';
|
|
551
|
+
}
|
|
552
|
+
select(utxos, targetValue, feeRate, extraOutputs = 1) {
|
|
553
|
+
// Sort UTXOs by descending value for better branch and bound performance
|
|
554
|
+
const sortedUtxos = [...utxos].sort((a, b) => b.value - a.value);
|
|
555
|
+
// Try to find exact match first (pass targetValue, not target+fee to avoid double counting)
|
|
556
|
+
const exactResult = this.findExactMatch(sortedUtxos, targetValue, feeRate, extraOutputs);
|
|
557
|
+
if (exactResult)
|
|
558
|
+
return exactResult;
|
|
559
|
+
// Run branch and bound algorithm
|
|
560
|
+
return this.branchAndBound(sortedUtxos, targetValue, feeRate, extraOutputs);
|
|
561
|
+
}
|
|
562
|
+
findExactMatch(utxos, targetValue, feeRate, extraOutputs) {
|
|
563
|
+
// Calculate fee for single input (no change output)
|
|
564
|
+
const singleInputFee = this.calculateFee(1, extraOutputs, feeRate);
|
|
565
|
+
const requiredWithFee = targetValue + singleInputFee;
|
|
566
|
+
// Sort by how close they are to the exact target
|
|
567
|
+
const sortedByExactness = [...utxos].sort((a, b) => {
|
|
568
|
+
const aDiff = Math.abs(a.value - requiredWithFee);
|
|
569
|
+
const bDiff = Math.abs(b.value - requiredWithFee);
|
|
570
|
+
return aDiff - bDiff;
|
|
571
|
+
});
|
|
572
|
+
// Look for single UTXO that matches exactly or closely
|
|
573
|
+
for (const utxo of sortedByExactness) {
|
|
574
|
+
const change = utxo.value - requiredWithFee;
|
|
575
|
+
// If change is exactly 0 or less than dust (which we absorb into fee)
|
|
576
|
+
if (change >= 0 && change <= BranchAndBoundStrategy.DUST_THRESHOLD) {
|
|
577
|
+
return {
|
|
578
|
+
inputs: [utxo],
|
|
579
|
+
changeAmount: 0,
|
|
580
|
+
fee: singleInputFee + change, // Absorb dust into fee
|
|
581
|
+
efficiency: 1.0, // Perfect efficiency for exact match
|
|
582
|
+
strategy: this.name,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
// If we have reasonable change (not too much excess)
|
|
586
|
+
if (change > BranchAndBoundStrategy.DUST_THRESHOLD && change < targetValue * 0.1) {
|
|
587
|
+
// Add change output fee
|
|
588
|
+
const feeWithChange = this.calculateFee(1, extraOutputs + 1, feeRate);
|
|
589
|
+
const finalChange = utxo.value - targetValue - feeWithChange;
|
|
590
|
+
if (finalChange > BranchAndBoundStrategy.DUST_THRESHOLD) {
|
|
591
|
+
return {
|
|
592
|
+
inputs: [utxo],
|
|
593
|
+
changeAmount: finalChange,
|
|
594
|
+
fee: feeWithChange,
|
|
595
|
+
efficiency: 0.95, // Very good efficiency
|
|
596
|
+
strategy: this.name,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
branchAndBound(utxos, targetValue, feeRate, extraOutputs) {
|
|
604
|
+
const target = targetValue;
|
|
605
|
+
let bestResult = null;
|
|
606
|
+
let tries = 0;
|
|
607
|
+
const search = (index, currentInputs, currentValue, depth) => {
|
|
608
|
+
if (tries++ >= BranchAndBoundStrategy.MAX_TRIES)
|
|
609
|
+
return;
|
|
610
|
+
// Calculate current fee and requirements
|
|
611
|
+
const inputCount = currentInputs.length;
|
|
612
|
+
const outputCount = extraOutputs +
|
|
613
|
+
(currentValue >
|
|
614
|
+
target + this.calculateFee(inputCount, extraOutputs, feeRate) + BranchAndBoundStrategy.DUST_THRESHOLD
|
|
615
|
+
? 1
|
|
616
|
+
: 0);
|
|
617
|
+
const fee = this.calculateFee(inputCount, outputCount, feeRate);
|
|
618
|
+
const required = target + fee;
|
|
619
|
+
// Check if we have a solution
|
|
620
|
+
if (currentValue >= required) {
|
|
621
|
+
const change = currentValue - required;
|
|
622
|
+
const finalFee = change > BranchAndBoundStrategy.DUST_THRESHOLD ? fee : fee + change;
|
|
623
|
+
const result = {
|
|
624
|
+
inputs: [...currentInputs],
|
|
625
|
+
changeAmount: change > BranchAndBoundStrategy.DUST_THRESHOLD ? change : 0,
|
|
626
|
+
fee: finalFee,
|
|
627
|
+
efficiency: this.calculateEfficiency(currentInputs, target, finalFee, change, change <= BranchAndBoundStrategy.DUST_THRESHOLD),
|
|
628
|
+
strategy: this.name,
|
|
629
|
+
};
|
|
630
|
+
if (!bestResult || result.fee < bestResult.fee) {
|
|
631
|
+
bestResult = result;
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Pruning conditions
|
|
636
|
+
if (index >= utxos.length)
|
|
637
|
+
return;
|
|
638
|
+
if (depth > 50)
|
|
639
|
+
return; // Prevent too deep recursion (supports wallets with many UTXOs)
|
|
640
|
+
const remainingValue = utxos.slice(index).reduce((sum, utxo) => sum + utxo.value, 0);
|
|
641
|
+
if (currentValue + remainingValue < required)
|
|
642
|
+
return; // Not enough remaining value
|
|
643
|
+
// Branch 1: Include current UTXO
|
|
644
|
+
search(index + 1, [...currentInputs, utxos[index]], currentValue + utxos[index].value, depth + 1);
|
|
645
|
+
// Branch 2: Exclude current UTXO (if we haven't found a good solution yet)
|
|
646
|
+
if (!bestResult || currentInputs.length < 3) {
|
|
647
|
+
search(index + 1, currentInputs, currentValue, depth + 1);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
search(0, [], 0, 0);
|
|
651
|
+
return bestResult;
|
|
652
|
+
}
|
|
653
|
+
calculateEfficiency(inputs, target, fee, change, changeAbsorbed = false) {
|
|
654
|
+
const totalValue = inputs.reduce((sum, utxo) => sum + utxo.value, 0);
|
|
655
|
+
const wastedValue = changeAbsorbed ? 0 : change > BranchAndBoundStrategy.DUST_THRESHOLD ? 0 : change;
|
|
656
|
+
const efficiency = target / (totalValue + fee + wastedValue);
|
|
657
|
+
return Math.min(1, efficiency);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Calculate estimated transaction fee
|
|
661
|
+
*/
|
|
662
|
+
calculateFee(inputCount, outputCount, feeRate) {
|
|
663
|
+
const txSize = BranchAndBoundStrategy.BASE_TX_SIZE +
|
|
664
|
+
inputCount * BranchAndBoundStrategy.BYTES_PER_INPUT +
|
|
665
|
+
outputCount * BranchAndBoundStrategy.BYTES_PER_OUTPUT;
|
|
666
|
+
return Math.ceil(txSize * feeRate);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
BranchAndBoundStrategy.MAX_TRIES = 100000;
|
|
670
|
+
BranchAndBoundStrategy.DUST_THRESHOLD = DUST_THRESHOLD;
|
|
671
|
+
BranchAndBoundStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT;
|
|
672
|
+
BranchAndBoundStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT;
|
|
673
|
+
BranchAndBoundStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Single Random Draw strategy - good for privacy
|
|
677
|
+
*/
|
|
678
|
+
class SingleRandomDrawStrategy {
|
|
679
|
+
constructor() {
|
|
680
|
+
this.name = 'SingleRandomDraw';
|
|
681
|
+
}
|
|
682
|
+
select(utxos, targetValue, feeRate, extraOutputs = 1) {
|
|
683
|
+
// Shuffle UTXOs using Fisher-Yates for unbiased randomness
|
|
684
|
+
const shuffledUtxos = [...utxos];
|
|
685
|
+
for (let i = shuffledUtxos.length - 1; i > 0; i--) {
|
|
686
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
687
|
+
[shuffledUtxos[i], shuffledUtxos[j]] = [shuffledUtxos[j], shuffledUtxos[i]];
|
|
688
|
+
}
|
|
689
|
+
for (const utxo of shuffledUtxos) {
|
|
690
|
+
// Calculate fee without change output first
|
|
691
|
+
const feeNoChange = this.calculateFee(1, extraOutputs, feeRate);
|
|
692
|
+
const requiredNoChange = targetValue + feeNoChange;
|
|
693
|
+
if (utxo.value >= requiredNoChange) {
|
|
694
|
+
const potentialChange = utxo.value - requiredNoChange;
|
|
695
|
+
// Check if change would be above dust threshold
|
|
696
|
+
if (potentialChange > SingleRandomDrawStrategy.DUST_THRESHOLD) {
|
|
697
|
+
// Recalculate fee WITH change output
|
|
698
|
+
const feeWithChange = this.calculateFee(1, extraOutputs + 1, feeRate);
|
|
699
|
+
const requiredWithChange = targetValue + feeWithChange;
|
|
700
|
+
const actualChange = utxo.value - requiredWithChange;
|
|
701
|
+
// Verify change is still above dust after recalculation
|
|
702
|
+
if (actualChange > SingleRandomDrawStrategy.DUST_THRESHOLD) {
|
|
703
|
+
return {
|
|
704
|
+
inputs: [utxo],
|
|
705
|
+
changeAmount: actualChange,
|
|
706
|
+
fee: feeWithChange,
|
|
707
|
+
efficiency: targetValue / utxo.value,
|
|
708
|
+
strategy: this.name,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
// No change output - add dust to fee
|
|
713
|
+
return {
|
|
714
|
+
inputs: [utxo],
|
|
715
|
+
changeAmount: 0,
|
|
716
|
+
fee: feeNoChange + potentialChange,
|
|
717
|
+
efficiency: targetValue / utxo.value,
|
|
718
|
+
strategy: this.name,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Calculate estimated transaction fee
|
|
726
|
+
*/
|
|
727
|
+
calculateFee(inputCount, outputCount, feeRate) {
|
|
728
|
+
const txSize = SingleRandomDrawStrategy.BASE_TX_SIZE +
|
|
729
|
+
inputCount * SingleRandomDrawStrategy.BYTES_PER_INPUT +
|
|
730
|
+
outputCount * SingleRandomDrawStrategy.BYTES_PER_OUTPUT;
|
|
731
|
+
return Math.ceil(txSize * feeRate);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
SingleRandomDrawStrategy.DUST_THRESHOLD = DUST_THRESHOLD;
|
|
735
|
+
SingleRandomDrawStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT;
|
|
736
|
+
SingleRandomDrawStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT;
|
|
737
|
+
SingleRandomDrawStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE;
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Accumulative strategy - simple and reliable fallback
|
|
741
|
+
*/
|
|
742
|
+
class AccumulativeStrategy {
|
|
743
|
+
constructor() {
|
|
744
|
+
this.name = 'Accumulative';
|
|
745
|
+
}
|
|
746
|
+
select(utxos, targetValue, feeRate, extraOutputs = 1) {
|
|
747
|
+
// Sort by value descending for faster accumulation
|
|
748
|
+
const sortedUtxos = [...utxos].sort((a, b) => b.value - a.value);
|
|
749
|
+
const selectedInputs = [];
|
|
750
|
+
let currentValue = 0;
|
|
751
|
+
for (const utxo of sortedUtxos) {
|
|
752
|
+
selectedInputs.push(utxo);
|
|
753
|
+
currentValue += utxo.value;
|
|
754
|
+
// First check with no change output (minimum fee)
|
|
755
|
+
const feeNoChange = this.calculateFee(selectedInputs.length, extraOutputs, feeRate);
|
|
756
|
+
const requiredNoChange = targetValue + feeNoChange;
|
|
757
|
+
if (currentValue >= requiredNoChange) {
|
|
758
|
+
const potentialChange = currentValue - requiredNoChange;
|
|
759
|
+
// Check if we need a change output
|
|
760
|
+
if (potentialChange > AccumulativeStrategy.DUST_THRESHOLD) {
|
|
761
|
+
// Recalculate with change output
|
|
762
|
+
const feeWithChange = this.calculateFee(selectedInputs.length, extraOutputs + 1, feeRate);
|
|
763
|
+
const finalChange = currentValue - targetValue - feeWithChange;
|
|
764
|
+
// Verify change is still above dust after recalculation
|
|
765
|
+
if (finalChange > AccumulativeStrategy.DUST_THRESHOLD) {
|
|
766
|
+
return {
|
|
767
|
+
inputs: [...selectedInputs],
|
|
768
|
+
changeAmount: finalChange,
|
|
769
|
+
fee: feeWithChange,
|
|
770
|
+
efficiency: targetValue / currentValue,
|
|
771
|
+
strategy: this.name,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
// No change output - absorb dust into fee
|
|
776
|
+
return {
|
|
777
|
+
inputs: [...selectedInputs],
|
|
778
|
+
changeAmount: 0,
|
|
779
|
+
fee: feeNoChange + potentialChange,
|
|
780
|
+
efficiency: targetValue / currentValue,
|
|
781
|
+
strategy: this.name,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Calculate estimated transaction fee
|
|
789
|
+
*/
|
|
790
|
+
calculateFee(inputCount, outputCount, feeRate) {
|
|
791
|
+
const txSize = AccumulativeStrategy.BASE_TX_SIZE +
|
|
792
|
+
inputCount * AccumulativeStrategy.BYTES_PER_INPUT +
|
|
793
|
+
outputCount * AccumulativeStrategy.BYTES_PER_OUTPUT;
|
|
794
|
+
return Math.ceil(txSize * feeRate);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
AccumulativeStrategy.DUST_THRESHOLD = DUST_THRESHOLD;
|
|
798
|
+
AccumulativeStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT;
|
|
799
|
+
AccumulativeStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT;
|
|
800
|
+
AccumulativeStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE;
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Largest First strategy - good for consolidation
|
|
804
|
+
*/
|
|
805
|
+
class LargestFirstStrategy {
|
|
806
|
+
constructor() {
|
|
807
|
+
this.name = 'LargestFirst';
|
|
808
|
+
}
|
|
809
|
+
select(utxos, targetValue, feeRate, extraOutputs = 1) {
|
|
810
|
+
// Sort by value descending
|
|
811
|
+
const sortedUtxos = [...utxos].sort((a, b) => b.value - a.value);
|
|
812
|
+
return new AccumulativeStrategy().select(sortedUtxos, targetValue, feeRate, extraOutputs);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Small First strategy - good for consolidating many small UTXOs
|
|
818
|
+
*/
|
|
819
|
+
class SmallFirstStrategy {
|
|
820
|
+
constructor() {
|
|
821
|
+
this.name = 'SmallFirst';
|
|
822
|
+
}
|
|
823
|
+
select(utxos, targetValue, feeRate, extraOutputs = 1) {
|
|
824
|
+
// Sort by value ascending to prioritize small UTXOs
|
|
825
|
+
const sortedUtxos = [...utxos].sort((a, b) => a.value - b.value);
|
|
826
|
+
const selectedInputs = [];
|
|
827
|
+
let currentValue = 0;
|
|
828
|
+
for (const utxo of sortedUtxos) {
|
|
829
|
+
selectedInputs.push(utxo);
|
|
830
|
+
currentValue += utxo.value;
|
|
831
|
+
const fee = this.calculateFee(selectedInputs.length, extraOutputs + 1, feeRate); // Assume change output
|
|
832
|
+
const required = targetValue + fee;
|
|
833
|
+
if (currentValue >= required) {
|
|
834
|
+
const change = currentValue - required;
|
|
835
|
+
const hasChange = change > SmallFirstStrategy.DUST_THRESHOLD;
|
|
836
|
+
const finalOutputs = hasChange ? extraOutputs + 1 : extraOutputs;
|
|
837
|
+
const finalFee = this.calculateFee(selectedInputs.length, finalOutputs, feeRate);
|
|
838
|
+
const finalChange = currentValue - targetValue - finalFee;
|
|
839
|
+
return {
|
|
840
|
+
inputs: [...selectedInputs],
|
|
841
|
+
changeAmount: finalChange > SmallFirstStrategy.DUST_THRESHOLD ? finalChange : 0,
|
|
842
|
+
fee: finalChange > SmallFirstStrategy.DUST_THRESHOLD ? finalFee : finalFee + finalChange,
|
|
843
|
+
efficiency: targetValue / (currentValue + finalFee),
|
|
844
|
+
strategy: this.name,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Calculate estimated transaction fee
|
|
852
|
+
*/
|
|
853
|
+
calculateFee(inputCount, outputCount, feeRate) {
|
|
854
|
+
const txSize = SmallFirstStrategy.BASE_TX_SIZE +
|
|
855
|
+
inputCount * SmallFirstStrategy.BYTES_PER_INPUT +
|
|
856
|
+
outputCount * SmallFirstStrategy.BYTES_PER_OUTPUT;
|
|
857
|
+
return Math.ceil(txSize * feeRate);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
SmallFirstStrategy.DUST_THRESHOLD = DUST_THRESHOLD;
|
|
861
|
+
SmallFirstStrategy.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT;
|
|
862
|
+
SmallFirstStrategy.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT;
|
|
863
|
+
SmallFirstStrategy.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE;
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Enhanced UTXO selector with multiple strategies
|
|
867
|
+
*/
|
|
868
|
+
class UtxoSelector {
|
|
869
|
+
constructor() {
|
|
870
|
+
this.strategies = [
|
|
871
|
+
new BranchAndBoundStrategy(),
|
|
872
|
+
new SingleRandomDrawStrategy(),
|
|
873
|
+
new AccumulativeStrategy(),
|
|
874
|
+
new LargestFirstStrategy(),
|
|
875
|
+
new SmallFirstStrategy(),
|
|
876
|
+
];
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Select optimal UTXOs for a transaction
|
|
880
|
+
*/
|
|
881
|
+
selectOptimal(utxos, targetValue, feeRate, preferences = {}, extraOutputs = 1) {
|
|
882
|
+
if (utxos.length === 0) {
|
|
883
|
+
throw UtxoError.utxoSelectionFailed(targetValue, 0, 'no UTXOs available');
|
|
884
|
+
}
|
|
885
|
+
// Validate inputs
|
|
886
|
+
this.validateInputs(utxos, targetValue, feeRate);
|
|
887
|
+
// Filter out dust UTXOs if requested
|
|
888
|
+
const filteredUtxos = preferences.avoidDust
|
|
889
|
+
? utxos.filter((utxo) => utxo.value >= UtxoSelector.DUST_THRESHOLD)
|
|
890
|
+
: utxos;
|
|
891
|
+
if (filteredUtxos.length === 0) {
|
|
892
|
+
throw UtxoError.utxoSelectionFailed(targetValue, 0, 'no non-dust UTXOs available');
|
|
893
|
+
}
|
|
894
|
+
// Try each strategy and collect results
|
|
895
|
+
const results = [];
|
|
896
|
+
for (const strategy of this.strategies) {
|
|
897
|
+
try {
|
|
898
|
+
const result = strategy.select(filteredUtxos, targetValue, feeRate, extraOutputs);
|
|
899
|
+
if (result && this.isValidResult(result, targetValue)) {
|
|
900
|
+
results.push(result);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch (_a) {
|
|
904
|
+
// Strategy failed, continue to next one
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (results.length === 0) {
|
|
908
|
+
const totalValue = filteredUtxos.reduce((sum, utxo) => sum + utxo.value, 0);
|
|
909
|
+
throw UtxoError.utxoSelectionFailed(targetValue, totalValue);
|
|
910
|
+
}
|
|
911
|
+
// Select the best result based on preferences
|
|
912
|
+
return this.selectBestResult(results, preferences);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Select the best result based on preferences
|
|
916
|
+
*/
|
|
917
|
+
selectBestResult(results, preferences) {
|
|
918
|
+
return results.reduce((best, current) => {
|
|
919
|
+
const bestScore = this.calculateScore(best, preferences);
|
|
920
|
+
const currentScore = this.calculateScore(current, preferences);
|
|
921
|
+
return currentScore > bestScore ? current : best;
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Calculate a score for a result based on preferences
|
|
926
|
+
*/
|
|
927
|
+
calculateScore(result, preferences) {
|
|
928
|
+
// Adjust base efficiency weight based on active preferences
|
|
929
|
+
const hasSpecialPreference = preferences.minimizeInputs || preferences.minimizeChange;
|
|
930
|
+
let score = result.efficiency * (hasSpecialPreference ? 0.1 : 0.3);
|
|
931
|
+
// Minimize fee preference
|
|
932
|
+
if (preferences.minimizeFee) {
|
|
933
|
+
const feeScore = Math.max(0, 1 - result.fee / 100000); // Normalize fee
|
|
934
|
+
score += feeScore * 0.3;
|
|
935
|
+
}
|
|
936
|
+
// Minimize inputs preference (privacy and transaction size)
|
|
937
|
+
if (preferences.minimizeInputs) {
|
|
938
|
+
if (result.inputs.length === 1) {
|
|
939
|
+
score += 0.8; // Very high bonus for single input
|
|
940
|
+
}
|
|
941
|
+
else if (result.inputs.length === 2) {
|
|
942
|
+
score += 0.3; // Moderate bonus for 2 inputs
|
|
943
|
+
}
|
|
944
|
+
else if (result.inputs.length === 3) {
|
|
945
|
+
score += 0.1; // Small bonus for 3 inputs
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
// Heavily penalize many inputs when minimizeInputs is requested
|
|
949
|
+
const inputPenalty = Math.min(0.7, (result.inputs.length - 1) * 0.2);
|
|
950
|
+
score -= inputPenalty;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// Minimize change preference (exact or minimal change)
|
|
954
|
+
if (preferences.minimizeChange) {
|
|
955
|
+
if (result.changeAmount === 0) {
|
|
956
|
+
score += 0.8; // Perfect - no change needed (very high bonus)
|
|
957
|
+
}
|
|
958
|
+
else if (result.changeAmount < UtxoSelector.DUST_THRESHOLD) {
|
|
959
|
+
score += 0.6; // Small change that might be added to fee
|
|
960
|
+
}
|
|
961
|
+
else if (result.changeAmount < 1000) {
|
|
962
|
+
score += 0.4; // Very small change (< 1000 sats)
|
|
963
|
+
}
|
|
964
|
+
else if (result.changeAmount < 5000) {
|
|
965
|
+
score += 0.1; // Small change (< 5000 sats)
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
// Heavily penalize large change amounts when minimizeChange is requested
|
|
969
|
+
// Use much stronger penalty for larger change amounts
|
|
970
|
+
const changePenalty = Math.min(0.9, result.changeAmount / 20000); // Very strong penalty
|
|
971
|
+
score -= changePenalty;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Consolidate small UTXOs preference
|
|
975
|
+
if (preferences.consolidateSmallUtxos) {
|
|
976
|
+
const smallUtxoCount = result.inputs.filter((utxo) => utxo.value < 10000).length;
|
|
977
|
+
if (smallUtxoCount > 0) {
|
|
978
|
+
score += smallUtxoCount * 0.2; // Large bonus for each small UTXO consolidated
|
|
979
|
+
}
|
|
980
|
+
// Additional bonus for using multiple small UTXOs instead of one large one
|
|
981
|
+
if (smallUtxoCount >= 3) {
|
|
982
|
+
score += 0.3; // Extra bonus for consolidating 3+ small UTXOs
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return Math.max(0, Math.min(1, score));
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Validate inputs for UTXO selection
|
|
989
|
+
*/
|
|
990
|
+
validateInputs(utxos, targetValue, feeRate) {
|
|
991
|
+
if (targetValue <= 0) {
|
|
992
|
+
throw UtxoError.invalidAmount(targetValue, 'Target value must be positive');
|
|
993
|
+
}
|
|
994
|
+
if (feeRate <= 0) {
|
|
995
|
+
throw UtxoError.invalidFeeRate(feeRate, 'Fee rate must be positive');
|
|
996
|
+
}
|
|
997
|
+
const totalValue = utxos.reduce((sum, utxo) => sum + utxo.value, 0);
|
|
998
|
+
if (totalValue < targetValue) {
|
|
999
|
+
throw UtxoError.insufficientBalance(targetValue.toString(), totalValue.toString());
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Validate that a result is correct
|
|
1004
|
+
*/
|
|
1005
|
+
isValidResult(result, targetValue) {
|
|
1006
|
+
const inputSum = result.inputs.reduce((sum, utxo) => sum + utxo.value, 0);
|
|
1007
|
+
const expectedTotal = targetValue + result.fee + result.changeAmount;
|
|
1008
|
+
// Allow for small rounding differences
|
|
1009
|
+
return Math.abs(inputSum - expectedTotal) <= 1;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Calculate estimated transaction fee
|
|
1013
|
+
*/
|
|
1014
|
+
static calculateFee(inputCount, outputCount, feeRate) {
|
|
1015
|
+
const txSize = UtxoSelector.BASE_TX_SIZE +
|
|
1016
|
+
inputCount * UtxoSelector.BYTES_PER_INPUT +
|
|
1017
|
+
outputCount * UtxoSelector.BYTES_PER_OUTPUT;
|
|
1018
|
+
return Math.ceil(txSize * feeRate);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// Constants for calculations - re-exported from shared constants
|
|
1022
|
+
UtxoSelector.DUST_THRESHOLD = DUST_THRESHOLD;
|
|
1023
|
+
UtxoSelector.BYTES_PER_INPUT = TX_SIZE_CONSTANTS.BYTES_PER_INPUT;
|
|
1024
|
+
UtxoSelector.BYTES_PER_OUTPUT = TX_SIZE_CONSTANTS.BYTES_PER_OUTPUT;
|
|
1025
|
+
UtxoSelector.BASE_TX_SIZE = TX_SIZE_CONSTANTS.BASE_TX_SIZE;
|
|
1026
|
+
|
|
38
1027
|
/**
|
|
39
1028
|
* Abstract base class for creating blockchain clients in the UTXO model.
|
|
40
1029
|
*/
|
|
@@ -124,7 +1113,6 @@ class Client extends xchainClient.BaseXChainClient {
|
|
|
124
1113
|
// see changes for `xchain-bitcoin` https://github.com/xchainjs/xchainjs-lib/pull/490
|
|
125
1114
|
getBalance(address, _assets /* not used */, _confirmedOnly) {
|
|
126
1115
|
return __awaiter(this, void 0, void 0, function* () {
|
|
127
|
-
// The actual logic for getting balances
|
|
128
1116
|
return yield this.roundRobinGetBalance(address);
|
|
129
1117
|
});
|
|
130
1118
|
}
|
|
@@ -140,6 +1128,19 @@ class Client extends xchainClient.BaseXChainClient {
|
|
|
140
1128
|
return this.roundRobinGetUnspentTxs(address, confirmedOnly);
|
|
141
1129
|
});
|
|
142
1130
|
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Get UTXOs for a given address.
|
|
1133
|
+
* Public wrapper around the protected scanUTXOs method for coin control.
|
|
1134
|
+
*
|
|
1135
|
+
* @param {string} address The address to get UTXOs for.
|
|
1136
|
+
* @param {boolean} confirmedOnly Whether to only return confirmed UTXOs. Default: true.
|
|
1137
|
+
* @returns {Promise<UTXO[]>} The UTXOs for the address.
|
|
1138
|
+
*/
|
|
1139
|
+
getUTXOs(address_1) {
|
|
1140
|
+
return __awaiter(this, arguments, void 0, function* (address, confirmedOnly = true) {
|
|
1141
|
+
return this.scanUTXOs(address, confirmedOnly);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
143
1144
|
/**
|
|
144
1145
|
* Get estimated fees with fee rates.
|
|
145
1146
|
*
|
|
@@ -190,7 +1191,7 @@ class Client extends xchainClient.BaseXChainClient {
|
|
|
190
1191
|
const feeRates = yield this.roundRobinGetFeeRates();
|
|
191
1192
|
return feeRates;
|
|
192
1193
|
}
|
|
193
|
-
catch (
|
|
1194
|
+
catch (_a) {
|
|
194
1195
|
console.warn('Can not retrieve fee rates from provider');
|
|
195
1196
|
}
|
|
196
1197
|
}
|
|
@@ -199,7 +1200,7 @@ class Client extends xchainClient.BaseXChainClient {
|
|
|
199
1200
|
const feeRate = yield this.getFeeRateFromThorchain();
|
|
200
1201
|
return xchainClient.standardFeeRates(feeRate);
|
|
201
1202
|
}
|
|
202
|
-
catch (
|
|
1203
|
+
catch (_b) {
|
|
203
1204
|
console.warn(`Can not retrieve fee rates from Thorchain`);
|
|
204
1205
|
}
|
|
205
1206
|
}
|
|
@@ -342,6 +1343,233 @@ class Client extends xchainClient.BaseXChainClient {
|
|
|
342
1343
|
throw Error('no provider able to BroadcastTx');
|
|
343
1344
|
});
|
|
344
1345
|
}
|
|
1346
|
+
// ==================== Enhanced UTXO Selection Methods ====================
|
|
1347
|
+
/**
|
|
1348
|
+
* Enhanced UTXO selection using the UtxoSelector with multiple strategies.
|
|
1349
|
+
* This method is available to all UTXO chain implementations.
|
|
1350
|
+
*
|
|
1351
|
+
* @param utxos Available UTXOs to select from
|
|
1352
|
+
* @param targetValue Target amount in satoshis
|
|
1353
|
+
* @param feeRate Fee rate in satoshis per byte
|
|
1354
|
+
* @param extraOutputs Number of extra outputs (default: 2 for recipient + change)
|
|
1355
|
+
* @param preferences Selection preferences
|
|
1356
|
+
* @returns Selection result with inputs, change, and fee
|
|
1357
|
+
*/
|
|
1358
|
+
selectUtxosForTransaction(utxos, targetValue, feeRate, extraOutputs = 2, preferences) {
|
|
1359
|
+
const selector = new UtxoSelector();
|
|
1360
|
+
const defaultPreferences = Object.assign({ minimizeFee: true, minimizeInputs: true, avoidDust: true }, preferences);
|
|
1361
|
+
try {
|
|
1362
|
+
return selector.selectOptimal(utxos, targetValue, feeRate, defaultPreferences, extraOutputs);
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
if (UtxoError.isUtxoError(error)) {
|
|
1366
|
+
throw error;
|
|
1367
|
+
}
|
|
1368
|
+
throw UtxoError.fromUnknown(error, 'UTXO selection');
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Validate transaction inputs using comprehensive validation.
|
|
1373
|
+
* Chain implementations can override to add chain-specific validation.
|
|
1374
|
+
*
|
|
1375
|
+
* @param params Transaction parameters including sender and feeRate
|
|
1376
|
+
*/
|
|
1377
|
+
validateTransactionInputs(params) {
|
|
1378
|
+
// Use comprehensive validator
|
|
1379
|
+
UtxoTransactionValidator.validateTransferParams(params, this.feeBounds);
|
|
1380
|
+
// Chain-specific address validation (uses abstract validateAddress)
|
|
1381
|
+
if (!this.validateAddress(params.recipient)) {
|
|
1382
|
+
throw UtxoError.invalidAddress(params.recipient, this.network);
|
|
1383
|
+
}
|
|
1384
|
+
if (params.sender && !this.validateAddress(params.sender)) {
|
|
1385
|
+
throw UtxoError.invalidAddress(params.sender, this.network);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Calculate maximum sendable amount by using ALL available UTXOs.
|
|
1390
|
+
*
|
|
1391
|
+
* For a true sweep operation, we use all UTXOs to maximize the sent amount.
|
|
1392
|
+
* This is simpler and more correct than binary search with UTXO selection.
|
|
1393
|
+
*
|
|
1394
|
+
* @param utxos Available UTXOs
|
|
1395
|
+
* @param feeRate Fee rate in satoshis per byte
|
|
1396
|
+
* @param hasMemo Whether transaction has a memo
|
|
1397
|
+
* @param preferences UTXO selection preferences
|
|
1398
|
+
* @returns Maximum sendable amount, fee, and selected inputs
|
|
1399
|
+
*/
|
|
1400
|
+
calculateMaxSendableAmount(utxos, feeRate, hasMemo, preferences) {
|
|
1401
|
+
// For sweep, use ALL UTXOs (optionally filter dust if preference set)
|
|
1402
|
+
const inputUtxos = (preferences === null || preferences === void 0 ? void 0 : preferences.avoidDust)
|
|
1403
|
+
? utxos.filter((utxo) => utxo.value >= UtxoSelector.DUST_THRESHOLD)
|
|
1404
|
+
: utxos;
|
|
1405
|
+
if (inputUtxos.length === 0) {
|
|
1406
|
+
throw UtxoError.insufficientBalance('max', '0', this.chain);
|
|
1407
|
+
}
|
|
1408
|
+
// Calculate total value of all inputs
|
|
1409
|
+
const totalValue = inputUtxos.reduce((sum, utxo) => sum + utxo.value, 0);
|
|
1410
|
+
// For max send: 1 output (recipient) + optional memo output, NO change output
|
|
1411
|
+
const outputCount = hasMemo ? 2 : 1;
|
|
1412
|
+
// Calculate fee for using all inputs
|
|
1413
|
+
const fee = UtxoSelector.calculateFee(inputUtxos.length, outputCount, feeRate);
|
|
1414
|
+
// Maximum sendable is total minus fee
|
|
1415
|
+
const maxAmount = totalValue - fee;
|
|
1416
|
+
if (maxAmount <= 0) {
|
|
1417
|
+
throw UtxoError.insufficientBalance('max', totalValue.toString(), this.chain);
|
|
1418
|
+
}
|
|
1419
|
+
// Verify amount is above dust threshold
|
|
1420
|
+
if (maxAmount < UtxoSelector.DUST_THRESHOLD) {
|
|
1421
|
+
throw UtxoError.invalidAmount(maxAmount, `below dust threshold of ${UtxoSelector.DUST_THRESHOLD} satoshis`);
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
amount: maxAmount,
|
|
1425
|
+
fee,
|
|
1426
|
+
inputs: inputUtxos,
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Get and validate UTXOs for transaction building.
|
|
1431
|
+
*
|
|
1432
|
+
* @param sender Sender address
|
|
1433
|
+
* @param confirmedOnly Whether to only use confirmed UTXOs
|
|
1434
|
+
* @returns Validated UTXO array
|
|
1435
|
+
*/
|
|
1436
|
+
getValidatedUtxos(sender, confirmedOnly) {
|
|
1437
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
1438
|
+
const utxos = yield this.scanUTXOs(sender, confirmedOnly);
|
|
1439
|
+
if (utxos.length === 0) {
|
|
1440
|
+
throw UtxoError.insufficientBalance('any', '0', this.chain);
|
|
1441
|
+
}
|
|
1442
|
+
// Validate UTXO set integrity
|
|
1443
|
+
UtxoTransactionValidator.validateUtxoSet(utxos);
|
|
1444
|
+
return utxos;
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Prepare transaction with enhanced UTXO selection.
|
|
1449
|
+
* Chain implementations should override buildTxPsbt to provide chain-specific PSBT construction.
|
|
1450
|
+
*
|
|
1451
|
+
* @param params Transaction parameters
|
|
1452
|
+
* @returns Prepared transaction with UTXO details
|
|
1453
|
+
*/
|
|
1454
|
+
prepareTxEnhanced(_a) {
|
|
1455
|
+
return __awaiter(this, arguments, void 0, function* ({ sender, memo, amount, recipient, spendPendingUTXO = true, feeRate, utxoSelectionPreferences, selectedUtxos, }) {
|
|
1456
|
+
try {
|
|
1457
|
+
// Comprehensive input validation
|
|
1458
|
+
this.validateTransactionInputs({
|
|
1459
|
+
amount,
|
|
1460
|
+
recipient,
|
|
1461
|
+
memo,
|
|
1462
|
+
sender,
|
|
1463
|
+
feeRate,
|
|
1464
|
+
});
|
|
1465
|
+
// Use provided UTXOs (coin control) or fetch from chain
|
|
1466
|
+
let utxos;
|
|
1467
|
+
if (selectedUtxos && selectedUtxos.length > 0) {
|
|
1468
|
+
UtxoTransactionValidator.validateUtxoSet(selectedUtxos);
|
|
1469
|
+
utxos = selectedUtxos;
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
const confirmedOnly = !spendPendingUTXO;
|
|
1473
|
+
utxos = yield this.getValidatedUtxos(sender, confirmedOnly);
|
|
1474
|
+
}
|
|
1475
|
+
const compiledMemo = memo ? this.compileMemo(memo) : null;
|
|
1476
|
+
const targetValue = amount.amount().toNumber();
|
|
1477
|
+
// Output count: recipient + change + optional memo
|
|
1478
|
+
const extraOutputs = 2 + (compiledMemo ? 1 : 0);
|
|
1479
|
+
// Enhanced UTXO selection
|
|
1480
|
+
const selectionResult = this.selectUtxosForTransaction(utxos, targetValue, Math.ceil(feeRate), extraOutputs, utxoSelectionPreferences);
|
|
1481
|
+
// Build PSBT using chain-specific implementation
|
|
1482
|
+
const rawUnsignedTx = yield this.buildTxPsbt({
|
|
1483
|
+
inputs: selectionResult.inputs,
|
|
1484
|
+
recipient,
|
|
1485
|
+
amount: targetValue,
|
|
1486
|
+
changeAmount: selectionResult.changeAmount,
|
|
1487
|
+
changeAddress: sender,
|
|
1488
|
+
memo: compiledMemo,
|
|
1489
|
+
});
|
|
1490
|
+
return { rawUnsignedTx, utxos, inputs: selectionResult.inputs };
|
|
1491
|
+
}
|
|
1492
|
+
catch (error) {
|
|
1493
|
+
if (UtxoError.isUtxoError(error)) {
|
|
1494
|
+
throw error;
|
|
1495
|
+
}
|
|
1496
|
+
throw UtxoError.fromUnknown(error, 'prepareTxEnhanced');
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Prepare maximum amount transfer (sweep transaction).
|
|
1502
|
+
*
|
|
1503
|
+
* @param params Send max parameters
|
|
1504
|
+
* @returns Prepared transaction with maximum sendable amount
|
|
1505
|
+
*/
|
|
1506
|
+
prepareMaxTx(_a) {
|
|
1507
|
+
return __awaiter(this, arguments, void 0, function* ({ sender, recipient, memo, feeRate, spendPendingUTXO = true, utxoSelectionPreferences, selectedUtxos, }) {
|
|
1508
|
+
try {
|
|
1509
|
+
// Basic validation
|
|
1510
|
+
if (!(recipient === null || recipient === void 0 ? void 0 : recipient.trim())) {
|
|
1511
|
+
throw UtxoError.invalidAddress(recipient, this.network);
|
|
1512
|
+
}
|
|
1513
|
+
if (!this.validateAddress(recipient)) {
|
|
1514
|
+
throw UtxoError.invalidAddress(recipient, this.network);
|
|
1515
|
+
}
|
|
1516
|
+
if (!this.validateAddress(sender)) {
|
|
1517
|
+
throw UtxoError.invalidAddress(sender, this.network);
|
|
1518
|
+
}
|
|
1519
|
+
// Validate fee rate
|
|
1520
|
+
if (typeof feeRate !== 'number' || !isFinite(feeRate) || feeRate <= 0) {
|
|
1521
|
+
throw UtxoError.invalidFeeRate(feeRate, 'Fee rate must be a positive finite number');
|
|
1522
|
+
}
|
|
1523
|
+
const validatedFeeRate = Math.ceil(feeRate);
|
|
1524
|
+
// Use provided UTXOs (coin control) or fetch from chain
|
|
1525
|
+
let utxos;
|
|
1526
|
+
if (selectedUtxos && selectedUtxos.length > 0) {
|
|
1527
|
+
UtxoTransactionValidator.validateUtxoSet(selectedUtxos);
|
|
1528
|
+
utxos = selectedUtxos;
|
|
1529
|
+
}
|
|
1530
|
+
else {
|
|
1531
|
+
const confirmedOnly = !spendPendingUTXO;
|
|
1532
|
+
utxos = yield this.getValidatedUtxos(sender, confirmedOnly);
|
|
1533
|
+
}
|
|
1534
|
+
// Calculate maximum sendable amount
|
|
1535
|
+
const maxCalc = this.calculateMaxSendableAmount(utxos, validatedFeeRate, !!memo, utxoSelectionPreferences);
|
|
1536
|
+
const compiledMemo = memo ? this.compileMemo(memo) : null;
|
|
1537
|
+
// Build PSBT using chain-specific implementation (no change for max send)
|
|
1538
|
+
const rawUnsignedTx = yield this.buildTxPsbt({
|
|
1539
|
+
inputs: maxCalc.inputs,
|
|
1540
|
+
recipient,
|
|
1541
|
+
amount: maxCalc.amount,
|
|
1542
|
+
changeAmount: 0,
|
|
1543
|
+
changeAddress: sender,
|
|
1544
|
+
memo: compiledMemo,
|
|
1545
|
+
});
|
|
1546
|
+
return {
|
|
1547
|
+
rawUnsignedTx,
|
|
1548
|
+
utxos,
|
|
1549
|
+
inputs: maxCalc.inputs,
|
|
1550
|
+
maxAmount: maxCalc.amount,
|
|
1551
|
+
fee: maxCalc.fee,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
catch (error) {
|
|
1555
|
+
if (UtxoError.isUtxoError(error)) {
|
|
1556
|
+
throw error;
|
|
1557
|
+
}
|
|
1558
|
+
throw UtxoError.fromUnknown(error, 'prepareMaxTx');
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Build a PSBT for this chain.
|
|
1564
|
+
* Chain implementations should override this to provide chain-specific PSBT construction.
|
|
1565
|
+
* Default implementation throws - override in chain client to enable enhanced methods.
|
|
1566
|
+
*
|
|
1567
|
+
* @param params PSBT build parameters
|
|
1568
|
+
* @returns Base64-encoded PSBT string
|
|
1569
|
+
*/
|
|
1570
|
+
buildTxPsbt(_params) {
|
|
1571
|
+
throw new Error(`buildTxPsbt not implemented for ${this.chain}. Override in chain client to use enhanced methods.`);
|
|
1572
|
+
}
|
|
345
1573
|
/**
|
|
346
1574
|
* Retrieves fee rates using a round-robin approach across multiple data providers.
|
|
347
1575
|
* @returns {Promise<FeeRates>} The fee rates (average, fast, fastest) in `Satoshis/byte`.
|
|
@@ -650,5 +1878,17 @@ function toBitcoinJS(chain, network) {
|
|
|
650
1878
|
return toBitcoinJSInner(config);
|
|
651
1879
|
}
|
|
652
1880
|
|
|
1881
|
+
exports.AccumulativeStrategy = AccumulativeStrategy;
|
|
1882
|
+
exports.BranchAndBoundStrategy = BranchAndBoundStrategy;
|
|
653
1883
|
exports.Client = Client;
|
|
1884
|
+
exports.DUST_THRESHOLD = DUST_THRESHOLD;
|
|
1885
|
+
exports.LargestFirstStrategy = LargestFirstStrategy;
|
|
1886
|
+
exports.MAX_REASONABLE_AMOUNT = MAX_REASONABLE_AMOUNT;
|
|
1887
|
+
exports.MAX_UTXO_DECIMALS = MAX_UTXO_DECIMALS;
|
|
1888
|
+
exports.SingleRandomDrawStrategy = SingleRandomDrawStrategy;
|
|
1889
|
+
exports.SmallFirstStrategy = SmallFirstStrategy;
|
|
1890
|
+
exports.TX_SIZE_CONSTANTS = TX_SIZE_CONSTANTS;
|
|
1891
|
+
exports.UtxoError = UtxoError;
|
|
1892
|
+
exports.UtxoSelector = UtxoSelector;
|
|
1893
|
+
exports.UtxoTransactionValidator = UtxoTransactionValidator;
|
|
654
1894
|
exports.toBitcoinJS = toBitcoinJS;
|