@exodus/bitcoin-api 4.4.0 → 4.6.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/CHANGELOG.md CHANGED
@@ -3,6 +3,36 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [4.6.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.6.0) (2025-11-14)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: add PSBT builder infrastructure (#6822)
13
+
14
+ * feat: add PSBT parser functionality (#6823)
15
+
16
+ * feat: integrate PSBT support and legacy chain index (#6819)
17
+
18
+ * feat: integrate PSBT support and legacy chain index (#6819) (#6829)
19
+
20
+
21
+
22
+ ## [4.5.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.5.0) (2025-11-12)
23
+
24
+
25
+ ### Features
26
+
27
+
28
+ * feat: add PSBT builder infrastructure (#6822)
29
+
30
+ * feat: add PSBT parser functionality (#6823)
31
+
32
+ * feat: integrate PSBT support and legacy chain index (#6819)
33
+
34
+
35
+
6
36
  ## [4.4.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.4.0) (2025-11-12)
7
37
 
8
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
4
4
  "description": "Bitcoin transaction and fee monitors, RPC with the blockchain node, other networking code.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -60,5 +60,5 @@
60
60
  "type": "git",
61
61
  "url": "git+https://github.com/ExodusMovement/assets.git"
62
62
  },
63
- "gitHead": "5bc77cf38ec6fbd32d9e286d5bda25ea7366854d"
63
+ "gitHead": "660b300377800da2edc9c250d61736b416db73d1"
64
64
  }
@@ -1,11 +1,17 @@
1
- import { payments, Psbt, Transaction } from '@exodus/bitcoinjs'
1
+ import { payments } from '@exodus/bitcoinjs'
2
2
  import { publicKeyToX } from '@exodus/crypto/secp256k1'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
5
  import { SubType, writePsbtGlobalField, writePsbtOutputField } from './psbt-proprietary-types.js'
6
- import { getAddressType, getPurposeXPubs, validatePurpose } from './psbt-utils.js'
7
-
8
- function canParseTx(rawTxBuffer) {
6
+ import {
7
+ getAddressType,
8
+ getPurposeXPubs,
9
+ setPsbtVersionIfNotBitcoin,
10
+ validatePurpose,
11
+ } from './psbt-utils.js'
12
+ import { getMaximumFeeRate } from './tx-sign/maximum-fee-rates.js'
13
+
14
+ function canParseTx(rawTxBuffer, Transaction) {
9
15
  try {
10
16
  Transaction.fromBuffer(rawTxBuffer)
11
17
  return true
@@ -76,7 +82,7 @@ function createPsbtInput({
76
82
  addressPathsMap,
77
83
  purposeXPubs,
78
84
  nonWitnessTxs,
79
- allowedPurposes,
85
+ Transaction,
80
86
  }) {
81
87
  const psbtInput = {
82
88
  hash: input.txId,
@@ -85,7 +91,7 @@ function createPsbtInput({
85
91
  }
86
92
 
87
93
  const purpose = asset.address.resolvePurpose(input.address)
88
- validatePurpose(purpose, allowedPurposes, `address ${input.address}`)
94
+ validatePurpose(purpose, purposeXPubs, `address ${input.address}`)
89
95
 
90
96
  const { isSegwitAddress, isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
91
97
 
@@ -118,7 +124,7 @@ function createPsbtInput({
118
124
  )
119
125
 
120
126
  const rawTxBuffer = Buffer.from(rawTx.rawData, 'hex')
121
- if (canParseTx(rawTxBuffer)) {
127
+ if (canParseTx(rawTxBuffer, Transaction)) {
122
128
  psbtInput.nonWitnessUtxo = rawTxBuffer
123
129
  } else {
124
130
  // bitcoinjs can’t parse a handful of edge-case transactions (Litecoin MWEB, odd
@@ -141,14 +147,7 @@ function createPsbtInput({
141
147
  return { ...psbtInput, ...derivationData }
142
148
  }
143
149
 
144
- function createPsbtOutput({
145
- address,
146
- amount,
147
- asset,
148
- addressPathsMap,
149
- purposeXPubs,
150
- allowedPurposes,
151
- }) {
150
+ function createPsbtOutput({ address, amount, asset, addressPathsMap, purposeXPubs }) {
152
151
  const psbtOutput = {
153
152
  address,
154
153
  value: amount,
@@ -160,7 +159,7 @@ function createPsbtOutput({
160
159
  }
161
160
 
162
161
  const purpose = asset.address.resolvePurpose(address)
163
- validatePurpose(purpose, allowedPurposes, `output address ${address}`)
162
+ validatePurpose(purpose, purposeXPubs, `output address ${address}`)
164
163
 
165
164
  const { isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
166
165
 
@@ -195,6 +194,8 @@ export async function createPsbtWithMetadata({
195
194
  addressPathsMap,
196
195
  metadata,
197
196
  allowedPurposes,
197
+ Psbt,
198
+ Transaction,
198
199
  }) {
199
200
  assert(inputs, 'inputs is required')
200
201
  assert(outputs, 'outputs is required')
@@ -203,8 +204,16 @@ export async function createPsbtWithMetadata({
203
204
  assert(walletAccount, 'walletAccount is required')
204
205
  assert(addressPathsMap, 'addressPathsMap is required')
205
206
  assert(metadata, 'metadata is required')
207
+ assert(Psbt, 'Psbt is required')
208
+ assert(Transaction, 'Transaction is required')
209
+ const psbtOptions = { network: asset.coinInfo.toBitcoinJS() }
210
+ const maximumFeeRate = getMaximumFeeRate(asset.name)
211
+ if (maximumFeeRate !== undefined) {
212
+ psbtOptions.maximumFeeRate = maximumFeeRate
213
+ }
206
214
 
207
- const psbt = new Psbt({ network: asset.coinInfo.toBitcoinJS() })
215
+ const psbt = new Psbt(psbtOptions)
216
+ setPsbtVersionIfNotBitcoin(psbt, asset.name)
208
217
 
209
218
  const purposeXPubs = await getPurposeXPubs({
210
219
  assetClientInterface,
@@ -223,6 +232,7 @@ export async function createPsbtWithMetadata({
223
232
  purposeXPubs,
224
233
  nonWitnessTxs,
225
234
  allowedPurposes,
235
+ Transaction,
226
236
  })
227
237
  psbt.addInput(psbtInput)
228
238
  }
@@ -244,5 +254,5 @@ export async function createPsbtWithMetadata({
244
254
  writePsbtOutputField(psbt, sendOutputIndex, SubType.OutputRole, 'primary')
245
255
  }
246
256
 
247
- return psbt.toBase64()
257
+ return psbt
248
258
  }
@@ -1,4 +1,3 @@
1
- import { Psbt, Transaction } from '@exodus/bitcoinjs'
2
1
  import { publicKeyToX } from '@exodus/crypto/secp256k1'
3
2
  import { UtxoCollection } from '@exodus/models'
4
3
  import BipPath from 'bip32-path'
@@ -11,7 +10,7 @@ import { findUnconfirmedSentRbfTxs } from './tx-utils.js'
11
10
  import { getUnconfirmedTxAncestorMap } from './unconfirmed-ancestor-data.js'
12
11
  import { getUsableUtxos, getUtxos } from './utxos-utils.js'
13
12
 
14
- function extractInputUtxoData(psbtInput, txInput, index) {
13
+ function extractInputUtxoData(psbtInput, txInput, index, Transaction) {
15
14
  if (psbtInput.nonWitnessUtxo) {
16
15
  const prevTx = Transaction.fromBuffer(psbtInput.nonWitnessUtxo)
17
16
  const prevOutput = prevTx.outs[txInput.index]
@@ -34,14 +33,14 @@ function extractInputUtxoData(psbtInput, txInput, index) {
34
33
  throw new Error(`Input ${index} has no witnessUtxo or nonWitnessUtxo`)
35
34
  }
36
35
 
37
- function parseSingleInput({ txInput, psbtInput, index, asset, purposeXPubs, allowedPurposes }) {
36
+ function parseSingleInput({ txInput, psbtInput, index, asset, purposeXPubs, Transaction }) {
38
37
  const input = {
39
38
  txId: Buffer.from(txInput.hash).reverse().toString('hex'),
40
39
  vout: txInput.index,
41
40
  sequence: txInput.sequence,
42
41
  }
43
42
 
44
- const inputUtxoData = extractInputUtxoData(psbtInput, txInput, index)
43
+ const inputUtxoData = extractInputUtxoData(psbtInput, txInput, index, Transaction)
45
44
 
46
45
  input.value = inputUtxoData.value
47
46
  input.script = Buffer.from(inputUtxoData.scriptBuffer).toString('hex')
@@ -53,7 +52,7 @@ function parseSingleInput({ txInput, psbtInput, index, asset, purposeXPubs, allo
53
52
 
54
53
  const address = asset.address.fromScriptPubKey(inputUtxoData.scriptBuffer)
55
54
  const purpose = asset.address.resolvePurpose(address)
56
- validatePurpose(purpose, allowedPurposes, `input ${index}`)
55
+ validatePurpose(purpose, purposeXPubs, `input ${index}`)
57
56
 
58
57
  const { isTaprootAddress } = getAddressType(purpose)
59
58
 
@@ -75,15 +74,7 @@ function parseSingleInput({ txInput, psbtInput, index, asset, purposeXPubs, allo
75
74
  return input
76
75
  }
77
76
 
78
- function parseSingleOutput({
79
- txOutput,
80
- psbtOutput,
81
- index,
82
- asset,
83
- purposeXPubs,
84
- psbt,
85
- allowedPurposes,
86
- }) {
77
+ function parseSingleOutput({ txOutput, psbtOutput, index, asset, purposeXPubs, psbt }) {
87
78
  const address = txOutput.address ?? asset.address.fromScriptPubKey(txOutput.script)
88
79
  const output = { amount: txOutput.value }
89
80
 
@@ -94,7 +85,7 @@ function parseSingleOutput({
94
85
 
95
86
  const purpose = asset.address.resolvePurpose(address)
96
87
  try {
97
- validatePurpose(purpose, allowedPurposes)
88
+ validatePurpose(purpose, purposeXPubs)
98
89
  } catch {
99
90
  output.address = { address }
100
91
  return output
@@ -125,9 +116,15 @@ function readGlobalMetadata(psbt) {
125
116
  }
126
117
 
127
118
  function calculateFee(inputs, outputs) {
128
- const inputSum = inputs.reduce((sum, input) => sum + (input.value || 0), 0)
129
- const outputSum = outputs.reduce((sum, output) => sum + (output.amount || 0), 0)
130
- return inputSum - outputSum
119
+ const inputSum = inputs.reduce((sum, input) => {
120
+ const value = BigInt(input.value || 0)
121
+ return sum + value
122
+ }, BigInt(0))
123
+ const outputSum = outputs.reduce((sum, output) => {
124
+ const amount = BigInt(output.amount || 0)
125
+ return sum + amount
126
+ }, BigInt(0))
127
+ return inputSum - outputSum // Return BigInt directly
131
128
  }
132
129
 
133
130
  function getBip32Derivation(hdkey, bip32Derivations, ignoreY) {
@@ -332,16 +329,20 @@ function extractRawTransactions(parsedInputs) {
332
329
  }
333
330
 
334
331
  export async function parsePsbt({
335
- psbtBase64,
332
+ psbtBuffer,
336
333
  asset,
337
334
  assetClientInterface,
338
335
  walletAccount,
339
336
  allowedPurposes,
337
+ Psbt,
338
+ Transaction,
340
339
  }) {
341
- assert(psbtBase64, 'psbtBase64 is required')
340
+ assert(psbtBuffer, 'psbtBuffer is required')
342
341
  assert(asset, 'asset is required')
343
342
  assert(assetClientInterface, 'assetClientInterface is required')
344
343
  assert(walletAccount, 'walletAccount is required')
344
+ assert(Psbt, 'Psbt is required')
345
+ assert(Transaction, 'Transaction is required')
345
346
 
346
347
  const purposeXPubs = await getPurposeXPubs({
347
348
  assetClientInterface,
@@ -350,8 +351,7 @@ export async function parsePsbt({
350
351
  allowedPurposes,
351
352
  })
352
353
 
353
- // TBD: change it to fromBuffer
354
- const psbt = Psbt.fromBase64(psbtBase64, { network: asset.coinInfo.toBitcoinJS() })
354
+ const psbt = Psbt.fromBuffer(psbtBuffer, { network: asset.coinInfo.toBitcoinJS() })
355
355
 
356
356
  const inputs = []
357
357
  for (let i = 0; i < psbt.inputCount; i++) {
@@ -362,6 +362,7 @@ export async function parsePsbt({
362
362
  asset,
363
363
  purposeXPubs,
364
364
  allowedPurposes,
365
+ Transaction,
365
366
  })
366
367
  inputs.push(input)
367
368
  }
@@ -392,16 +393,20 @@ export async function parsePsbt({
392
393
  }
393
394
 
394
395
  export async function extractTransactionContext({
395
- psbtBase64,
396
+ psbtBuffer,
396
397
  asset,
397
398
  assetClientInterface,
398
399
  walletAccount,
399
400
  allowedPurposes,
401
+ Psbt,
402
+ Transaction,
400
403
  }) {
401
- assert(psbtBase64, 'psbtBase64 is required')
404
+ assert(psbtBuffer, 'psbtBuffer is required')
402
405
  assert(asset, 'asset is required')
403
406
  assert(assetClientInterface, 'assetClientInterface is required')
404
407
  assert(walletAccount, 'walletAccount is required')
408
+ assert(Psbt, 'Psbt is required')
409
+ assert(Transaction, 'Transaction is required')
405
410
 
406
411
  const {
407
412
  inputs: parsedInputs,
@@ -409,11 +414,13 @@ export async function extractTransactionContext({
409
414
  fee: calculatedFee,
410
415
  globalMetadata,
411
416
  } = await parsePsbt({
412
- psbtBase64,
417
+ psbtBuffer,
413
418
  asset,
414
419
  assetClientInterface,
415
420
  walletAccount,
416
421
  allowedPurposes,
422
+ Psbt,
423
+ Transaction,
417
424
  })
418
425
 
419
426
  const changeOutputData = await getChangeOutputData({
package/src/psbt-utils.js CHANGED
@@ -29,26 +29,31 @@ export async function getPurposeXPubs({
29
29
  const purposeXPubs = Object.create(null)
30
30
 
31
31
  for (const purpose of allowedPurposes) {
32
- const xpub = await assetClientInterface.getExtendedPublicKey({
33
- walletAccount,
34
- assetName: asset.name,
35
- purpose,
36
- })
37
- const hdkey = BIP32.fromXPub(xpub)
38
- const masterFingerprint = Buffer.alloc(4)
39
- masterFingerprint.writeUint32BE(hdkey.fingerprint)
40
- purposeXPubs[purpose] = {
41
- hdkey,
42
- masterFingerprint,
32
+ try {
33
+ const xpub = await assetClientInterface.getExtendedPublicKey({
34
+ walletAccount,
35
+ assetName: asset.name,
36
+ purpose,
37
+ })
38
+ const hdkey = BIP32.fromXPub(xpub)
39
+ const masterFingerprint = Buffer.alloc(4)
40
+ masterFingerprint.writeUint32BE(hdkey.fingerprint)
41
+ purposeXPubs[purpose] = {
42
+ hdkey,
43
+ masterFingerprint,
44
+ }
45
+ } catch {
46
+ // ignore any error that happened while we are getting the extended public key to handle cases where the extended public key is not available
47
+ // Eg. Ledger/Trezor doesn't support getting extended public keys for certain purposes
43
48
  }
44
49
  }
45
50
 
46
51
  return purposeXPubs
47
52
  }
48
53
 
49
- export function validatePurpose(purpose, allowedPurposes, context = '') {
50
- assert(allowedPurposes, 'allowedPurposes is required')
51
- if (!allowedPurposes.includes(purpose)) {
54
+ export function validatePurpose(purpose, purposeXPubs, context = '') {
55
+ assert(purposeXPubs, 'purposeXPubs is required')
56
+ if (!purposeXPubs[purpose]) {
52
57
  throw new Error(`Purpose ${purpose} not found${context ? ' for ' + context : ''}`)
53
58
  }
54
59
  }
@@ -126,3 +131,7 @@ export async function withUnsafeNonSegwit({ psbt, fn, unsafe = true }) {
126
131
  cache.__UNSAFE_SIGN_NONSEGWIT = prevValue
127
132
  }
128
133
  }
134
+
135
+ export function setPsbtVersionIfNotBitcoin(psbt, assetName) {
136
+ if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
137
+ }
@@ -5,6 +5,7 @@ import assert from 'minimalistic-assert'
5
5
  import { getChangeDustValue } from '../dust.js'
6
6
  import { parseCurrency, serializeCurrency } from '../fee/fee-utils.js'
7
7
  import { selectUtxos } from '../fee/utxo-selector.js'
8
+ import { createPsbtWithMetadata } from '../psbt-builder.js'
8
9
  import { findUnconfirmedSentRbfTxs } from '../tx-utils.js'
9
10
  import { getUnconfirmedTxAncestorMap } from '../unconfirmed-ancestor-data.js'
10
11
  import { getUsableUtxos, getUtxos } from '../utxos-utils.js'
@@ -267,10 +268,20 @@ async function createUnsignedTx({
267
268
  asset,
268
269
  selectedUtxos,
269
270
  insightClient,
271
+ assetClientInterface,
272
+ walletAccount,
273
+ bumpTxId,
274
+ rbfEnabled,
275
+ txType = 'transfer',
276
+ sendOutputIndex,
277
+ changeOutputIndex,
278
+ allowedPurposes,
279
+ Psbt,
280
+ Transaction,
270
281
  }) {
271
282
  const nonWitnessTxs = await getNonWitnessTxs(asset, selectedUtxos, insightClient)
272
283
 
273
- return {
284
+ const result = {
274
285
  txData: {
275
286
  inputs,
276
287
  outputs,
@@ -282,6 +293,36 @@ async function createUnsignedTx({
282
293
  rawTxs: nonWitnessTxs,
283
294
  },
284
295
  }
296
+
297
+ // Create PSBT for all UTXO coins that support it
298
+ // Note: Some coins (like BCash) may not use PSBT in their signing flow,
299
+ // but creating it here allows for a unified transaction flow
300
+ if (Psbt) {
301
+ const psbt = await createPsbtWithMetadata({
302
+ inputs,
303
+ outputs,
304
+ asset,
305
+ assetClientInterface,
306
+ walletAccount,
307
+ nonWitnessTxs,
308
+ addressPathsMap,
309
+ allowedPurposes,
310
+ metadata: {
311
+ rbfEnabled,
312
+ txType,
313
+ sendOutputIndexes: [sendOutputIndex],
314
+ changeOutputIndex,
315
+ bumpTxId,
316
+ blockHeight,
317
+ },
318
+ Psbt,
319
+ Transaction,
320
+ })
321
+
322
+ result.txData.psbtBuffer = psbt.toBuffer()
323
+ }
324
+
325
+ return result
285
326
  }
286
327
 
287
328
  const getTxHandler = (type) => {
@@ -309,6 +350,9 @@ const transferHandler = {
309
350
  utxosDescendingOrder,
310
351
  assetClientInterface,
311
352
  changeAddressType,
353
+ allowedPurposes,
354
+ Psbt,
355
+ Transaction,
312
356
  }) => {
313
357
  const assetName = asset.name
314
358
  const insightClient = asset.baseAsset.insightClient
@@ -437,12 +481,22 @@ const transferHandler = {
437
481
  asset,
438
482
  selectedUtxos,
439
483
  insightClient,
484
+ assetClientInterface,
485
+ walletAccount,
486
+ bumpTxId,
487
+ rbfEnabled: context.rbfEnabled,
488
+ txType: 'transfer',
489
+ sendOutputIndex: sendOutput ? outputs.indexOf(sendOutput) : undefined,
490
+ changeOutputIndex: changeOutput ? outputs.indexOf(changeOutput) : undefined,
491
+ allowedPurposes,
492
+ Psbt,
493
+ Transaction,
440
494
  })
441
495
 
442
496
  return {
443
497
  unsignedTx,
444
- fee: adjustedFee,
445
498
  metadata: {
499
+ fee: adjustedFee,
446
500
  amount,
447
501
  change: selectedUtxos.value.sub(totalAmount).sub(adjustedFee),
448
502
  totalAmount,
@@ -469,6 +523,9 @@ export const createTxFactory =
469
523
  utxosDescendingOrder,
470
524
  assetClientInterface,
471
525
  changeAddressType,
526
+ allowedPurposes,
527
+ Psbt,
528
+ Transaction,
472
529
  }) =>
473
530
  async ({
474
531
  asset,
@@ -514,5 +571,8 @@ export const createTxFactory =
514
571
  utxosDescendingOrder,
515
572
  assetClientInterface,
516
573
  changeAddressType,
574
+ allowedPurposes,
575
+ Psbt,
576
+ Transaction,
517
577
  })
518
578
  }
@@ -1,6 +1,8 @@
1
1
  import * as defaultBitcoinjsLib from '@exodus/bitcoinjs'
2
+ import { Psbt as DefaultPsbt, Transaction as DefaultTransaction } from '@exodus/bitcoinjs'
2
3
  import assert from 'minimalistic-assert'
3
4
 
5
+ import { extractTransactionContext } from '../psbt-parser.js'
4
6
  import { createTxFactory } from '../tx-create/create-tx.js'
5
7
  import { broadcastTransaction } from './broadcast-tx.js'
6
8
  import { updateAccountState, updateTransactionLog } from './update-state.js'
@@ -108,6 +110,9 @@ const getPrepareSendTransaction = async ({
108
110
  options,
109
111
  utxosDescendingOrder,
110
112
  walletAccount,
113
+ allowedPurposes,
114
+ Psbt,
115
+ Transaction,
111
116
  }) => {
112
117
  const createTx = createTxFactory({
113
118
  getFeeEstimator,
@@ -115,6 +120,9 @@ const getPrepareSendTransaction = async ({
115
120
  utxosDescendingOrder,
116
121
  assetClientInterface,
117
122
  changeAddressType,
123
+ allowedPurposes,
124
+ Psbt,
125
+ Transaction,
118
126
  })
119
127
 
120
128
  // Set default values for options
@@ -131,6 +139,59 @@ const getPrepareSendTransaction = async ({
131
139
  })
132
140
  }
133
141
 
142
+ function toTransactionDescriptor({ txContext, psbtBuffer }) {
143
+ const {
144
+ inputs,
145
+ outputs,
146
+ addressPathsMap,
147
+ blockHeight,
148
+ rawTxs,
149
+ fee,
150
+ totalSendAmount,
151
+ changeAmount,
152
+ totalAmount,
153
+ primaryAddresses,
154
+ ourAddress,
155
+ usableUtxos,
156
+ selectedUtxos,
157
+ replaceTx,
158
+ sendOutputs,
159
+ changeOutput,
160
+ rbfEnabled,
161
+ } = txContext
162
+
163
+ return {
164
+ unsignedTx: {
165
+ txData: {
166
+ inputs,
167
+ outputs,
168
+ psbtBuffer,
169
+ },
170
+ txMeta: {
171
+ addressPathsMap,
172
+ blockHeight,
173
+ rawTxs,
174
+ },
175
+ },
176
+ metadata: {
177
+ fee,
178
+ amount: totalSendAmount.isZero ? undefined : totalSendAmount,
179
+ sendAmount: totalSendAmount,
180
+ change: changeAmount,
181
+ totalAmount,
182
+ address: primaryAddresses[0]?.address,
183
+ ourAddress,
184
+ usableUtxos,
185
+ selectedUtxos,
186
+ replaceTx,
187
+ sendOutput: sendOutputs[0],
188
+ changeOutput,
189
+ rbfEnabled,
190
+ blockHeight,
191
+ },
192
+ }
193
+ }
194
+
134
195
  // not ported from Exodus; but this demos signing / broadcasting
135
196
  // NOTE: this will be ripped out in the coming weeks
136
197
  export const createAndBroadcastTXFactory =
@@ -141,15 +202,18 @@ export const createAndBroadcastTXFactory =
141
202
  utxosDescendingOrder,
142
203
  assetClientInterface,
143
204
  changeAddressType,
205
+ allowedPurposes,
206
+ Psbt = DefaultPsbt,
207
+ Transaction = DefaultTransaction,
144
208
  }) =>
145
- async ({ asset, walletAccount, address, amount, options }) => {
209
+ async ({ asset, walletAccount, address, amount, ...options }) => {
146
210
  // Prepare transaction
147
211
  const { bumpTxId } = options
148
212
 
149
213
  const assetName = asset.name
150
214
  const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
151
215
 
152
- const transactionDescriptor = await getPrepareSendTransaction({
216
+ let transactionDescriptor = await getPrepareSendTransaction({
153
217
  address,
154
218
  allowUnconfirmedRbfEnabledUtxos,
155
219
  amount,
@@ -160,22 +224,35 @@ export const createAndBroadcastTXFactory =
160
224
  options,
161
225
  utxosDescendingOrder,
162
226
  walletAccount,
227
+ allowedPurposes,
228
+ Psbt,
229
+ Transaction,
163
230
  })
164
231
 
165
- const { unsignedTx, fee, metadata } = transactionDescriptor
166
- const {
167
- sendAmount,
168
- usableUtxos,
169
- replaceTx,
170
- sendOutput,
171
- changeOutput,
172
- blockHeight,
173
- rbfEnabled,
174
- } = metadata
175
-
176
- const outputs = unsignedTx.txData.outputs
232
+ // If we already created a PSBT for Bitcoin, hydrate the full transaction
233
+ // context from it before signing.
234
+ let unsignedTx, metadata
235
+ if (transactionDescriptor.unsignedTx?.txData?.psbtBuffer) {
236
+ const psbtBuffer = transactionDescriptor.unsignedTx.txData.psbtBuffer
237
+ const txContext = await extractTransactionContext({
238
+ psbtBuffer,
239
+ asset,
240
+ assetClientInterface,
241
+ walletAccount,
242
+ allowedPurposes,
243
+ Psbt,
244
+ Transaction,
245
+ })
246
+ transactionDescriptor = toTransactionDescriptor({ txContext, psbtBuffer })
247
+ unsignedTx = transactionDescriptor.unsignedTx
248
+ metadata = transactionDescriptor.metadata
249
+ } else {
250
+ // Legacy/non-PSBT flows stick with the original descriptor shape.
251
+ unsignedTx = transactionDescriptor.unsignedTx
252
+ metadata = transactionDescriptor.metadata
253
+ }
177
254
 
178
- address = metadata.address
255
+ const { sendAmount, usableUtxos, replaceTx, sendOutput, changeOutput } = metadata
179
256
 
180
257
  // Sign transaction
181
258
  const { rawTx, txId, tx } = await signTransaction({
@@ -192,7 +269,7 @@ export const createAndBroadcastTXFactory =
192
269
  if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
193
270
  err.txInfo = JSON.stringify({
194
271
  amount: sendAmount.toDefaultString({ unit: true }),
195
- fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
272
+ fee: ((metadata.fee && metadata.fee.toDefaultString({ unit: true })) || 0).toString(),
196
273
  allUtxos: usableUtxos.toJSON(),
197
274
  })
198
275
  }
@@ -203,7 +280,7 @@ export const createAndBroadcastTXFactory =
203
280
  function findUtxoIndex(output) {
204
281
  let utxoIndex = -1
205
282
  if (output) {
206
- for (const [i, [address, amount]] of outputs.entries()) {
283
+ for (const [i, [address, amount]] of unsignedTx.txData.outputs.entries()) {
207
284
  if (output[0] === address && output[1] === amount) {
208
285
  utxoIndex = i
209
286
  break
@@ -228,7 +305,6 @@ export const createAndBroadcastTXFactory =
228
305
  rawTx,
229
306
  changeUtxoIndex,
230
307
  getSizeAndChangeScript,
231
- rbfEnabled,
232
308
  })
233
309
 
234
310
  await updateTransactionLog({
@@ -236,14 +312,9 @@ export const createAndBroadcastTXFactory =
236
312
  assetClientInterface,
237
313
  walletAccount,
238
314
  txId,
239
- fee,
240
315
  metadata,
241
- address,
242
- amount,
243
316
  bumpTxId,
244
317
  size,
245
- blockHeight,
246
- rbfEnabled,
247
318
  })
248
319
 
249
320
  return {
@@ -16,7 +16,6 @@ import { serializeCurrency } from '../fee/fee-utils.js'
16
16
  * @param {number} params.changeUtxoIndex - Index of change output
17
17
  * @param {Object} params.changeOutput - Change output details
18
18
  * @param {Object} params.getSizeAndChangeScript - Function to get size and script
19
- * @param {boolean} params.rbfEnabled - Whether RBF is enabled
20
19
  */
21
20
  export async function updateAccountState({
22
21
  assetClientInterface,
@@ -29,9 +28,8 @@ export async function updateAccountState({
29
28
  rawTx,
30
29
  changeUtxoIndex,
31
30
  getSizeAndChangeScript,
32
- rbfEnabled,
33
31
  }) {
34
- const { usableUtxos, selectedUtxos, replaceTx, change, ourAddress } = metadata
32
+ const { usableUtxos, selectedUtxos, replaceTx, change, ourAddress, rbfEnabled } = metadata
35
33
 
36
34
  // Get change script and size
37
35
  const { script, size } = getSizeAndChangeScript({
@@ -88,30 +86,31 @@ export async function updateAccountState({
88
86
  * @param {Object} params.assetClientInterface - Asset client interface
89
87
  * @param {Object} params.walletAccount - Wallet account
90
88
  * @param {string} params.txId - Transaction ID
91
- * @param {Object} params.fee - Transaction fee
92
89
  * @param {Object} params.metadata - Transaction metadata
93
- * @param {string} params.address - Recipient address
94
- * @param {Object} params.amount - Transaction amount (for regular sends)
95
90
  * @param {string} params.bumpTxId - ID of transaction being bumped (if applicable)
96
91
  * @param {number} params.size - Transaction size
97
- * @param {number} params.blockHeight - Block height
98
- * @param {boolean} params.rbfEnabled - Whether RBF is enabled
99
92
  */
100
93
  export async function updateTransactionLog({
101
94
  asset,
102
95
  assetClientInterface,
103
96
  walletAccount,
104
97
  txId,
105
- fee,
106
98
  metadata,
107
- address,
108
- amount,
109
99
  bumpTxId,
110
100
  size,
111
- blockHeight,
112
- rbfEnabled,
113
101
  }) {
114
- const { totalAmount, selectedUtxos, replaceTx, changeOutput, ourAddress } = metadata
102
+ const {
103
+ totalAmount,
104
+ selectedUtxos,
105
+ replaceTx,
106
+ changeOutput,
107
+ ourAddress,
108
+ fee,
109
+ blockHeight,
110
+ rbfEnabled,
111
+ address,
112
+ amount,
113
+ } = metadata
115
114
  const assetName = asset.name
116
115
 
117
116
  // Check if this is a self-send
@@ -27,3 +27,12 @@ export const serializeTx = ({ tx }) => {
27
27
  })),
28
28
  }
29
29
  }
30
+
31
+ export function shouldSkipFinalize(unsignedTx) {
32
+ const isExternalPsbt =
33
+ unsignedTx.txData.psbtBuffer &&
34
+ unsignedTx.txMeta.addressPathsMap &&
35
+ unsignedTx.txMeta.inputsToSign
36
+
37
+ return isExternalPsbt || unsignedTx.txMeta.returnPsbt
38
+ }
@@ -1,6 +1,7 @@
1
1
  import { bip371, payments, Transaction } from '@exodus/bitcoinjs'
2
2
  import { publicKeyToX } from '@exodus/crypto/secp256k1'
3
3
 
4
+ import { withUnsafeNonSegwit } from '../psbt-utils.js'
4
5
  import { createGetKeyWithMetadata } from './create-get-key-and-purpose.js'
5
6
  import { toAsyncBufferSigner, toAsyncSigner } from './taproot.js'
6
7
 
@@ -82,9 +83,12 @@ export function createSignWithWallet({
82
83
  : toAsyncSigner({ privateKey, publicKey, isTaprootKeySpend })
83
84
 
84
85
  // desktop / BE / mobile with bip-schnorr signing
85
- signingPromises.push(psbt.signInputAsync(index, asyncSigner, allowedSigHashTypes))
86
+ signingPromises.push(() => psbt.signInputAsync(index, asyncSigner, allowedSigHashTypes))
86
87
  }
87
88
 
88
- await Promise.all(signingPromises)
89
+ await withUnsafeNonSegwit({
90
+ psbt,
91
+ fn: () => Promise.all(signingPromises.map((sign) => sign())),
92
+ })
89
93
  }
90
94
  }
@@ -1,7 +1,7 @@
1
1
  import { Psbt as DefaultPsbt, Transaction as DefaultTransaction } from '@exodus/bitcoinjs'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import { extractTransaction } from './common.js'
4
+ import { extractTransaction, shouldSkipFinalize } from './common.js'
5
5
  import { createSignWithWallet } from './create-sign-with-wallet.js'
6
6
  import { createPrepareForSigning } from './default-prepare-for-signing.js'
7
7
 
@@ -57,7 +57,7 @@ export const signTxFactory = ({
57
57
  },
58
58
  })
59
59
 
60
- const skipFinalize = !!unsignedTx.txData.psbtBuffer || unsignedTx.txMeta.returnPsbt
60
+ const skipFinalize = shouldSkipFinalize(unsignedTx)
61
61
  await signWithWallet(psbt, inputsToSign, skipFinalize)
62
62
  return extractTransaction({ psbt, skipFinalize })
63
63
  }
@@ -2,11 +2,8 @@ import { Psbt as DefaultPsbt, Transaction as DefaultTransaction } from '@exodus/
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
4
  import { writePsbtBlockHeight } from '../psbt-proprietary-types.js'
5
-
6
- const _MAXIMUM_FEE_RATES = {
7
- qtumignition: 25_000,
8
- ravencoin: 1_000_000,
9
- }
5
+ import { setPsbtVersionIfNotBitcoin } from '../psbt-utils.js'
6
+ import { getMaximumFeeRate } from './maximum-fee-rates.js'
10
7
 
11
8
  /**
12
9
  * Factory function that create the prepareForSigning function for a bitcoin-like asset.
@@ -26,17 +23,18 @@ export function createPrepareForSigning({
26
23
 
27
24
  return ({ unsignedTx }) => {
28
25
  const networkInfo = coinInfo.toBitcoinJS()
26
+ const maximumFeeRate = getMaximumFeeRate(assetName)
29
27
 
30
- const isPsbtBufferPassed =
31
- unsignedTx.txData.psbtBuffer &&
32
- unsignedTx.txMeta.addressPathsMap &&
33
- unsignedTx.txMeta.inputsToSign
34
- if (isPsbtBufferPassed) {
35
- // PSBT created externally (Web3, etc..)
28
+ const psbtOptions = { network: networkInfo }
29
+ if (maximumFeeRate !== undefined) {
30
+ psbtOptions.maximumFeeRate = maximumFeeRate
31
+ }
32
+
33
+ if (unsignedTx.txData.psbtBuffer) {
36
34
  return createPsbtFromBuffer({
37
35
  Psbt,
38
36
  psbtBuffer: unsignedTx.txData.psbtBuffer,
39
- networkInfo,
37
+ psbtOptions,
40
38
  })
41
39
  }
42
40
 
@@ -50,15 +48,17 @@ export function createPrepareForSigning({
50
48
  Psbt,
51
49
  Transaction,
52
50
  })
53
- if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
51
+ setPsbtVersionIfNotBitcoin(psbt, assetName)
54
52
 
55
53
  return psbt
56
54
  }
57
55
  }
58
56
 
59
57
  // Creates a PSBT instance from the passed transaction buffer provided by 3rd parties (e.g. dApps).
60
- function createPsbtFromBuffer({ Psbt, psbtBuffer, ecc, networkInfo }) {
61
- return Psbt.fromBuffer(psbtBuffer, { eccLib: ecc, network: networkInfo })
58
+ function createPsbtFromBuffer({ Psbt, psbtBuffer, ecc, psbtOptions = {} }) {
59
+ const fromBufferOptions = { ...psbtOptions }
60
+ if (ecc) fromBufferOptions.eccLib = ecc
61
+ return Psbt.fromBuffer(psbtBuffer, fromBufferOptions)
62
62
  }
63
63
 
64
64
  // Creates a PSBT instance from the passed inputs, outputs etc. The wallet itself provides this data.
@@ -73,11 +73,18 @@ function createPsbtFromTxData({
73
73
  Transaction,
74
74
  blockHeight,
75
75
  }) {
76
- // use harcoded max fee rates for specific assets
77
- // if undefined, will be set to default value by PSBT (2500)
78
- const maximumFeeRate = _MAXIMUM_FEE_RATES[assetName]
76
+ // use hardcoded max fee rates for specific assets
77
+ // if undefined, the PSBT default (5000 sat/vB) is used. Passing `undefined`
78
+ // directly into the constructor used to clobber the default (buggy behavior),
79
+ // so we now only set the override when we actually have one.
80
+ const maximumFeeRate = getMaximumFeeRate(assetName)
81
+
82
+ const psbtOptions = { network: networkInfo }
83
+ if (maximumFeeRate !== undefined) {
84
+ psbtOptions.maximumFeeRate = maximumFeeRate
85
+ }
79
86
 
80
- const psbt = new Psbt({ maximumFeeRate, network: networkInfo })
87
+ const psbt = new Psbt(psbtOptions)
81
88
 
82
89
  // If present, add blockHeight as a proprietary field
83
90
  if (blockHeight !== undefined) {
@@ -1,6 +1,6 @@
1
1
  import assert from 'minimalistic-assert'
2
2
 
3
- import { extractTransaction } from './common.js'
3
+ import { extractTransaction, shouldSkipFinalize } from './common.js'
4
4
  import { createPrepareForSigning } from './default-prepare-for-signing.js'
5
5
 
6
6
  export const signHardwareFactory = ({ assetName, resolvePurpose, keys, coinInfo }) => {
@@ -40,7 +40,7 @@ export const signHardwareFactory = ({ assetName, resolvePurpose, keys, coinInfo
40
40
  multisigData,
41
41
  })
42
42
 
43
- const skipFinalize = !!unsignedTx.txData.psbtBuffer || unsignedTx.txMeta.returnPsbt
43
+ const skipFinalize = shouldSkipFinalize(unsignedTx)
44
44
  return extractTransaction({ psbt, skipFinalize })
45
45
  }
46
46
  }
@@ -0,0 +1,13 @@
1
+ // We keep hardcoded fee caps here so both signing and PSBT builders reuse the
2
+ // same per-asset overrides (e.g. Dogecoin needs a higher ceiling than the
3
+ // bitcoinjs default). If an asset is missing, the library fallback (5000 sat/vB)
4
+ // will apply instead.
5
+ const MAXIMUM_FEE_RATES = {
6
+ dogecoin: 12_000,
7
+ qtumignition: 25_000,
8
+ ravencoin: 1_000_000,
9
+ }
10
+
11
+ export function getMaximumFeeRate(assetName) {
12
+ return MAXIMUM_FEE_RATES[assetName]
13
+ }