@exodus/bitcoin-api 4.5.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,22 @@
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
+
6
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)
7
23
 
8
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.5.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": "482aa1dda8d9273c4f1a477ffcef2310f1df9884"
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
@@ -70,7 +76,14 @@ function writeGlobalMetadata(psbt, metadata) {
70
76
  }
71
77
  }
72
78
 
73
- function createPsbtInput({ input, asset, addressPathsMap, purposeXPubs, nonWitnessTxs }) {
79
+ function createPsbtInput({
80
+ input,
81
+ asset,
82
+ addressPathsMap,
83
+ purposeXPubs,
84
+ nonWitnessTxs,
85
+ Transaction,
86
+ }) {
74
87
  const psbtInput = {
75
88
  hash: input.txId,
76
89
  index: input.vout,
@@ -111,7 +124,7 @@ function createPsbtInput({ input, asset, addressPathsMap, purposeXPubs, nonWitne
111
124
  )
112
125
 
113
126
  const rawTxBuffer = Buffer.from(rawTx.rawData, 'hex')
114
- if (canParseTx(rawTxBuffer)) {
127
+ if (canParseTx(rawTxBuffer, Transaction)) {
115
128
  psbtInput.nonWitnessUtxo = rawTxBuffer
116
129
  } else {
117
130
  // bitcoinjs can’t parse a handful of edge-case transactions (Litecoin MWEB, odd
@@ -181,6 +194,8 @@ export async function createPsbtWithMetadata({
181
194
  addressPathsMap,
182
195
  metadata,
183
196
  allowedPurposes,
197
+ Psbt,
198
+ Transaction,
184
199
  }) {
185
200
  assert(inputs, 'inputs is required')
186
201
  assert(outputs, 'outputs is required')
@@ -189,8 +204,16 @@ export async function createPsbtWithMetadata({
189
204
  assert(walletAccount, 'walletAccount is required')
190
205
  assert(addressPathsMap, 'addressPathsMap is required')
191
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
+ }
192
214
 
193
- const psbt = new Psbt({ network: asset.coinInfo.toBitcoinJS() })
215
+ const psbt = new Psbt(psbtOptions)
216
+ setPsbtVersionIfNotBitcoin(psbt, asset.name)
194
217
 
195
218
  const purposeXPubs = await getPurposeXPubs({
196
219
  assetClientInterface,
@@ -209,6 +232,7 @@ export async function createPsbtWithMetadata({
209
232
  purposeXPubs,
210
233
  nonWitnessTxs,
211
234
  allowedPurposes,
235
+ Transaction,
212
236
  })
213
237
  psbt.addInput(psbtInput)
214
238
  }
@@ -230,5 +254,5 @@ export async function createPsbtWithMetadata({
230
254
  writePsbtOutputField(psbt, sendOutputIndex, SubType.OutputRole, 'primary')
231
255
  }
232
256
 
233
- return psbt.toBuffer()
257
+ return psbt
234
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 }) {
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')
@@ -117,9 +116,15 @@ function readGlobalMetadata(psbt) {
117
116
  }
118
117
 
119
118
  function calculateFee(inputs, outputs) {
120
- const inputSum = inputs.reduce((sum, input) => sum + (input.value || 0), 0)
121
- const outputSum = outputs.reduce((sum, output) => sum + (output.amount || 0), 0)
122
- 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
123
128
  }
124
129
 
125
130
  function getBip32Derivation(hdkey, bip32Derivations, ignoreY) {
@@ -329,11 +334,15 @@ export async function parsePsbt({
329
334
  assetClientInterface,
330
335
  walletAccount,
331
336
  allowedPurposes,
337
+ Psbt,
338
+ Transaction,
332
339
  }) {
333
340
  assert(psbtBuffer, 'psbtBuffer is required')
334
341
  assert(asset, 'asset is required')
335
342
  assert(assetClientInterface, 'assetClientInterface is required')
336
343
  assert(walletAccount, 'walletAccount is required')
344
+ assert(Psbt, 'Psbt is required')
345
+ assert(Transaction, 'Transaction is required')
337
346
 
338
347
  const purposeXPubs = await getPurposeXPubs({
339
348
  assetClientInterface,
@@ -353,6 +362,7 @@ export async function parsePsbt({
353
362
  asset,
354
363
  purposeXPubs,
355
364
  allowedPurposes,
365
+ Transaction,
356
366
  })
357
367
  inputs.push(input)
358
368
  }
@@ -388,11 +398,15 @@ export async function extractTransactionContext({
388
398
  assetClientInterface,
389
399
  walletAccount,
390
400
  allowedPurposes,
401
+ Psbt,
402
+ Transaction,
391
403
  }) {
392
404
  assert(psbtBuffer, 'psbtBuffer is required')
393
405
  assert(asset, 'asset is required')
394
406
  assert(assetClientInterface, 'assetClientInterface is required')
395
407
  assert(walletAccount, 'walletAccount is required')
408
+ assert(Psbt, 'Psbt is required')
409
+ assert(Transaction, 'Transaction is required')
396
410
 
397
411
  const {
398
412
  inputs: parsedInputs,
@@ -405,6 +419,8 @@ export async function extractTransactionContext({
405
419
  assetClientInterface,
406
420
  walletAccount,
407
421
  allowedPurposes,
422
+ Psbt,
423
+ Transaction,
408
424
  })
409
425
 
410
426
  const changeOutputData = await getChangeOutputData({
package/src/psbt-utils.js CHANGED
@@ -131,3 +131,7 @@ export async function withUnsafeNonSegwit({ psbt, fn, unsafe = true }) {
131
131
  cache.__UNSAFE_SIGN_NONSEGWIT = prevValue
132
132
  }
133
133
  }
134
+
135
+ export function setPsbtVersionIfNotBitcoin(psbt, assetName) {
136
+ if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
137
+ }
@@ -276,6 +276,8 @@ async function createUnsignedTx({
276
276
  sendOutputIndex,
277
277
  changeOutputIndex,
278
278
  allowedPurposes,
279
+ Psbt,
280
+ Transaction,
279
281
  }) {
280
282
  const nonWitnessTxs = await getNonWitnessTxs(asset, selectedUtxos, insightClient)
281
283
 
@@ -292,11 +294,11 @@ async function createUnsignedTx({
292
294
  },
293
295
  }
294
296
 
295
- // Only attach PSBT metadata for Bitcoin transfer flows for now; support for other coins and
296
- // transaction types will come later.
297
- const isBitcoin = ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name)
298
- if (isBitcoin) {
299
- const psbtBuffer = await createPsbtWithMetadata({
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({
300
302
  inputs,
301
303
  outputs,
302
304
  asset,
@@ -313,9 +315,11 @@ async function createUnsignedTx({
313
315
  bumpTxId,
314
316
  blockHeight,
315
317
  },
318
+ Psbt,
319
+ Transaction,
316
320
  })
317
321
 
318
- result.txData.psbtBuffer = psbtBuffer
322
+ result.txData.psbtBuffer = psbt.toBuffer()
319
323
  }
320
324
 
321
325
  return result
@@ -347,6 +351,8 @@ const transferHandler = {
347
351
  assetClientInterface,
348
352
  changeAddressType,
349
353
  allowedPurposes,
354
+ Psbt,
355
+ Transaction,
350
356
  }) => {
351
357
  const assetName = asset.name
352
358
  const insightClient = asset.baseAsset.insightClient
@@ -483,6 +489,8 @@ const transferHandler = {
483
489
  sendOutputIndex: sendOutput ? outputs.indexOf(sendOutput) : undefined,
484
490
  changeOutputIndex: changeOutput ? outputs.indexOf(changeOutput) : undefined,
485
491
  allowedPurposes,
492
+ Psbt,
493
+ Transaction,
486
494
  })
487
495
 
488
496
  return {
@@ -516,6 +524,8 @@ export const createTxFactory =
516
524
  assetClientInterface,
517
525
  changeAddressType,
518
526
  allowedPurposes,
527
+ Psbt,
528
+ Transaction,
519
529
  }) =>
520
530
  async ({
521
531
  asset,
@@ -562,5 +572,7 @@ export const createTxFactory =
562
572
  assetClientInterface,
563
573
  changeAddressType,
564
574
  allowedPurposes,
575
+ Psbt,
576
+ Transaction,
565
577
  })
566
578
  }
@@ -1,4 +1,5 @@
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
 
4
5
  import { extractTransactionContext } from '../psbt-parser.js'
@@ -110,6 +111,8 @@ const getPrepareSendTransaction = async ({
110
111
  utxosDescendingOrder,
111
112
  walletAccount,
112
113
  allowedPurposes,
114
+ Psbt,
115
+ Transaction,
113
116
  }) => {
114
117
  const createTx = createTxFactory({
115
118
  getFeeEstimator,
@@ -118,6 +121,8 @@ const getPrepareSendTransaction = async ({
118
121
  assetClientInterface,
119
122
  changeAddressType,
120
123
  allowedPurposes,
124
+ Psbt,
125
+ Transaction,
121
126
  })
122
127
 
123
128
  // Set default values for options
@@ -134,7 +139,7 @@ const getPrepareSendTransaction = async ({
134
139
  })
135
140
  }
136
141
 
137
- function toTransactionDescriptor(txContext, psbtBuffer) {
142
+ function toTransactionDescriptor({ txContext, psbtBuffer }) {
138
143
  const {
139
144
  inputs,
140
145
  outputs,
@@ -198,8 +203,10 @@ export const createAndBroadcastTXFactory =
198
203
  assetClientInterface,
199
204
  changeAddressType,
200
205
  allowedPurposes,
206
+ Psbt = DefaultPsbt,
207
+ Transaction = DefaultTransaction,
201
208
  }) =>
202
- async ({ asset, walletAccount, address, amount, options }) => {
209
+ async ({ asset, walletAccount, address, amount, ...options }) => {
203
210
  // Prepare transaction
204
211
  const { bumpTxId } = options
205
212
 
@@ -218,6 +225,8 @@ export const createAndBroadcastTXFactory =
218
225
  utxosDescendingOrder,
219
226
  walletAccount,
220
227
  allowedPurposes,
228
+ Psbt,
229
+ Transaction,
221
230
  })
222
231
 
223
232
  // If we already created a PSBT for Bitcoin, hydrate the full transaction
@@ -231,8 +240,10 @@ export const createAndBroadcastTXFactory =
231
240
  assetClientInterface,
232
241
  walletAccount,
233
242
  allowedPurposes,
243
+ Psbt,
244
+ Transaction,
234
245
  })
235
- transactionDescriptor = toTransactionDescriptor(txContext, psbtBuffer)
246
+ transactionDescriptor = toTransactionDescriptor({ txContext, psbtBuffer })
236
247
  unsignedTx = transactionDescriptor.unsignedTx
237
248
  metadata = transactionDescriptor.metadata
238
249
  } else {
@@ -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,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,11 +57,7 @@ export const signTxFactory = ({
57
57
  },
58
58
  })
59
59
 
60
- const isExternalPsbt =
61
- unsignedTx.txData.psbtBuffer &&
62
- unsignedTx.txMeta.addressPathsMap &&
63
- unsignedTx.txMeta.inputsToSign
64
- const skipFinalize = isExternalPsbt || unsignedTx.txMeta.returnPsbt
60
+ const skipFinalize = shouldSkipFinalize(unsignedTx)
65
61
  await signWithWallet(psbt, inputsToSign, skipFinalize)
66
62
  return extractTransaction({ psbt, skipFinalize })
67
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,9 +23,19 @@ export function createPrepareForSigning({
26
23
 
27
24
  return ({ unsignedTx }) => {
28
25
  const networkInfo = coinInfo.toBitcoinJS()
26
+ const maximumFeeRate = getMaximumFeeRate(assetName)
27
+
28
+ const psbtOptions = { network: networkInfo }
29
+ if (maximumFeeRate !== undefined) {
30
+ psbtOptions.maximumFeeRate = maximumFeeRate
31
+ }
29
32
 
30
33
  if (unsignedTx.txData.psbtBuffer) {
31
- return createPsbtFromBuffer({ Psbt, psbtBuffer: unsignedTx.txData.psbtBuffer, networkInfo })
34
+ return createPsbtFromBuffer({
35
+ Psbt,
36
+ psbtBuffer: unsignedTx.txData.psbtBuffer,
37
+ psbtOptions,
38
+ })
32
39
  }
33
40
 
34
41
  // Create PSBT based on internal Exodus data structure
@@ -41,15 +48,17 @@ export function createPrepareForSigning({
41
48
  Psbt,
42
49
  Transaction,
43
50
  })
44
- if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
51
+ setPsbtVersionIfNotBitcoin(psbt, assetName)
45
52
 
46
53
  return psbt
47
54
  }
48
55
  }
49
56
 
50
57
  // Creates a PSBT instance from the passed transaction buffer provided by 3rd parties (e.g. dApps).
51
- function createPsbtFromBuffer({ Psbt, psbtBuffer, ecc, networkInfo }) {
52
- 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)
53
62
  }
54
63
 
55
64
  // Creates a PSBT instance from the passed inputs, outputs etc. The wallet itself provides this data.
@@ -64,11 +73,18 @@ function createPsbtFromTxData({
64
73
  Transaction,
65
74
  blockHeight,
66
75
  }) {
67
- // use harcoded max fee rates for specific assets
68
- // if undefined, will be set to default value by PSBT (2500)
69
- 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
+ }
70
86
 
71
- const psbt = new Psbt({ maximumFeeRate, network: networkInfo })
87
+ const psbt = new Psbt(psbtOptions)
72
88
 
73
89
  // If present, add blockHeight as a proprietary field
74
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,11 +40,7 @@ export const signHardwareFactory = ({ assetName, resolvePurpose, keys, coinInfo
40
40
  multisigData,
41
41
  })
42
42
 
43
- const isExternalPsbt =
44
- unsignedTx.txData.psbtBuffer &&
45
- unsignedTx.txMeta.addressPathsMap &&
46
- unsignedTx.txMeta.inputsToSign
47
- const skipFinalize = isExternalPsbt || unsignedTx.txMeta.returnPsbt
43
+ const skipFinalize = shouldSkipFinalize(unsignedTx)
48
44
  return extractTransaction({ psbt, skipFinalize })
49
45
  }
50
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
+ }