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