@exodus/bitcoin-api 4.3.0 → 4.4.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,18 @@
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.4.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.4.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
+
17
+
6
18
  ## [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
19
 
8
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.3.0",
3
+ "version": "4.4.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": "5bc77cf38ec6fbd32d9e286d5bda25ea7366854d"
64
64
  }
@@ -0,0 +1,502 @@
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, allowedPurposes }) {
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, allowedPurposes, `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({
79
+ txOutput,
80
+ psbtOutput,
81
+ index,
82
+ asset,
83
+ purposeXPubs,
84
+ psbt,
85
+ allowedPurposes,
86
+ }) {
87
+ const address = txOutput.address ?? asset.address.fromScriptPubKey(txOutput.script)
88
+ const output = { amount: txOutput.value }
89
+
90
+ const outputMetadata = readPsbtOutputField(psbt, index)
91
+ if (outputMetadata) {
92
+ output.metadata = outputMetadata
93
+ }
94
+
95
+ const purpose = asset.address.resolvePurpose(address)
96
+ try {
97
+ validatePurpose(purpose, allowedPurposes)
98
+ } catch {
99
+ output.address = { address }
100
+ return output
101
+ }
102
+
103
+ const { isTaprootAddress } = getAddressType(purpose)
104
+
105
+ const derivation = getBip32Derivation(
106
+ purposeXPubs[purpose].hdkey,
107
+ isTaprootAddress ? psbtOutput.tapBip32Derivation : psbtOutput.bip32Derivation,
108
+ isTaprootAddress
109
+ )
110
+
111
+ output.address = derivation
112
+ ? { address, meta: { path: derivation.path, purpose } }
113
+ : { address, meta: { purpose } }
114
+
115
+ return output
116
+ }
117
+
118
+ function readGlobalMetadata(psbt) {
119
+ return {
120
+ blockHeight: readPsbtGlobalField(psbt, SubType.BlockHeight),
121
+ rbfEnabled: readPsbtGlobalField(psbt, SubType.RbfEnabled),
122
+ bumpTxId: readPsbtGlobalField(psbt, SubType.BumpTxId),
123
+ txType: readPsbtGlobalField(psbt, SubType.TxType),
124
+ }
125
+ }
126
+
127
+ 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
131
+ }
132
+
133
+ function getBip32Derivation(hdkey, bip32Derivations, ignoreY) {
134
+ const fingerprintBuffer = Buffer.alloc(4)
135
+ fingerprintBuffer.writeUInt32BE(hdkey.fingerprint, 0)
136
+
137
+ const matchingDerivations = bip32Derivations?.filter((bipDv) => {
138
+ return bipDv.masterFingerprint.equals(fingerprintBuffer)
139
+ })
140
+
141
+ if (!matchingDerivations?.length) {
142
+ // No derivation matched our fingerprint; the output isn’t ours.
143
+ return null
144
+ }
145
+
146
+ if (matchingDerivations.length !== 1) {
147
+ // Multiple matches imply the input/output carries several keys from the same wallet xpub.
148
+ // That’s outside our wallet model, so we fail fast.
149
+ throw new Error(
150
+ `more than one matching derivation for fingerprint ${fingerprintBuffer.toString('hex')}: ${
151
+ matchingDerivations.length
152
+ }`
153
+ )
154
+ }
155
+
156
+ const [derivation] = matchingDerivations
157
+ const publicKey = hdkey.derive(derivation.path).publicKey
158
+
159
+ const publicKeyToCompare = ignoreY ? publicKeyToX({ publicKey, format: 'buffer' }) : publicKey
160
+
161
+ if (publicKeyToCompare.equals(derivation.pubkey)) {
162
+ return derivation
163
+ }
164
+
165
+ throw new Error('pubkey did not match bip32Derivation')
166
+ }
167
+
168
+ function getSelectedUtxos({ parsedInputs, utxos, asset, txSet }) {
169
+ const utxoId = (txId, vout) => txId + ':' + vout
170
+ const utxosArray = Array.isArray(utxos) ? utxos : utxos.toArray()
171
+ const utxosMap = new Map(utxosArray.map((u) => [utxoId(u.txId, u.vout), u]))
172
+
173
+ const inputIds = new Set(parsedInputs.map((inp) => utxoId(inp.txId, inp.vout)))
174
+ let detectedReplaceTx = null
175
+
176
+ const replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
177
+ for (const replaceableTx of replaceableTxs) {
178
+ if (replaceableTx.data?.inputs) {
179
+ const txInputs = UtxoCollection.fromJSON(replaceableTx.data.inputs, {
180
+ currency: asset.currency,
181
+ })
182
+
183
+ const txInputsArray = txInputs.toArray()
184
+
185
+ const txInputIds = txInputsArray.map((u) => utxoId(u.txId, u.vout))
186
+ const isReplacement = txInputIds.some((id) => inputIds.has(id))
187
+
188
+ if (isReplacement) {
189
+ detectedReplaceTx = replaceableTx
190
+
191
+ for (const utxo of txInputsArray) {
192
+ const id = utxoId(utxo.txId, utxo.vout)
193
+ if (!utxosMap.has(id)) {
194
+ utxosMap.set(id, utxo)
195
+ }
196
+ }
197
+
198
+ // Exodus only handles a single replaceable transaction at a time, so bail once we’ve tagged one.
199
+ break
200
+ }
201
+ }
202
+ }
203
+
204
+ const inputUtxos = []
205
+ const missingInputIds = []
206
+
207
+ for (const parsedInput of parsedInputs) {
208
+ const id = utxoId(parsedInput.txId, parsedInput.vout)
209
+ const ourUtxo = utxosMap.get(id)
210
+
211
+ if (ourUtxo) {
212
+ inputUtxos.push(ourUtxo)
213
+ } else {
214
+ missingInputIds.push(id)
215
+ }
216
+ }
217
+
218
+ return {
219
+ selectedUtxos: UtxoCollection.fromArray(inputUtxos, { currency: asset.currency }),
220
+ replaceTx: detectedReplaceTx,
221
+ missingInputIds,
222
+ }
223
+ }
224
+
225
+ async function getChangeOutputData({ outputs, assetClientInterface, walletAccount, asset }) {
226
+ let changeOutputData = null
227
+ for (const [i, output] of outputs.entries()) {
228
+ // Outputs marked “primary” are treated as sends even if they use one of our paths
229
+ // (common for self‑sends, consolidations, etc.), so skip them during change detection.
230
+ if (output.metadata?.outputRole === 'primary') {
231
+ continue
232
+ }
233
+
234
+ // We only treat addresses we can re-derive from the wallet xpub as potential
235
+ // change outputs.
236
+ if (!output.address?.meta?.path) {
237
+ continue
238
+ }
239
+
240
+ // At this point we already know the output was derived by our wallet and it is
241
+ // not explicitly flagged as a primary/send output, so we tentatively treat it as
242
+ // change and confirm by re-deriving the on-chain address.
243
+ if (changeOutputData) {
244
+ // Multiple change outputs are a smell. This can happen if an external PSBT
245
+ // marks a send output of the same wallet with its derivation path, so we fail fast instead of
246
+ // silently mislabelling funds.
247
+ throw new Error('Multiple change outputs are not allowed')
248
+ }
249
+
250
+ const { path, purpose } = output.address.meta
251
+ const [chainIndex, addressIndex] = BipPath.fromString(path).toPathArray()
252
+
253
+ const verifyAddr = await assetClientInterface.getAddress({
254
+ assetName: asset.name,
255
+ walletAccount: walletAccount.toString(),
256
+ purpose,
257
+ chainIndex,
258
+ addressIndex,
259
+ })
260
+
261
+ if (verifyAddr.toString() === output.address.address) {
262
+ changeOutputData = {
263
+ address: verifyAddr,
264
+ amount: output.amount,
265
+ index: i,
266
+ }
267
+ } else {
268
+ throw new Error(`Invalid change output address ${output.address.address}`)
269
+ }
270
+ }
271
+
272
+ return changeOutputData
273
+ }
274
+
275
+ function findPrimaryOutputIndexes(outputs) {
276
+ return outputs
277
+ .map((output, index) => (output.metadata?.outputRole === 'primary' ? index : null))
278
+ .filter((index) => index !== null)
279
+ }
280
+
281
+ function processReplacementTransaction(replaceTx, asset) {
282
+ if (!replaceTx) return null
283
+
284
+ const clonedTx = replaceTx.clone()
285
+ const updatedTx = clonedTx.update({ data: { ...clonedTx.data } })
286
+
287
+ updatedTx.data.sent = updatedTx?.data?.sent.map((to) => ({
288
+ ...to,
289
+ amount: asset.currency.baseUnit(to.amount),
290
+ }))
291
+
292
+ return updatedTx
293
+ }
294
+
295
+ function calculateAmounts({ primaryOutputs, changeOutputData, processedReplaceTx, asset }) {
296
+ const sendAmounts = primaryOutputs.map((primaryOutput) =>
297
+ asset.currency.baseUnit(primaryOutput.amount)
298
+ )
299
+ const totalSendAmount = sendAmounts.reduce((sum, amount) => sum.add(amount), asset.currency.ZERO)
300
+
301
+ const changeAmount = changeOutputData
302
+ ? asset.currency.baseUnit(changeOutputData.amount)
303
+ : asset.currency.ZERO
304
+
305
+ const totalAmount = processedReplaceTx
306
+ ? processedReplaceTx.data.sent.reduce((total, { amount }) => total.add(amount), totalSendAmount)
307
+ : totalSendAmount
308
+
309
+ return { sendAmounts, totalSendAmount, changeAmount, totalAmount }
310
+ }
311
+
312
+ function buildAddressPathsMap(selectedUtxos, changeOutputData) {
313
+ const addressPathsMap = selectedUtxos.getAddressPathsMap()
314
+
315
+ if (changeOutputData) {
316
+ addressPathsMap[changeOutputData.address.address] = changeOutputData.address.meta.path
317
+ }
318
+
319
+ return addressPathsMap
320
+ }
321
+
322
+ function extractRawTransactions(parsedInputs) {
323
+ const rawTxsData = parsedInputs
324
+ .filter((parsedInput) => parsedInput.prevTxId)
325
+ .map((parsedInput) => ({
326
+ txId: parsedInput.prevTxId,
327
+ rawData: parsedInput.prevTxHex,
328
+ }))
329
+
330
+ const rawTxsMap = new Map(rawTxsData.map((tx) => [tx.txId, tx.rawData]))
331
+ return [...rawTxsMap].map(([txId, rawData]) => ({ txId, rawData }))
332
+ }
333
+
334
+ export async function parsePsbt({
335
+ psbtBase64,
336
+ asset,
337
+ assetClientInterface,
338
+ walletAccount,
339
+ allowedPurposes,
340
+ }) {
341
+ assert(psbtBase64, 'psbtBase64 is required')
342
+ assert(asset, 'asset is required')
343
+ assert(assetClientInterface, 'assetClientInterface is required')
344
+ assert(walletAccount, 'walletAccount is required')
345
+
346
+ const purposeXPubs = await getPurposeXPubs({
347
+ assetClientInterface,
348
+ walletAccount,
349
+ asset,
350
+ allowedPurposes,
351
+ })
352
+
353
+ // TBD: change it to fromBuffer
354
+ const psbt = Psbt.fromBase64(psbtBase64, { network: asset.coinInfo.toBitcoinJS() })
355
+
356
+ const inputs = []
357
+ for (let i = 0; i < psbt.inputCount; i++) {
358
+ const input = parseSingleInput({
359
+ txInput: psbt.txInputs[i],
360
+ psbtInput: psbt.data.inputs[i],
361
+ index: i,
362
+ asset,
363
+ purposeXPubs,
364
+ allowedPurposes,
365
+ })
366
+ inputs.push(input)
367
+ }
368
+
369
+ const outputs = []
370
+ for (let i = 0; i < psbt.data.outputs.length; i++) {
371
+ const output = parseSingleOutput({
372
+ txOutput: psbt.txOutputs[i],
373
+ psbtOutput: psbt.data.outputs[i],
374
+ index: i,
375
+ asset,
376
+ purposeXPubs,
377
+ psbt,
378
+ allowedPurposes,
379
+ })
380
+ outputs.push(output)
381
+ }
382
+
383
+ const globalMetadata = readGlobalMetadata(psbt)
384
+ const fee = calculateFee(inputs, outputs)
385
+
386
+ return {
387
+ inputs,
388
+ outputs,
389
+ fee,
390
+ globalMetadata,
391
+ }
392
+ }
393
+
394
+ export async function extractTransactionContext({
395
+ psbtBase64,
396
+ asset,
397
+ assetClientInterface,
398
+ walletAccount,
399
+ allowedPurposes,
400
+ }) {
401
+ assert(psbtBase64, 'psbtBase64 is required')
402
+ assert(asset, 'asset is required')
403
+ assert(assetClientInterface, 'assetClientInterface is required')
404
+ assert(walletAccount, 'walletAccount is required')
405
+
406
+ const {
407
+ inputs: parsedInputs,
408
+ outputs: parsedOutputs,
409
+ fee: calculatedFee,
410
+ globalMetadata,
411
+ } = await parsePsbt({
412
+ psbtBase64,
413
+ asset,
414
+ assetClientInterface,
415
+ walletAccount,
416
+ allowedPurposes,
417
+ })
418
+
419
+ const changeOutputData = await getChangeOutputData({
420
+ outputs: parsedOutputs,
421
+ assetClientInterface,
422
+ walletAccount,
423
+ asset,
424
+ })
425
+
426
+ const assetName = asset.name
427
+ const accountState = await assetClientInterface.getAccountState({
428
+ assetName,
429
+ walletAccount,
430
+ })
431
+
432
+ const utxos = getUtxos({ accountState, asset })
433
+ const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
434
+
435
+ const { selectedUtxos, replaceTx, missingInputIds } = getSelectedUtxos({
436
+ parsedInputs,
437
+ utxos,
438
+ txSet,
439
+ asset,
440
+ })
441
+
442
+ if (missingInputIds.length > 0) {
443
+ // Missing inputs mean we no longer control some UTXOs referenced in the PSBT.
444
+ // Typical causes: they’ve already been spent from another device, or the RBF
445
+ // transaction we planned to replace has since confirmed and dropped out of the
446
+ // replaceable set.
447
+ throw new Error(`Unknown inputs: ${missingInputIds.join(', ')}`)
448
+ }
449
+
450
+ const processedReplaceTx = processReplacementTransaction(replaceTx, asset)
451
+
452
+ const primaryOutputIndexes = findPrimaryOutputIndexes(parsedOutputs)
453
+ const primaryOutputs = primaryOutputIndexes.map((v) => parsedOutputs[v])
454
+
455
+ const { sendAmounts, totalSendAmount, changeAmount, totalAmount } = calculateAmounts({
456
+ primaryOutputs,
457
+ changeOutputData,
458
+ processedReplaceTx,
459
+ asset,
460
+ })
461
+
462
+ const unconfirmedTxAncestor = getUnconfirmedTxAncestorMap({ accountState })
463
+ const feeData = await assetClientInterface.getFeeConfig({ assetName })
464
+ const usableUtxos = getUsableUtxos({
465
+ asset,
466
+ utxos,
467
+ feeData,
468
+ txSet,
469
+ unconfirmedTxAncestor,
470
+ })
471
+
472
+ const inputs = createInputs(assetName, selectedUtxos.toArray(), globalMetadata.rbfEnabled)
473
+ const outputs = parsedOutputs.map((output) =>
474
+ createOutput(assetName, output.address.address, asset.currency.baseUnit(output.amount))
475
+ )
476
+
477
+ const addressPathsMap = buildAddressPathsMap(selectedUtxos, changeOutputData)
478
+ const rawTxs = extractRawTransactions(parsedInputs)
479
+
480
+ return {
481
+ inputs,
482
+ outputs,
483
+ addressPathsMap,
484
+ rawTxs,
485
+ fee: asset.currency.baseUnit(calculatedFee),
486
+ sendOutputIndexes: primaryOutputIndexes,
487
+ changeOutputIndex: changeOutputData?.index,
488
+ sendAmounts,
489
+ changeAmount,
490
+ totalSendAmount,
491
+ totalAmount,
492
+ primaryAddresses: primaryOutputs.map((primaryOutput) => primaryOutput.address),
493
+ ourAddress: changeOutputData?.address,
494
+ usableUtxos,
495
+ selectedUtxos,
496
+ replaceTx: processedReplaceTx,
497
+ sendOutputs: primaryOutputIndexes.map((primaryOutputIndex) => outputs[primaryOutputIndex]),
498
+ changeOutput: changeOutputData ? outputs[changeOutputData.index] : undefined,
499
+ rbfEnabled: globalMetadata.rbfEnabled,
500
+ blockHeight: globalMetadata.blockHeight,
501
+ }
502
+ }