@exodus/bitcoin-api 4.3.0 → 4.5.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,32 @@
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.5.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.5.0) (2025-11-12)
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
+
19
+
20
+ ## [4.4.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.4.0) (2025-11-12)
21
+
22
+
23
+ ### Features
24
+
25
+
26
+ * feat: add PSBT builder infrastructure (#6822)
27
+
28
+ * feat: add PSBT parser functionality (#6823)
29
+
30
+
31
+
6
32
  ## [4.3.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.3.0) (2025-11-11)
7
33
 
8
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.3.0",
3
+ "version": "4.5.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": "7a2a429c2a182a889e550761d9106da3296815ca"
63
+ "gitHead": "482aa1dda8d9273c4f1a477ffcef2310f1df9884"
64
64
  }
@@ -70,14 +70,7 @@ function writeGlobalMetadata(psbt, metadata) {
70
70
  }
71
71
  }
72
72
 
73
- function createPsbtInput({
74
- input,
75
- asset,
76
- addressPathsMap,
77
- purposeXPubs,
78
- nonWitnessTxs,
79
- allowedPurposes,
80
- }) {
73
+ function createPsbtInput({ input, asset, addressPathsMap, purposeXPubs, nonWitnessTxs }) {
81
74
  const psbtInput = {
82
75
  hash: input.txId,
83
76
  index: input.vout,
@@ -85,7 +78,7 @@ function createPsbtInput({
85
78
  }
86
79
 
87
80
  const purpose = asset.address.resolvePurpose(input.address)
88
- validatePurpose(purpose, allowedPurposes, `address ${input.address}`)
81
+ validatePurpose(purpose, purposeXPubs, `address ${input.address}`)
89
82
 
90
83
  const { isSegwitAddress, isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
91
84
 
@@ -141,14 +134,7 @@ function createPsbtInput({
141
134
  return { ...psbtInput, ...derivationData }
142
135
  }
143
136
 
144
- function createPsbtOutput({
145
- address,
146
- amount,
147
- asset,
148
- addressPathsMap,
149
- purposeXPubs,
150
- allowedPurposes,
151
- }) {
137
+ function createPsbtOutput({ address, amount, asset, addressPathsMap, purposeXPubs }) {
152
138
  const psbtOutput = {
153
139
  address,
154
140
  value: amount,
@@ -160,7 +146,7 @@ function createPsbtOutput({
160
146
  }
161
147
 
162
148
  const purpose = asset.address.resolvePurpose(address)
163
- validatePurpose(purpose, allowedPurposes, `output address ${address}`)
149
+ validatePurpose(purpose, purposeXPubs, `output address ${address}`)
164
150
 
165
151
  const { isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
166
152
 
@@ -244,5 +230,5 @@ export async function createPsbtWithMetadata({
244
230
  writePsbtOutputField(psbt, sendOutputIndex, SubType.OutputRole, 'primary')
245
231
  }
246
232
 
247
- return psbt.toBase64()
233
+ return psbt.toBuffer()
248
234
  }
@@ -0,0 +1,493 @@
1
+ import { Psbt, Transaction } from '@exodus/bitcoinjs'
2
+ import { publicKeyToX } from '@exodus/crypto/secp256k1'
3
+ import { UtxoCollection } from '@exodus/models'
4
+ import BipPath from 'bip32-path'
5
+ import assert from 'minimalistic-assert'
6
+
7
+ import { readPsbtGlobalField, readPsbtOutputField, SubType } from './psbt-proprietary-types.js'
8
+ import { getAddressType, getPurposeXPubs, validatePurpose } from './psbt-utils.js'
9
+ import { createInputs, createOutput } from './tx-create/tx-create-utils.js'
10
+ import { findUnconfirmedSentRbfTxs } from './tx-utils.js'
11
+ import { getUnconfirmedTxAncestorMap } from './unconfirmed-ancestor-data.js'
12
+ import { getUsableUtxos, getUtxos } from './utxos-utils.js'
13
+
14
+ function extractInputUtxoData(psbtInput, txInput, index) {
15
+ if (psbtInput.nonWitnessUtxo) {
16
+ const prevTx = Transaction.fromBuffer(psbtInput.nonWitnessUtxo)
17
+ const prevOutput = prevTx.outs[txInput.index]
18
+
19
+ return {
20
+ scriptBuffer: prevOutput.script,
21
+ value: prevOutput.value,
22
+ prevTxHex: prevTx.toHex(),
23
+ prevTxId: prevTx.getId(),
24
+ }
25
+ }
26
+
27
+ if (psbtInput.witnessUtxo) {
28
+ return {
29
+ scriptBuffer: psbtInput.witnessUtxo.script,
30
+ value: psbtInput.witnessUtxo.value,
31
+ }
32
+ }
33
+
34
+ throw new Error(`Input ${index} has no witnessUtxo or nonWitnessUtxo`)
35
+ }
36
+
37
+ function parseSingleInput({ txInput, psbtInput, index, asset, purposeXPubs }) {
38
+ const input = {
39
+ txId: Buffer.from(txInput.hash).reverse().toString('hex'),
40
+ vout: txInput.index,
41
+ sequence: txInput.sequence,
42
+ }
43
+
44
+ const inputUtxoData = extractInputUtxoData(psbtInput, txInput, index)
45
+
46
+ input.value = inputUtxoData.value
47
+ input.script = Buffer.from(inputUtxoData.scriptBuffer).toString('hex')
48
+
49
+ if (inputUtxoData.prevTxHex) {
50
+ input.prevTxHex = inputUtxoData.prevTxHex
51
+ input.prevTxId = inputUtxoData.prevTxId
52
+ }
53
+
54
+ const address = asset.address.fromScriptPubKey(inputUtxoData.scriptBuffer)
55
+ const purpose = asset.address.resolvePurpose(address)
56
+ validatePurpose(purpose, purposeXPubs, `input ${index}`)
57
+
58
+ const { isTaprootAddress } = getAddressType(purpose)
59
+
60
+ if (!isTaprootAddress && !psbtInput.nonWitnessUtxo) {
61
+ input.missingNonWitnessUtxo = true
62
+ }
63
+
64
+ const derivation = getBip32Derivation(
65
+ purposeXPubs[purpose].hdkey,
66
+ isTaprootAddress ? psbtInput.tapBip32Derivation : psbtInput.bip32Derivation,
67
+ isTaprootAddress
68
+ )
69
+
70
+ if (!derivation) {
71
+ throw new Error(`Input ${index} has no derivation path`)
72
+ }
73
+
74
+ input.address = { address, meta: { path: derivation.path, purpose } }
75
+ return input
76
+ }
77
+
78
+ function parseSingleOutput({ txOutput, psbtOutput, index, asset, purposeXPubs, psbt }) {
79
+ const address = txOutput.address ?? asset.address.fromScriptPubKey(txOutput.script)
80
+ const output = { amount: txOutput.value }
81
+
82
+ const outputMetadata = readPsbtOutputField(psbt, index)
83
+ if (outputMetadata) {
84
+ output.metadata = outputMetadata
85
+ }
86
+
87
+ const purpose = asset.address.resolvePurpose(address)
88
+ try {
89
+ validatePurpose(purpose, purposeXPubs)
90
+ } catch {
91
+ output.address = { address }
92
+ return output
93
+ }
94
+
95
+ const { isTaprootAddress } = getAddressType(purpose)
96
+
97
+ const derivation = getBip32Derivation(
98
+ purposeXPubs[purpose].hdkey,
99
+ isTaprootAddress ? psbtOutput.tapBip32Derivation : psbtOutput.bip32Derivation,
100
+ isTaprootAddress
101
+ )
102
+
103
+ output.address = derivation
104
+ ? { address, meta: { path: derivation.path, purpose } }
105
+ : { address, meta: { purpose } }
106
+
107
+ return output
108
+ }
109
+
110
+ function readGlobalMetadata(psbt) {
111
+ return {
112
+ blockHeight: readPsbtGlobalField(psbt, SubType.BlockHeight),
113
+ rbfEnabled: readPsbtGlobalField(psbt, SubType.RbfEnabled),
114
+ bumpTxId: readPsbtGlobalField(psbt, SubType.BumpTxId),
115
+ txType: readPsbtGlobalField(psbt, SubType.TxType),
116
+ }
117
+ }
118
+
119
+ 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
123
+ }
124
+
125
+ function getBip32Derivation(hdkey, bip32Derivations, ignoreY) {
126
+ const fingerprintBuffer = Buffer.alloc(4)
127
+ fingerprintBuffer.writeUInt32BE(hdkey.fingerprint, 0)
128
+
129
+ const matchingDerivations = bip32Derivations?.filter((bipDv) => {
130
+ return bipDv.masterFingerprint.equals(fingerprintBuffer)
131
+ })
132
+
133
+ if (!matchingDerivations?.length) {
134
+ // No derivation matched our fingerprint; the output isn’t ours.
135
+ return null
136
+ }
137
+
138
+ if (matchingDerivations.length !== 1) {
139
+ // Multiple matches imply the input/output carries several keys from the same wallet xpub.
140
+ // That’s outside our wallet model, so we fail fast.
141
+ throw new Error(
142
+ `more than one matching derivation for fingerprint ${fingerprintBuffer.toString('hex')}: ${
143
+ matchingDerivations.length
144
+ }`
145
+ )
146
+ }
147
+
148
+ const [derivation] = matchingDerivations
149
+ const publicKey = hdkey.derive(derivation.path).publicKey
150
+
151
+ const publicKeyToCompare = ignoreY ? publicKeyToX({ publicKey, format: 'buffer' }) : publicKey
152
+
153
+ if (publicKeyToCompare.equals(derivation.pubkey)) {
154
+ return derivation
155
+ }
156
+
157
+ throw new Error('pubkey did not match bip32Derivation')
158
+ }
159
+
160
+ function getSelectedUtxos({ parsedInputs, utxos, asset, txSet }) {
161
+ const utxoId = (txId, vout) => txId + ':' + vout
162
+ const utxosArray = Array.isArray(utxos) ? utxos : utxos.toArray()
163
+ const utxosMap = new Map(utxosArray.map((u) => [utxoId(u.txId, u.vout), u]))
164
+
165
+ const inputIds = new Set(parsedInputs.map((inp) => utxoId(inp.txId, inp.vout)))
166
+ let detectedReplaceTx = null
167
+
168
+ const replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
169
+ for (const replaceableTx of replaceableTxs) {
170
+ if (replaceableTx.data?.inputs) {
171
+ const txInputs = UtxoCollection.fromJSON(replaceableTx.data.inputs, {
172
+ currency: asset.currency,
173
+ })
174
+
175
+ const txInputsArray = txInputs.toArray()
176
+
177
+ const txInputIds = txInputsArray.map((u) => utxoId(u.txId, u.vout))
178
+ const isReplacement = txInputIds.some((id) => inputIds.has(id))
179
+
180
+ if (isReplacement) {
181
+ detectedReplaceTx = replaceableTx
182
+
183
+ for (const utxo of txInputsArray) {
184
+ const id = utxoId(utxo.txId, utxo.vout)
185
+ if (!utxosMap.has(id)) {
186
+ utxosMap.set(id, utxo)
187
+ }
188
+ }
189
+
190
+ // Exodus only handles a single replaceable transaction at a time, so bail once we’ve tagged one.
191
+ break
192
+ }
193
+ }
194
+ }
195
+
196
+ const inputUtxos = []
197
+ const missingInputIds = []
198
+
199
+ for (const parsedInput of parsedInputs) {
200
+ const id = utxoId(parsedInput.txId, parsedInput.vout)
201
+ const ourUtxo = utxosMap.get(id)
202
+
203
+ if (ourUtxo) {
204
+ inputUtxos.push(ourUtxo)
205
+ } else {
206
+ missingInputIds.push(id)
207
+ }
208
+ }
209
+
210
+ return {
211
+ selectedUtxos: UtxoCollection.fromArray(inputUtxos, { currency: asset.currency }),
212
+ replaceTx: detectedReplaceTx,
213
+ missingInputIds,
214
+ }
215
+ }
216
+
217
+ async function getChangeOutputData({ outputs, assetClientInterface, walletAccount, asset }) {
218
+ let changeOutputData = null
219
+ for (const [i, output] of outputs.entries()) {
220
+ // Outputs marked “primary” are treated as sends even if they use one of our paths
221
+ // (common for self‑sends, consolidations, etc.), so skip them during change detection.
222
+ if (output.metadata?.outputRole === 'primary') {
223
+ continue
224
+ }
225
+
226
+ // We only treat addresses we can re-derive from the wallet xpub as potential
227
+ // change outputs.
228
+ if (!output.address?.meta?.path) {
229
+ continue
230
+ }
231
+
232
+ // At this point we already know the output was derived by our wallet and it is
233
+ // not explicitly flagged as a primary/send output, so we tentatively treat it as
234
+ // change and confirm by re-deriving the on-chain address.
235
+ if (changeOutputData) {
236
+ // Multiple change outputs are a smell. This can happen if an external PSBT
237
+ // marks a send output of the same wallet with its derivation path, so we fail fast instead of
238
+ // silently mislabelling funds.
239
+ throw new Error('Multiple change outputs are not allowed')
240
+ }
241
+
242
+ const { path, purpose } = output.address.meta
243
+ const [chainIndex, addressIndex] = BipPath.fromString(path).toPathArray()
244
+
245
+ const verifyAddr = await assetClientInterface.getAddress({
246
+ assetName: asset.name,
247
+ walletAccount: walletAccount.toString(),
248
+ purpose,
249
+ chainIndex,
250
+ addressIndex,
251
+ })
252
+
253
+ if (verifyAddr.toString() === output.address.address) {
254
+ changeOutputData = {
255
+ address: verifyAddr,
256
+ amount: output.amount,
257
+ index: i,
258
+ }
259
+ } else {
260
+ throw new Error(`Invalid change output address ${output.address.address}`)
261
+ }
262
+ }
263
+
264
+ return changeOutputData
265
+ }
266
+
267
+ function findPrimaryOutputIndexes(outputs) {
268
+ return outputs
269
+ .map((output, index) => (output.metadata?.outputRole === 'primary' ? index : null))
270
+ .filter((index) => index !== null)
271
+ }
272
+
273
+ function processReplacementTransaction(replaceTx, asset) {
274
+ if (!replaceTx) return null
275
+
276
+ const clonedTx = replaceTx.clone()
277
+ const updatedTx = clonedTx.update({ data: { ...clonedTx.data } })
278
+
279
+ updatedTx.data.sent = updatedTx?.data?.sent.map((to) => ({
280
+ ...to,
281
+ amount: asset.currency.baseUnit(to.amount),
282
+ }))
283
+
284
+ return updatedTx
285
+ }
286
+
287
+ function calculateAmounts({ primaryOutputs, changeOutputData, processedReplaceTx, asset }) {
288
+ const sendAmounts = primaryOutputs.map((primaryOutput) =>
289
+ asset.currency.baseUnit(primaryOutput.amount)
290
+ )
291
+ const totalSendAmount = sendAmounts.reduce((sum, amount) => sum.add(amount), asset.currency.ZERO)
292
+
293
+ const changeAmount = changeOutputData
294
+ ? asset.currency.baseUnit(changeOutputData.amount)
295
+ : asset.currency.ZERO
296
+
297
+ const totalAmount = processedReplaceTx
298
+ ? processedReplaceTx.data.sent.reduce((total, { amount }) => total.add(amount), totalSendAmount)
299
+ : totalSendAmount
300
+
301
+ return { sendAmounts, totalSendAmount, changeAmount, totalAmount }
302
+ }
303
+
304
+ function buildAddressPathsMap(selectedUtxos, changeOutputData) {
305
+ const addressPathsMap = selectedUtxos.getAddressPathsMap()
306
+
307
+ if (changeOutputData) {
308
+ addressPathsMap[changeOutputData.address.address] = changeOutputData.address.meta.path
309
+ }
310
+
311
+ return addressPathsMap
312
+ }
313
+
314
+ function extractRawTransactions(parsedInputs) {
315
+ const rawTxsData = parsedInputs
316
+ .filter((parsedInput) => parsedInput.prevTxId)
317
+ .map((parsedInput) => ({
318
+ txId: parsedInput.prevTxId,
319
+ rawData: parsedInput.prevTxHex,
320
+ }))
321
+
322
+ const rawTxsMap = new Map(rawTxsData.map((tx) => [tx.txId, tx.rawData]))
323
+ return [...rawTxsMap].map(([txId, rawData]) => ({ txId, rawData }))
324
+ }
325
+
326
+ export async function parsePsbt({
327
+ psbtBuffer,
328
+ asset,
329
+ assetClientInterface,
330
+ walletAccount,
331
+ allowedPurposes,
332
+ }) {
333
+ assert(psbtBuffer, 'psbtBuffer is required')
334
+ assert(asset, 'asset is required')
335
+ assert(assetClientInterface, 'assetClientInterface is required')
336
+ assert(walletAccount, 'walletAccount is required')
337
+
338
+ const purposeXPubs = await getPurposeXPubs({
339
+ assetClientInterface,
340
+ walletAccount,
341
+ asset,
342
+ allowedPurposes,
343
+ })
344
+
345
+ const psbt = Psbt.fromBuffer(psbtBuffer, { network: asset.coinInfo.toBitcoinJS() })
346
+
347
+ const inputs = []
348
+ for (let i = 0; i < psbt.inputCount; i++) {
349
+ const input = parseSingleInput({
350
+ txInput: psbt.txInputs[i],
351
+ psbtInput: psbt.data.inputs[i],
352
+ index: i,
353
+ asset,
354
+ purposeXPubs,
355
+ allowedPurposes,
356
+ })
357
+ inputs.push(input)
358
+ }
359
+
360
+ const outputs = []
361
+ for (let i = 0; i < psbt.data.outputs.length; i++) {
362
+ const output = parseSingleOutput({
363
+ txOutput: psbt.txOutputs[i],
364
+ psbtOutput: psbt.data.outputs[i],
365
+ index: i,
366
+ asset,
367
+ purposeXPubs,
368
+ psbt,
369
+ allowedPurposes,
370
+ })
371
+ outputs.push(output)
372
+ }
373
+
374
+ const globalMetadata = readGlobalMetadata(psbt)
375
+ const fee = calculateFee(inputs, outputs)
376
+
377
+ return {
378
+ inputs,
379
+ outputs,
380
+ fee,
381
+ globalMetadata,
382
+ }
383
+ }
384
+
385
+ export async function extractTransactionContext({
386
+ psbtBuffer,
387
+ asset,
388
+ assetClientInterface,
389
+ walletAccount,
390
+ allowedPurposes,
391
+ }) {
392
+ assert(psbtBuffer, 'psbtBuffer is required')
393
+ assert(asset, 'asset is required')
394
+ assert(assetClientInterface, 'assetClientInterface is required')
395
+ assert(walletAccount, 'walletAccount is required')
396
+
397
+ const {
398
+ inputs: parsedInputs,
399
+ outputs: parsedOutputs,
400
+ fee: calculatedFee,
401
+ globalMetadata,
402
+ } = await parsePsbt({
403
+ psbtBuffer,
404
+ asset,
405
+ assetClientInterface,
406
+ walletAccount,
407
+ allowedPurposes,
408
+ })
409
+
410
+ const changeOutputData = await getChangeOutputData({
411
+ outputs: parsedOutputs,
412
+ assetClientInterface,
413
+ walletAccount,
414
+ asset,
415
+ })
416
+
417
+ const assetName = asset.name
418
+ const accountState = await assetClientInterface.getAccountState({
419
+ assetName,
420
+ walletAccount,
421
+ })
422
+
423
+ const utxos = getUtxos({ accountState, asset })
424
+ const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
425
+
426
+ const { selectedUtxos, replaceTx, missingInputIds } = getSelectedUtxos({
427
+ parsedInputs,
428
+ utxos,
429
+ txSet,
430
+ asset,
431
+ })
432
+
433
+ if (missingInputIds.length > 0) {
434
+ // Missing inputs mean we no longer control some UTXOs referenced in the PSBT.
435
+ // Typical causes: they’ve already been spent from another device, or the RBF
436
+ // transaction we planned to replace has since confirmed and dropped out of the
437
+ // replaceable set.
438
+ throw new Error(`Unknown inputs: ${missingInputIds.join(', ')}`)
439
+ }
440
+
441
+ const processedReplaceTx = processReplacementTransaction(replaceTx, asset)
442
+
443
+ const primaryOutputIndexes = findPrimaryOutputIndexes(parsedOutputs)
444
+ const primaryOutputs = primaryOutputIndexes.map((v) => parsedOutputs[v])
445
+
446
+ const { sendAmounts, totalSendAmount, changeAmount, totalAmount } = calculateAmounts({
447
+ primaryOutputs,
448
+ changeOutputData,
449
+ processedReplaceTx,
450
+ asset,
451
+ })
452
+
453
+ const unconfirmedTxAncestor = getUnconfirmedTxAncestorMap({ accountState })
454
+ const feeData = await assetClientInterface.getFeeConfig({ assetName })
455
+ const usableUtxos = getUsableUtxos({
456
+ asset,
457
+ utxos,
458
+ feeData,
459
+ txSet,
460
+ unconfirmedTxAncestor,
461
+ })
462
+
463
+ const inputs = createInputs(assetName, selectedUtxos.toArray(), globalMetadata.rbfEnabled)
464
+ const outputs = parsedOutputs.map((output) =>
465
+ createOutput(assetName, output.address.address, asset.currency.baseUnit(output.amount))
466
+ )
467
+
468
+ const addressPathsMap = buildAddressPathsMap(selectedUtxos, changeOutputData)
469
+ const rawTxs = extractRawTransactions(parsedInputs)
470
+
471
+ return {
472
+ inputs,
473
+ outputs,
474
+ addressPathsMap,
475
+ rawTxs,
476
+ fee: asset.currency.baseUnit(calculatedFee),
477
+ sendOutputIndexes: primaryOutputIndexes,
478
+ changeOutputIndex: changeOutputData?.index,
479
+ sendAmounts,
480
+ changeAmount,
481
+ totalSendAmount,
482
+ totalAmount,
483
+ primaryAddresses: primaryOutputs.map((primaryOutput) => primaryOutput.address),
484
+ ourAddress: changeOutputData?.address,
485
+ usableUtxos,
486
+ selectedUtxos,
487
+ replaceTx: processedReplaceTx,
488
+ sendOutputs: primaryOutputIndexes.map((primaryOutputIndex) => outputs[primaryOutputIndex]),
489
+ changeOutput: changeOutputData ? outputs[changeOutputData.index] : undefined,
490
+ rbfEnabled: globalMetadata.rbfEnabled,
491
+ blockHeight: globalMetadata.blockHeight,
492
+ }
493
+ }
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
  }
@@ -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,18 @@ 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,
270
279
  }) {
271
280
  const nonWitnessTxs = await getNonWitnessTxs(asset, selectedUtxos, insightClient)
272
281
 
273
- return {
282
+ const result = {
274
283
  txData: {
275
284
  inputs,
276
285
  outputs,
@@ -282,6 +291,34 @@ async function createUnsignedTx({
282
291
  rawTxs: nonWitnessTxs,
283
292
  },
284
293
  }
294
+
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({
300
+ inputs,
301
+ outputs,
302
+ asset,
303
+ assetClientInterface,
304
+ walletAccount,
305
+ nonWitnessTxs,
306
+ addressPathsMap,
307
+ allowedPurposes,
308
+ metadata: {
309
+ rbfEnabled,
310
+ txType,
311
+ sendOutputIndexes: [sendOutputIndex],
312
+ changeOutputIndex,
313
+ bumpTxId,
314
+ blockHeight,
315
+ },
316
+ })
317
+
318
+ result.txData.psbtBuffer = psbtBuffer
319
+ }
320
+
321
+ return result
285
322
  }
286
323
 
287
324
  const getTxHandler = (type) => {
@@ -309,6 +346,7 @@ const transferHandler = {
309
346
  utxosDescendingOrder,
310
347
  assetClientInterface,
311
348
  changeAddressType,
349
+ allowedPurposes,
312
350
  }) => {
313
351
  const assetName = asset.name
314
352
  const insightClient = asset.baseAsset.insightClient
@@ -437,12 +475,20 @@ const transferHandler = {
437
475
  asset,
438
476
  selectedUtxos,
439
477
  insightClient,
478
+ assetClientInterface,
479
+ walletAccount,
480
+ bumpTxId,
481
+ rbfEnabled: context.rbfEnabled,
482
+ txType: 'transfer',
483
+ sendOutputIndex: sendOutput ? outputs.indexOf(sendOutput) : undefined,
484
+ changeOutputIndex: changeOutput ? outputs.indexOf(changeOutput) : undefined,
485
+ allowedPurposes,
440
486
  })
441
487
 
442
488
  return {
443
489
  unsignedTx,
444
- fee: adjustedFee,
445
490
  metadata: {
491
+ fee: adjustedFee,
446
492
  amount,
447
493
  change: selectedUtxos.value.sub(totalAmount).sub(adjustedFee),
448
494
  totalAmount,
@@ -469,6 +515,7 @@ export const createTxFactory =
469
515
  utxosDescendingOrder,
470
516
  assetClientInterface,
471
517
  changeAddressType,
518
+ allowedPurposes,
472
519
  }) =>
473
520
  async ({
474
521
  asset,
@@ -514,5 +561,6 @@ export const createTxFactory =
514
561
  utxosDescendingOrder,
515
562
  assetClientInterface,
516
563
  changeAddressType,
564
+ allowedPurposes,
517
565
  })
518
566
  }
@@ -1,6 +1,7 @@
1
1
  import * as defaultBitcoinjsLib from '@exodus/bitcoinjs'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
+ import { extractTransactionContext } from '../psbt-parser.js'
4
5
  import { createTxFactory } from '../tx-create/create-tx.js'
5
6
  import { broadcastTransaction } from './broadcast-tx.js'
6
7
  import { updateAccountState, updateTransactionLog } from './update-state.js'
@@ -108,6 +109,7 @@ const getPrepareSendTransaction = async ({
108
109
  options,
109
110
  utxosDescendingOrder,
110
111
  walletAccount,
112
+ allowedPurposes,
111
113
  }) => {
112
114
  const createTx = createTxFactory({
113
115
  getFeeEstimator,
@@ -115,6 +117,7 @@ const getPrepareSendTransaction = async ({
115
117
  utxosDescendingOrder,
116
118
  assetClientInterface,
117
119
  changeAddressType,
120
+ allowedPurposes,
118
121
  })
119
122
 
120
123
  // Set default values for options
@@ -131,6 +134,59 @@ const getPrepareSendTransaction = async ({
131
134
  })
132
135
  }
133
136
 
137
+ function toTransactionDescriptor(txContext, psbtBuffer) {
138
+ const {
139
+ inputs,
140
+ outputs,
141
+ addressPathsMap,
142
+ blockHeight,
143
+ rawTxs,
144
+ fee,
145
+ totalSendAmount,
146
+ changeAmount,
147
+ totalAmount,
148
+ primaryAddresses,
149
+ ourAddress,
150
+ usableUtxos,
151
+ selectedUtxos,
152
+ replaceTx,
153
+ sendOutputs,
154
+ changeOutput,
155
+ rbfEnabled,
156
+ } = txContext
157
+
158
+ return {
159
+ unsignedTx: {
160
+ txData: {
161
+ inputs,
162
+ outputs,
163
+ psbtBuffer,
164
+ },
165
+ txMeta: {
166
+ addressPathsMap,
167
+ blockHeight,
168
+ rawTxs,
169
+ },
170
+ },
171
+ metadata: {
172
+ fee,
173
+ amount: totalSendAmount.isZero ? undefined : totalSendAmount,
174
+ sendAmount: totalSendAmount,
175
+ change: changeAmount,
176
+ totalAmount,
177
+ address: primaryAddresses[0]?.address,
178
+ ourAddress,
179
+ usableUtxos,
180
+ selectedUtxos,
181
+ replaceTx,
182
+ sendOutput: sendOutputs[0],
183
+ changeOutput,
184
+ rbfEnabled,
185
+ blockHeight,
186
+ },
187
+ }
188
+ }
189
+
134
190
  // not ported from Exodus; but this demos signing / broadcasting
135
191
  // NOTE: this will be ripped out in the coming weeks
136
192
  export const createAndBroadcastTXFactory =
@@ -141,6 +197,7 @@ export const createAndBroadcastTXFactory =
141
197
  utxosDescendingOrder,
142
198
  assetClientInterface,
143
199
  changeAddressType,
200
+ allowedPurposes,
144
201
  }) =>
145
202
  async ({ asset, walletAccount, address, amount, options }) => {
146
203
  // Prepare transaction
@@ -149,7 +206,7 @@ export const createAndBroadcastTXFactory =
149
206
  const assetName = asset.name
150
207
  const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
151
208
 
152
- const transactionDescriptor = await getPrepareSendTransaction({
209
+ let transactionDescriptor = await getPrepareSendTransaction({
153
210
  address,
154
211
  allowUnconfirmedRbfEnabledUtxos,
155
212
  amount,
@@ -160,22 +217,31 @@ export const createAndBroadcastTXFactory =
160
217
  options,
161
218
  utxosDescendingOrder,
162
219
  walletAccount,
220
+ allowedPurposes,
163
221
  })
164
222
 
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
223
+ // If we already created a PSBT for Bitcoin, hydrate the full transaction
224
+ // context from it before signing.
225
+ let unsignedTx, metadata
226
+ if (transactionDescriptor.unsignedTx?.txData?.psbtBuffer) {
227
+ const psbtBuffer = transactionDescriptor.unsignedTx.txData.psbtBuffer
228
+ const txContext = await extractTransactionContext({
229
+ psbtBuffer,
230
+ asset,
231
+ assetClientInterface,
232
+ walletAccount,
233
+ allowedPurposes,
234
+ })
235
+ transactionDescriptor = toTransactionDescriptor(txContext, psbtBuffer)
236
+ unsignedTx = transactionDescriptor.unsignedTx
237
+ metadata = transactionDescriptor.metadata
238
+ } else {
239
+ // Legacy/non-PSBT flows stick with the original descriptor shape.
240
+ unsignedTx = transactionDescriptor.unsignedTx
241
+ metadata = transactionDescriptor.metadata
242
+ }
177
243
 
178
- address = metadata.address
244
+ const { sendAmount, usableUtxos, replaceTx, sendOutput, changeOutput } = metadata
179
245
 
180
246
  // Sign transaction
181
247
  const { rawTx, txId, tx } = await signTransaction({
@@ -192,7 +258,7 @@ export const createAndBroadcastTXFactory =
192
258
  if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
193
259
  err.txInfo = JSON.stringify({
194
260
  amount: sendAmount.toDefaultString({ unit: true }),
195
- fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
261
+ fee: ((metadata.fee && metadata.fee.toDefaultString({ unit: true })) || 0).toString(),
196
262
  allUtxos: usableUtxos.toJSON(),
197
263
  })
198
264
  }
@@ -203,7 +269,7 @@ export const createAndBroadcastTXFactory =
203
269
  function findUtxoIndex(output) {
204
270
  let utxoIndex = -1
205
271
  if (output) {
206
- for (const [i, [address, amount]] of outputs.entries()) {
272
+ for (const [i, [address, amount]] of unsignedTx.txData.outputs.entries()) {
207
273
  if (output[0] === address && output[1] === amount) {
208
274
  utxoIndex = i
209
275
  break
@@ -228,7 +294,6 @@ export const createAndBroadcastTXFactory =
228
294
  rawTx,
229
295
  changeUtxoIndex,
230
296
  getSizeAndChangeScript,
231
- rbfEnabled,
232
297
  })
233
298
 
234
299
  await updateTransactionLog({
@@ -236,14 +301,9 @@ export const createAndBroadcastTXFactory =
236
301
  assetClientInterface,
237
302
  walletAccount,
238
303
  txId,
239
- fee,
240
304
  metadata,
241
- address,
242
- amount,
243
305
  bumpTxId,
244
306
  size,
245
- blockHeight,
246
- rbfEnabled,
247
307
  })
248
308
 
249
309
  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
@@ -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
  }
@@ -57,7 +57,11 @@ export const signTxFactory = ({
57
57
  },
58
58
  })
59
59
 
60
- const skipFinalize = !!unsignedTx.txData.psbtBuffer || unsignedTx.txMeta.returnPsbt
60
+ const isExternalPsbt =
61
+ unsignedTx.txData.psbtBuffer &&
62
+ unsignedTx.txMeta.addressPathsMap &&
63
+ unsignedTx.txMeta.inputsToSign
64
+ const skipFinalize = isExternalPsbt || unsignedTx.txMeta.returnPsbt
61
65
  await signWithWallet(psbt, inputsToSign, skipFinalize)
62
66
  return extractTransaction({ psbt, skipFinalize })
63
67
  }
@@ -27,17 +27,8 @@ export function createPrepareForSigning({
27
27
  return ({ unsignedTx }) => {
28
28
  const networkInfo = coinInfo.toBitcoinJS()
29
29
 
30
- const isPsbtBufferPassed =
31
- unsignedTx.txData.psbtBuffer &&
32
- unsignedTx.txMeta.addressPathsMap &&
33
- unsignedTx.txMeta.inputsToSign
34
- if (isPsbtBufferPassed) {
35
- // PSBT created externally (Web3, etc..)
36
- return createPsbtFromBuffer({
37
- Psbt,
38
- psbtBuffer: unsignedTx.txData.psbtBuffer,
39
- networkInfo,
40
- })
30
+ if (unsignedTx.txData.psbtBuffer) {
31
+ return createPsbtFromBuffer({ Psbt, psbtBuffer: unsignedTx.txData.psbtBuffer, networkInfo })
41
32
  }
42
33
 
43
34
  // Create PSBT based on internal Exodus data structure
@@ -40,7 +40,11 @@ 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 isExternalPsbt =
44
+ unsignedTx.txData.psbtBuffer &&
45
+ unsignedTx.txMeta.addressPathsMap &&
46
+ unsignedTx.txMeta.inputsToSign
47
+ const skipFinalize = isExternalPsbt || unsignedTx.txMeta.returnPsbt
44
48
  return extractTransaction({ psbt, skipFinalize })
45
49
  }
46
50
  }