@dynamic-labs/bitcoin 4.53.1 → 4.54.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.
@@ -24,104 +24,234 @@ class PsbtBuilderService {
24
24
  constructor(mempoolApiService) {
25
25
  this.mempoolApiService = mempoolApiService;
26
26
  }
27
+ /**
28
+ * Filters out Taproot (P2TR) UTXOs to prevent accidental spending of Ordinals/Runes
29
+ * Since we only support Native SegWit (P2WPKH), we ensure the account address is not Taproot
30
+ * @param accountAddress - The account address to check
31
+ * @param utxos - Array of UTXOs to filter
32
+ * @returns Filtered array of UTXOs (only P2WPKH compatible)
33
+ */
34
+ filterTaprootUTXOs(accountAddress, utxos) {
35
+ // Safety check: Ensure account address is not Taproot (bc1p...)
36
+ if (accountAddress.toLowerCase().startsWith('bc1p') ||
37
+ accountAddress.toLowerCase().startsWith('tb1p')) {
38
+ logger.warn(`Account address ${accountAddress} appears to be Taproot. Only Native SegWit (P2WPKH) is supported.`);
39
+ throw new utils.DynamicError('Taproot addresses are not supported. Only Native SegWit (P2WPKH) addresses are allowed.');
40
+ }
41
+ return utxos;
42
+ }
43
+ /**
44
+ * Selects UTXOs using Largest-First (Accumulator) strategy
45
+ * Sorts UTXOs by value (descending) and selects until we have enough to cover amount + fees
46
+ * @param utxos - Available UTXOs
47
+ * @param targetAmount - Target amount including fees and dust limit
48
+ * @returns Selected UTXOs
49
+ */
50
+ selectUTXOsLargestFirst(utxos, targetAmount) {
51
+ // Sort UTXOs by value (largest first)
52
+ const sortedUTXOs = [...utxos].sort((a, b) => b.value - a.value);
53
+ // Accumulate UTXOs until we have enough
54
+ const selected = [];
55
+ let total = 0;
56
+ for (const utxo of sortedUTXOs) {
57
+ selected.push(utxo);
58
+ total += utxo.value;
59
+ // Stop when we have enough to cover the target amount
60
+ if (total >= targetAmount) {
61
+ break;
62
+ }
63
+ }
64
+ return selected;
65
+ }
66
+ /**
67
+ * Calculates the total value of UTXOs
68
+ * @param utxos - Array of UTXOs
69
+ * @returns Total value in satoshis
70
+ */
71
+ calculateUTXOTotal(utxos) {
72
+ return utxos.reduce((total, utxo) => total + utxo.value, 0);
73
+ }
74
+ /**
75
+ * Validates and ensures sufficient funds for the transaction
76
+ * @param selectedUTXOs - Initially selected UTXOs
77
+ * @param allUTXOs - All available UTXOs
78
+ * @param selectedTotal - Total value of selected UTXOs
79
+ * @param amountInSatoshisNumber - Transaction amount
80
+ * @param feeEstimate - Estimated fee
81
+ * @returns Validated selected UTXOs
82
+ * @throws {DynamicError} If insufficient funds
83
+ */
84
+ validateAndSelectUTXOs(selectedUTXOs, allUTXOs, selectedTotal, amountInSatoshisNumber, feeEstimate) {
85
+ const requiredAmount = amountInSatoshisNumber + feeEstimate;
86
+ if (selectedTotal >= requiredAmount) {
87
+ return selectedUTXOs;
88
+ }
89
+ // Try with all UTXOs if selection wasn't enough
90
+ if (selectedUTXOs.length < allUTXOs.length) {
91
+ const allTotal = this.calculateUTXOTotal(allUTXOs);
92
+ if (allTotal < requiredAmount) {
93
+ throw new utils.DynamicError(`Insufficient funds. Available: ${allTotal / _const.SATOSHIS_PER_BTC} BTC (${allTotal} satoshis), Required: ${amountInSatoshisNumber / _const.SATOSHIS_PER_BTC} BTC (${amountInSatoshisNumber} satoshis) + fees`);
94
+ }
95
+ return allUTXOs;
96
+ }
97
+ throw new utils.DynamicError(`Insufficient funds. Available: ${selectedTotal / _const.SATOSHIS_PER_BTC} BTC (${selectedTotal} satoshis), Required: ${amountInSatoshisNumber / _const.SATOSHIS_PER_BTC} BTC (${amountInSatoshisNumber} satoshis) + fees`);
98
+ }
99
+ /**
100
+ * Calculates fee estimate and change amount, handling dust limit
101
+ * @param accountAddress - Account address for fee estimation
102
+ * @param selectedUTXOs - Selected UTXOs
103
+ * @param selectedTotalValue - Total value of selected UTXOs
104
+ * @param amountInSatoshis - Transaction amount
105
+ * @param feePriority - Fee priority level
106
+ * @returns Object with feeEstimate, changeAmount, changeAmountNumber, and hasChangeOutput
107
+ */
108
+ calculateFeeAndChange(accountAddress, selectedUTXOs, selectedTotalValue, amountInSatoshis, feePriority) {
109
+ return _tslib.__awaiter(this, void 0, void 0, function* () {
110
+ // Re-estimate fee with actual number of inputs
111
+ let feeEstimate = yield this.mempoolApiService.estimateTransactionFee(accountAddress, selectedUTXOs.length, 1, // Start with 1 output (recipient only)
112
+ feePriority);
113
+ let maxToSpend = selectedTotalValue - feeEstimate;
114
+ let changeAmount = BigInt(maxToSpend) - amountInSatoshis;
115
+ // If change will be above dust limit, re-estimate fees for 2 outputs
116
+ const changeAmountNumber = Number(changeAmount);
117
+ if (changeAmount > 0 && changeAmountNumber >= _const.DUST_LIMIT) {
118
+ feeEstimate = yield this.mempoolApiService.estimateTransactionFee(accountAddress, selectedUTXOs.length, 2, // recipient + change output
119
+ feePriority);
120
+ maxToSpend = selectedTotalValue - feeEstimate;
121
+ changeAmount = BigInt(maxToSpend) - amountInSatoshis;
122
+ }
123
+ const finalChangeAmountNumber = Number(changeAmount);
124
+ const hasChangeOutput = changeAmount > 0 && finalChangeAmountNumber >= _const.DUST_LIMIT;
125
+ // Final fee adjustment: if change < dust limit, add it to fee
126
+ if (changeAmount > 0 && finalChangeAmountNumber < _const.DUST_LIMIT) {
127
+ feeEstimate += finalChangeAmountNumber;
128
+ }
129
+ return {
130
+ changeAmount,
131
+ changeAmountNumber: finalChangeAmountNumber,
132
+ feeEstimate,
133
+ hasChangeOutput,
134
+ };
135
+ });
136
+ }
137
+ /**
138
+ * Adds inputs to PSBT from selected UTXOs
139
+ * @param psbt - PSBT to add inputs to
140
+ * @param selectedUTXOs - Selected UTXOs
141
+ * @param publicKeyPair - ECPair public key pair
142
+ * @param network - Bitcoin network
143
+ */
144
+ addInputsToPsbt(psbt, selectedUTXOs, publicKeyPair, network) {
145
+ for (const utxo of selectedUTXOs) {
146
+ const outputScript = bitcoinjsLib.payments.p2wpkh({
147
+ network,
148
+ pubkey: publicKeyPair.publicKey,
149
+ }).output;
150
+ if (!outputScript) {
151
+ throw new utils.DynamicError('Failed to create segwit output script');
152
+ }
153
+ // Convert txid from hex string to Buffer and reverse it (Bitcoin uses little-endian)
154
+ // The txid from the API is in big-endian format, but bitcoinjs-lib expects little-endian
155
+ const txidBuffer = Buffer.from(utxo.txid, 'hex').reverse();
156
+ psbt.addInput({
157
+ hash: txidBuffer,
158
+ index: utxo.vout,
159
+ sequence: _const.RBF_SEQUENCE, // Enable RBF (Replace-By-Fee)
160
+ witnessUtxo: {
161
+ script: outputScript,
162
+ value: utxo.value,
163
+ },
164
+ });
165
+ }
166
+ }
167
+ /**
168
+ * Adds outputs to PSBT (recipient and optionally change)
169
+ * @param psbt - PSBT to add outputs to
170
+ * @param recipientAddress - Recipient address
171
+ * @param accountAddress - Account address for change
172
+ * @param amountInSatoshisNumber - Transaction amount
173
+ * @param changeAmountNumber - Change amount
174
+ * @param hasChangeOutput - Whether to include change output
175
+ * @param network - Bitcoin network
176
+ */
177
+ addOutputsToPsbt(psbt, recipientAddress, accountAddress, amountInSatoshisNumber, changeAmountNumber, hasChangeOutput, network) {
178
+ if (amountInSatoshisNumber < _const.DUST_LIMIT) {
179
+ throw new utils.DynamicError(`Amount is below dust limit of ${_const.DUST_LIMIT} satoshis (${_const.DUST_LIMIT / _const.SATOSHIS_PER_BTC} BTC)`);
180
+ }
181
+ psbt.addOutput({
182
+ script: bitcoinjsLib.address.toOutputScript(recipientAddress, network),
183
+ value: amountInSatoshisNumber,
184
+ });
185
+ if (hasChangeOutput) {
186
+ psbt.addOutput({
187
+ script: bitcoinjsLib.address.toOutputScript(accountAddress, network),
188
+ value: changeAmountNumber,
189
+ });
190
+ }
191
+ }
27
192
  /**
28
193
  * Builds a PSBT for a Bitcoin transaction with real UTXOs
194
+ * Uses Largest-First UTXO selection strategy with accurate vSize fee estimation
29
195
  * @param options - Options for building the PSBT
30
196
  * @returns A PSBT in Base64 format
31
197
  * @throws {DynamicError} If insufficient funds, no UTXOs, or other errors
32
198
  */
33
199
  buildPsbt(options) {
34
200
  return _tslib.__awaiter(this, void 0, void 0, function* () {
35
- const { accountAddress, recipientAddress, amountInSatoshis, publicKeyHex, network, } = options;
201
+ const { accountAddress, recipientAddress, amountInSatoshis, publicKeyHex, network, feePriority = 'medium', } = options;
202
+ logger.debug(`buildPsbt called with feePriority: ${feePriority}, amount: ${amountInSatoshis} satoshis`);
36
203
  if (amountInSatoshis <= BigInt(0)) {
37
204
  throw new utils.DynamicError('Amount must be greater than 0');
38
205
  }
39
- // Get UTXOs for the account address
40
- const utxos = yield this.mempoolApiService.getUTXOs(accountAddress);
41
- if (utxos.length === 0) {
206
+ const allUTXOs = yield this.mempoolApiService.getUTXOs(accountAddress);
207
+ if (allUTXOs.length === 0) {
42
208
  throw new utils.DynamicError('No UTXOs found for this address');
43
209
  }
44
- // Get public key for creating output scripts
210
+ const utxos = this.filterTaprootUTXOs(accountAddress, allUTXOs);
45
211
  const publicKeyBuffer = Buffer.from(publicKeyHex, 'hex');
46
- // Use ECPair to ensure the public key is properly formatted for bitcoinjs-lib
47
212
  const ECPair = ecpair.ECPairFactory(ecc__default["default"]);
48
213
  const publicKeyPair = ECPair.fromPublicKey(publicKeyBuffer, {
49
214
  compressed: true,
50
215
  });
51
- // Calculate total available
52
- const totalToSpend = utxos.reduce((total, utxo) => total + utxo.value, 0);
53
- // Initial fee estimate with 1 output (recipient only)
54
- let feeEstimate = yield this.mempoolApiService.estimateTransactionFee(accountAddress, utxos.length, 1);
55
- // Calculate change amount with 1-output fee estimate
56
- let maxToSpend = totalToSpend - feeEstimate;
57
- let changeAmount = BigInt(maxToSpend) - amountInSatoshis;
58
- // If change will be above dust limit, re-estimate fees for 2 outputs
59
- if (changeAmount > 0 && Number(changeAmount) >= _const.DUST_LIMIT) {
60
- feeEstimate = yield this.mempoolApiService.estimateTransactionFee(accountAddress, utxos.length, 2);
61
- maxToSpend = totalToSpend - feeEstimate;
62
- changeAmount = BigInt(maxToSpend) - amountInSatoshis;
63
- }
64
- if (maxToSpend < Number(amountInSatoshis)) {
65
- const amountInSatoshisNumber = Number(amountInSatoshis);
216
+ const amountInSatoshisNumber = Number(amountInSatoshis);
217
+ // Initial fee estimate with 1 input and 1 output
218
+ const initialFeeEstimate = yield this.mempoolApiService.estimateTransactionFee(accountAddress, 1, 1, feePriority);
219
+ // Target amount: transaction amount + fee + dust limit (for change output safety)
220
+ const targetAmount = amountInSatoshisNumber + initialFeeEstimate + _const.DUST_LIMIT;
221
+ // Select UTXOs using Largest-First strategy
222
+ let selectedUTXOs = this.selectUTXOsLargestFirst(utxos, targetAmount);
223
+ const selectedTotal = this.calculateUTXOTotal(selectedUTXOs);
224
+ // Validate and ensure sufficient funds
225
+ selectedUTXOs = this.validateAndSelectUTXOs(selectedUTXOs, utxos, selectedTotal, amountInSatoshisNumber, initialFeeEstimate);
226
+ const selectedTotalValue = this.calculateUTXOTotal(selectedUTXOs);
227
+ // Calculate fee and change with proper dust limit handling
228
+ const { feeEstimate, changeAmountNumber, hasChangeOutput } = yield this.calculateFeeAndChange(accountAddress, selectedUTXOs, selectedTotalValue, amountInSatoshis, feePriority);
229
+ // Final check: ensure we have enough after final fee calculation
230
+ const maxToSpend = selectedTotalValue - feeEstimate;
231
+ if (maxToSpend < amountInSatoshisNumber) {
66
232
  throw new utils.DynamicError(`Insufficient funds. Available: ${maxToSpend / _const.SATOSHIS_PER_BTC} BTC (${maxToSpend} satoshis), Required: ${amountInSatoshisNumber / _const.SATOSHIS_PER_BTC} BTC (${amountInSatoshisNumber} satoshis)`);
67
233
  }
68
- // Create PSBT
234
+ // Create PSBT and add inputs/outputs
69
235
  const psbt = new bitcoinjsLib.Psbt({ network });
70
- // Add inputs from UTXOs (only supporting native SegWit P2WPKH)
71
- for (const utxo of utxos) {
72
- // SegWit (P2WPKH) addresses
73
- // For SegWit, we need the public key to construct the witness output script
74
- const outputScript = bitcoinjsLib.payments.p2wpkh({
75
- network,
76
- pubkey: publicKeyPair.publicKey,
77
- }).output;
78
- if (!outputScript) {
79
- throw new utils.DynamicError('Failed to create segwit output script');
80
- }
81
- // Convert txid from hex string to Buffer and reverse it (Bitcoin uses little-endian)
82
- // The txid from the API is in big-endian format, but bitcoinjs-lib expects little-endian
83
- const txidBuffer = Buffer.from(utxo.txid, 'hex').reverse();
84
- psbt.addInput({
85
- hash: txidBuffer,
86
- index: utxo.vout,
87
- witnessUtxo: {
88
- script: outputScript,
89
- value: utxo.value,
90
- },
91
- });
92
- }
93
- // Add recipient output
94
- const amountInSatoshisNumber = Number(amountInSatoshis);
95
- if (amountInSatoshisNumber < _const.DUST_LIMIT) {
96
- throw new utils.DynamicError(`Amount is below dust limit of ${_const.DUST_LIMIT} satoshis (${_const.DUST_LIMIT / _const.SATOSHIS_PER_BTC} BTC)`);
97
- }
98
- psbt.addOutput({
99
- script: bitcoinjsLib.address.toOutputScript(recipientAddress, network),
100
- value: amountInSatoshisNumber,
101
- });
102
- // Add change output if needed
103
- const changeAmountNumber = Number(changeAmount);
104
- if (changeAmount > 0 && changeAmountNumber >= _const.DUST_LIMIT) {
105
- psbt.addOutput({
106
- script: bitcoinjsLib.address.toOutputScript(accountAddress, network),
107
- value: changeAmountNumber,
108
- });
109
- }
110
- logger.debug(`buildPsbt created PSBT for recipientAddress: ${recipientAddress}, amount: ${amountInSatoshisNumber} satoshis (${amountInSatoshisNumber / _const.SATOSHIS_PER_BTC} BTC), change: ${changeAmountNumber} satoshis`);
236
+ this.addInputsToPsbt(psbt, selectedUTXOs, publicKeyPair, network);
237
+ this.addOutputsToPsbt(psbt, recipientAddress, accountAddress, amountInSatoshisNumber, changeAmountNumber, hasChangeOutput, network);
238
+ logger.debug(`buildPsbt created PSBT for recipientAddress: ${recipientAddress}, amount: ${amountInSatoshisNumber} satoshis (${amountInSatoshisNumber / _const.SATOSHIS_PER_BTC} BTC), change: ${changeAmountNumber} satoshis, estimated fee: ${feeEstimate} satoshis, feePriority: ${feePriority}, inputs: ${selectedUTXOs.length}`);
111
239
  return psbt.toBase64();
112
240
  });
113
241
  }
114
242
  /**
115
243
  * Helper method to create BuildPsbtOptions from transaction and account details
116
244
  * @param accountAddress - The account address
117
- * @param transaction - The Bitcoin transaction
245
+ * @param transaction - The Bitcoin transaction (must have amount and recipientAddress)
118
246
  * @param publicKeyHex - The public key in hex format
247
+ * @param feePriority - Optional fee priority (defaults to 'medium')
119
248
  * @returns BuildPsbtOptions
120
249
  */
121
- static createBuildOptions(accountAddress, transaction, publicKeyHex) {
250
+ static createBuildOptions(accountAddress, transaction, publicKeyHex, feePriority = 'medium') {
122
251
  return {
123
252
  accountAddress,
124
253
  amountInSatoshis: transaction.amount,
254
+ feePriority,
125
255
  network: getBitcoinNetwork.getBitcoinNetwork(accountAddress),
126
256
  publicKeyHex,
127
257
  recipientAddress: transaction.recipientAddress,
@@ -6,8 +6,71 @@ import { MempoolApiService } from './MempoolApiService';
6
6
  export declare class PsbtBuilderService {
7
7
  private mempoolApiService;
8
8
  constructor(mempoolApiService: MempoolApiService);
9
+ /**
10
+ * Filters out Taproot (P2TR) UTXOs to prevent accidental spending of Ordinals/Runes
11
+ * Since we only support Native SegWit (P2WPKH), we ensure the account address is not Taproot
12
+ * @param accountAddress - The account address to check
13
+ * @param utxos - Array of UTXOs to filter
14
+ * @returns Filtered array of UTXOs (only P2WPKH compatible)
15
+ */
16
+ private filterTaprootUTXOs;
17
+ /**
18
+ * Selects UTXOs using Largest-First (Accumulator) strategy
19
+ * Sorts UTXOs by value (descending) and selects until we have enough to cover amount + fees
20
+ * @param utxos - Available UTXOs
21
+ * @param targetAmount - Target amount including fees and dust limit
22
+ * @returns Selected UTXOs
23
+ */
24
+ private selectUTXOsLargestFirst;
25
+ /**
26
+ * Calculates the total value of UTXOs
27
+ * @param utxos - Array of UTXOs
28
+ * @returns Total value in satoshis
29
+ */
30
+ private calculateUTXOTotal;
31
+ /**
32
+ * Validates and ensures sufficient funds for the transaction
33
+ * @param selectedUTXOs - Initially selected UTXOs
34
+ * @param allUTXOs - All available UTXOs
35
+ * @param selectedTotal - Total value of selected UTXOs
36
+ * @param amountInSatoshisNumber - Transaction amount
37
+ * @param feeEstimate - Estimated fee
38
+ * @returns Validated selected UTXOs
39
+ * @throws {DynamicError} If insufficient funds
40
+ */
41
+ private validateAndSelectUTXOs;
42
+ /**
43
+ * Calculates fee estimate and change amount, handling dust limit
44
+ * @param accountAddress - Account address for fee estimation
45
+ * @param selectedUTXOs - Selected UTXOs
46
+ * @param selectedTotalValue - Total value of selected UTXOs
47
+ * @param amountInSatoshis - Transaction amount
48
+ * @param feePriority - Fee priority level
49
+ * @returns Object with feeEstimate, changeAmount, changeAmountNumber, and hasChangeOutput
50
+ */
51
+ private calculateFeeAndChange;
52
+ /**
53
+ * Adds inputs to PSBT from selected UTXOs
54
+ * @param psbt - PSBT to add inputs to
55
+ * @param selectedUTXOs - Selected UTXOs
56
+ * @param publicKeyPair - ECPair public key pair
57
+ * @param network - Bitcoin network
58
+ */
59
+ private addInputsToPsbt;
60
+ /**
61
+ * Adds outputs to PSBT (recipient and optionally change)
62
+ * @param psbt - PSBT to add outputs to
63
+ * @param recipientAddress - Recipient address
64
+ * @param accountAddress - Account address for change
65
+ * @param amountInSatoshisNumber - Transaction amount
66
+ * @param changeAmountNumber - Change amount
67
+ * @param hasChangeOutput - Whether to include change output
68
+ * @param network - Bitcoin network
69
+ */
70
+ private addOutputsToPsbt;
9
71
  /**
10
72
  * Builds a PSBT for a Bitcoin transaction with real UTXOs
73
+ * Uses Largest-First UTXO selection strategy with accurate vSize fee estimation
11
74
  * @param options - Options for building the PSBT
12
75
  * @returns A PSBT in Base64 format
13
76
  * @throws {DynamicError} If insufficient funds, no UTXOs, or other errors
@@ -16,12 +79,13 @@ export declare class PsbtBuilderService {
16
79
  /**
17
80
  * Helper method to create BuildPsbtOptions from transaction and account details
18
81
  * @param accountAddress - The account address
19
- * @param transaction - The Bitcoin transaction
82
+ * @param transaction - The Bitcoin transaction (must have amount and recipientAddress)
20
83
  * @param publicKeyHex - The public key in hex format
84
+ * @param feePriority - Optional fee priority (defaults to 'medium')
21
85
  * @returns BuildPsbtOptions
22
86
  */
23
87
  static createBuildOptions(accountAddress: string, transaction: {
24
88
  amount: bigint;
25
89
  recipientAddress: string;
26
- }, publicKeyHex: string): BuildPsbtOptions;
90
+ }, publicKeyHex: string, feePriority?: 'high' | 'medium' | 'low'): BuildPsbtOptions;
27
91
  }