@exodus/bitcoin-api 4.2.2 → 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,28 @@
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
+
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)
19
+
20
+
21
+ ### Features
22
+
23
+
24
+ * feat: add PSBT builder infrastructure (#6822)
25
+
26
+
27
+
6
28
  ## [4.2.2](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.2.1...@exodus/bitcoin-api@4.2.2) (2025-11-10)
7
29
 
8
30
  **Note:** Version bump only for package @exodus/bitcoin-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.2.2",
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": "ba11d8958a6416d939b43f5297930f4069f7daa8"
63
+ "gitHead": "5bc77cf38ec6fbd32d9e286d5bda25ea7366854d"
64
64
  }
@@ -0,0 +1,248 @@
1
+ import { payments, Psbt, Transaction } from '@exodus/bitcoinjs'
2
+ import { publicKeyToX } from '@exodus/crypto/secp256k1'
3
+ import assert from 'minimalistic-assert'
4
+
5
+ import { SubType, writePsbtGlobalField, writePsbtOutputField } from './psbt-proprietary-types.js'
6
+ import { getAddressType, getPurposeXPubs, validatePurpose } from './psbt-utils.js'
7
+
8
+ function canParseTx(rawTxBuffer) {
9
+ try {
10
+ Transaction.fromBuffer(rawTxBuffer)
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ function getWrappedSegwitRedeemScript({ publicKey, address, context = '' }) {
18
+ const p2wpkh = payments.p2wpkh({ pubkey: publicKey })
19
+ const p2sh = payments.p2sh({ redeem: p2wpkh })
20
+
21
+ if (address !== p2sh.address) {
22
+ throw new Error(`Expected P2SH script to be a nested p2wpkh${context ? ' for ' + context : ''}`)
23
+ }
24
+
25
+ return p2sh.redeem.output
26
+ }
27
+
28
+ function addBip32Derivation({ isTaprootAddress, publicKey, path, masterFingerprint }) {
29
+ if (isTaprootAddress) {
30
+ const pubkey = publicKeyToX({ publicKey, format: 'buffer' })
31
+ return {
32
+ tapBip32Derivation: [
33
+ {
34
+ path,
35
+ leafHashes: [],
36
+ masterFingerprint,
37
+ pubkey,
38
+ },
39
+ ],
40
+ tapInternalKey: pubkey,
41
+ }
42
+ }
43
+
44
+ return {
45
+ bip32Derivation: [
46
+ {
47
+ path,
48
+ masterFingerprint,
49
+ pubkey: publicKey,
50
+ },
51
+ ],
52
+ }
53
+ }
54
+
55
+ function writeGlobalMetadata(psbt, metadata) {
56
+ if (metadata.blockHeight !== undefined) {
57
+ writePsbtGlobalField(psbt, SubType.BlockHeight, metadata.blockHeight)
58
+ }
59
+
60
+ if (metadata.rbfEnabled) {
61
+ writePsbtGlobalField(psbt, SubType.RbfEnabled, metadata.rbfEnabled)
62
+ }
63
+
64
+ if (metadata.bumpTxId) {
65
+ writePsbtGlobalField(psbt, SubType.BumpTxId, metadata.bumpTxId)
66
+ }
67
+
68
+ if (metadata.txType) {
69
+ writePsbtGlobalField(psbt, SubType.TxType, metadata.txType)
70
+ }
71
+ }
72
+
73
+ function createPsbtInput({
74
+ input,
75
+ asset,
76
+ addressPathsMap,
77
+ purposeXPubs,
78
+ nonWitnessTxs,
79
+ allowedPurposes,
80
+ }) {
81
+ const psbtInput = {
82
+ hash: input.txId,
83
+ index: input.vout,
84
+ sequence: input.sequence,
85
+ }
86
+
87
+ const purpose = asset.address.resolvePurpose(input.address)
88
+ validatePurpose(purpose, allowedPurposes, `address ${input.address}`)
89
+
90
+ const { isSegwitAddress, isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
91
+
92
+ const path = addressPathsMap[input.address]
93
+ assert(path, `Derivation path is missing for address ${input.address}`)
94
+
95
+ const publicKey = purposeXPubs[purpose].hdkey.derive(path).publicKey
96
+ const masterFingerprint = purposeXPubs[purpose].masterFingerprint
97
+
98
+ if (isWrappedSegwitAddress) {
99
+ psbtInput.redeemScript = getWrappedSegwitRedeemScript({
100
+ publicKey,
101
+ address: input.address,
102
+ context: `input address ${input.address}`,
103
+ })
104
+ }
105
+
106
+ if (isWrappedSegwitAddress || isSegwitAddress || isTaprootAddress) {
107
+ psbtInput.witnessUtxo = {
108
+ value: input.value,
109
+ script: Buffer.from(input.script, 'hex'),
110
+ }
111
+ }
112
+
113
+ if (!isTaprootAddress) {
114
+ const rawTx = (nonWitnessTxs || []).find((t) => t.txId === input.txId)
115
+ assert(
116
+ !!rawTx?.rawData,
117
+ `Non-taproot outputs require the full previous transaction for address ${input.address}`
118
+ )
119
+
120
+ const rawTxBuffer = Buffer.from(rawTx.rawData, 'hex')
121
+ if (canParseTx(rawTxBuffer)) {
122
+ psbtInput.nonWitnessUtxo = rawTxBuffer
123
+ } else {
124
+ // bitcoinjs can’t parse a handful of edge-case transactions (Litecoin MWEB, odd
125
+ // vendor forks, malformed historical data). When that happens we fall back to a
126
+ // witness-only record and rely on the signer to opt-in to __UNSAFE_SIGN_NONSEGWIT.
127
+ psbtInput.witnessUtxo = {
128
+ value: input.value,
129
+ script: Buffer.from(input.script, 'hex'),
130
+ }
131
+ }
132
+ }
133
+
134
+ const derivationData = addBip32Derivation({
135
+ isTaprootAddress,
136
+ publicKey,
137
+ path,
138
+ masterFingerprint,
139
+ })
140
+
141
+ return { ...psbtInput, ...derivationData }
142
+ }
143
+
144
+ function createPsbtOutput({
145
+ address,
146
+ amount,
147
+ asset,
148
+ addressPathsMap,
149
+ purposeXPubs,
150
+ allowedPurposes,
151
+ }) {
152
+ const psbtOutput = {
153
+ address,
154
+ value: amount,
155
+ }
156
+
157
+ const path = addressPathsMap[address]
158
+ if (!path) {
159
+ return psbtOutput
160
+ }
161
+
162
+ const purpose = asset.address.resolvePurpose(address)
163
+ validatePurpose(purpose, allowedPurposes, `output address ${address}`)
164
+
165
+ const { isTaprootAddress, isWrappedSegwitAddress } = getAddressType(purpose)
166
+
167
+ const publicKey = purposeXPubs[purpose].hdkey.derive(path).publicKey
168
+ const masterFingerprint = purposeXPubs[purpose].masterFingerprint
169
+
170
+ if (isWrappedSegwitAddress) {
171
+ psbtOutput.redeemScript = getWrappedSegwitRedeemScript({
172
+ publicKey,
173
+ address,
174
+ context: `output address ${address}`,
175
+ })
176
+ }
177
+
178
+ const derivationData = addBip32Derivation({
179
+ isTaprootAddress,
180
+ publicKey,
181
+ path,
182
+ masterFingerprint,
183
+ })
184
+
185
+ return { ...psbtOutput, ...derivationData }
186
+ }
187
+
188
+ export async function createPsbtWithMetadata({
189
+ inputs,
190
+ outputs,
191
+ asset,
192
+ assetClientInterface,
193
+ walletAccount,
194
+ nonWitnessTxs,
195
+ addressPathsMap,
196
+ metadata,
197
+ allowedPurposes,
198
+ }) {
199
+ assert(inputs, 'inputs is required')
200
+ assert(outputs, 'outputs is required')
201
+ assert(asset, 'asset is required')
202
+ assert(assetClientInterface, 'assetClientInterface is required')
203
+ assert(walletAccount, 'walletAccount is required')
204
+ assert(addressPathsMap, 'addressPathsMap is required')
205
+ assert(metadata, 'metadata is required')
206
+
207
+ const psbt = new Psbt({ network: asset.coinInfo.toBitcoinJS() })
208
+
209
+ const purposeXPubs = await getPurposeXPubs({
210
+ assetClientInterface,
211
+ walletAccount,
212
+ asset,
213
+ allowedPurposes,
214
+ })
215
+
216
+ writeGlobalMetadata(psbt, metadata)
217
+
218
+ for (const input of inputs) {
219
+ const psbtInput = createPsbtInput({
220
+ input,
221
+ asset,
222
+ addressPathsMap,
223
+ purposeXPubs,
224
+ nonWitnessTxs,
225
+ allowedPurposes,
226
+ })
227
+ psbt.addInput(psbtInput)
228
+ }
229
+
230
+ outputs.forEach(([address, amount]) => {
231
+ const psbtOutput = createPsbtOutput({
232
+ address,
233
+ amount,
234
+ asset,
235
+ addressPathsMap,
236
+ purposeXPubs,
237
+ allowedPurposes,
238
+ })
239
+ psbt.addOutput(psbtOutput)
240
+ })
241
+
242
+ // sendOutputIndexes is optional (e.g., bump transactions).
243
+ for (const sendOutputIndex of metadata.sendOutputIndexes || []) {
244
+ writePsbtOutputField(psbt, sendOutputIndex, SubType.OutputRole, 'primary')
245
+ }
246
+
247
+ return psbt.toBase64()
248
+ }
@@ -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
+ }
@@ -11,6 +11,10 @@ const PROP_TYPE_MARKER = 0xfc
11
11
 
12
12
  export const SubType = Object.freeze({
13
13
  BlockHeight: 0x01,
14
+ BumpTxId: 0x03,
15
+ RbfEnabled: 0x04,
16
+ TxType: 0x05,
17
+ OutputRole: 0x06,
14
18
  })
15
19
 
16
20
  const buildPropKey = (subType) => {
@@ -51,3 +55,97 @@ export const readPsbtBlockHeight = (psbt) => {
51
55
  const kv = findProprietaryVal(psbt.data.globalMap.unknownKeyVals, SubType.BlockHeight)
52
56
  return kv ? kv.value.readUInt32LE(0) : undefined
53
57
  }
58
+
59
+ const encodeProprietaryValue = (subType, value) => {
60
+ switch (subType) {
61
+ case SubType.BlockHeight:
62
+ assert(
63
+ Number.isInteger(value) && value >= 0 && value <= 4_294_967_295,
64
+ 'blockHeight must be a positive integer between 0 and 4294967295'
65
+ )
66
+ return u32LE(value)
67
+ case SubType.RbfEnabled:
68
+ return Buffer.from([value ? 1 : 0])
69
+ case SubType.BumpTxId:
70
+ case SubType.TxType:
71
+ case SubType.OutputRole:
72
+ return Buffer.from(value, 'utf8')
73
+ default:
74
+ throw new Error(`Unknown proprietary field subType: ${subType}`)
75
+ }
76
+ }
77
+
78
+ const decodeProprietaryValue = (subType, buffer) => {
79
+ switch (subType) {
80
+ case SubType.BlockHeight:
81
+ return buffer.readUInt32LE(0)
82
+ case SubType.RbfEnabled:
83
+ return buffer[0] === 1
84
+ case SubType.BumpTxId:
85
+ case SubType.TxType:
86
+ case SubType.OutputRole:
87
+ return buffer.toString('utf8')
88
+ default:
89
+ throw new Error(`Unknown proprietary field subType: ${subType}`)
90
+ }
91
+ }
92
+
93
+ export const writePsbtGlobalField = (psbt, subType, value) => {
94
+ assert(psbt, 'psbt is required')
95
+ assert(Number.isInteger(subType), 'subType must be an integer')
96
+ assert(value !== undefined, 'value is required')
97
+
98
+ const encodedValue = encodeProprietaryValue(subType, value)
99
+ psbt.addUnknownKeyValToGlobal({
100
+ key: buildPropKey(subType),
101
+ value: encodedValue,
102
+ })
103
+ }
104
+
105
+ export const writePsbtOutputField = (psbt, outputIndex, subType, value) => {
106
+ assert(psbt, 'psbt is required')
107
+ assert(
108
+ Number.isInteger(outputIndex) && outputIndex >= 0,
109
+ 'outputIndex must be a non-negative integer'
110
+ )
111
+ assert(Number.isInteger(subType), 'subType must be an integer')
112
+ assert(value !== undefined, 'value is required')
113
+
114
+ const encodedValue = encodeProprietaryValue(subType, value)
115
+ psbt.addUnknownKeyValToOutput(outputIndex, {
116
+ key: buildPropKey(subType),
117
+ value: encodedValue,
118
+ })
119
+ }
120
+
121
+ export const readPsbtGlobalField = (psbt, subType) => {
122
+ assert(psbt, 'psbt is required')
123
+ assert(Number.isInteger(subType), 'subType must be an integer')
124
+
125
+ const kv = findProprietaryVal(psbt.data.globalMap.unknownKeyVals, subType)
126
+ return kv ? decodeProprietaryValue(subType, kv.value) : undefined
127
+ }
128
+
129
+ export const readPsbtOutputField = (psbt, outputIndex) => {
130
+ assert(psbt, 'psbt is required')
131
+ assert(
132
+ Number.isInteger(outputIndex) && outputIndex >= 0,
133
+ 'outputIndex must be a non-negative integer'
134
+ )
135
+
136
+ const output = psbt.data.outputs[outputIndex]
137
+ if (!output) return Object.create(null)
138
+
139
+ const result = Object.create(null)
140
+ for (const [key, subTypeVal] of Object.entries(SubType)) {
141
+ const kv = findProprietaryVal(output.unknownKeyVals, subTypeVal)
142
+ if (kv) {
143
+ result[key.charAt(0).toLowerCase() + key.slice(1)] = decodeProprietaryValue(
144
+ subTypeVal,
145
+ kv.value
146
+ )
147
+ }
148
+ }
149
+
150
+ return result
151
+ }
package/src/psbt-utils.js CHANGED
@@ -1,10 +1,62 @@
1
+ import BIP32 from '@exodus/bip32'
1
2
  import BipPath from 'bip32-path'
2
3
  import lodash from 'lodash'
4
+ import assert from 'minimalistic-assert'
5
+
6
+ export const PURPOSE_TYPES = {
7
+ LEGACY: 44, // P2PKH
8
+ WRAPPED_SEGWIT: 49, // P2SH-P2WPKH
9
+ SEGWIT: 84, // P2WPKH
10
+ TAPROOT: 86, // P2TR
11
+ }
12
+
13
+ export function getAddressType(purpose) {
14
+ return {
15
+ isLegacyAddress: purpose === PURPOSE_TYPES.LEGACY,
16
+ isWrappedSegwitAddress: purpose === PURPOSE_TYPES.WRAPPED_SEGWIT,
17
+ isSegwitAddress: purpose === PURPOSE_TYPES.SEGWIT,
18
+ isTaprootAddress: purpose === PURPOSE_TYPES.TAPROOT,
19
+ }
20
+ }
21
+
22
+ export async function getPurposeXPubs({
23
+ assetClientInterface,
24
+ walletAccount,
25
+ asset,
26
+ allowedPurposes,
27
+ }) {
28
+ assert(allowedPurposes, 'allowedPurposes is required')
29
+ const purposeXPubs = Object.create(null)
30
+
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,
43
+ }
44
+ }
45
+
46
+ return purposeXPubs
47
+ }
48
+
49
+ export function validatePurpose(purpose, allowedPurposes, context = '') {
50
+ assert(allowedPurposes, 'allowedPurposes is required')
51
+ if (!allowedPurposes.includes(purpose)) {
52
+ throw new Error(`Purpose ${purpose} not found${context ? ' for ' + context : ''}`)
53
+ }
54
+ }
3
55
 
4
56
  export const createPsbtToUnsignedTx =
5
57
  ({ assetClientInterface, assetName }) =>
6
58
  async ({ psbt, walletAccount, purpose = 86 }) => {
7
- const addressPathsMap = {}
59
+ const addressPathsMap = Object.create(null)
8
60
  const inputsToSign = []
9
61
 
10
62
  const addressOpts = {
@@ -59,3 +111,18 @@ export const createPsbtToUnsignedTx =
59
111
  },
60
112
  }
61
113
  }
114
+
115
+ /**
116
+ * Temporarily turns on __UNSAFE_SIGN_NONSEGWIT so we can sign or validate PSBTs
117
+ * whose legacy inputs are missing nonWitnessUtxo.
118
+ */
119
+ export async function withUnsafeNonSegwit({ psbt, fn, unsafe = true }) {
120
+ const cache = psbt.__CACHE
121
+ const prevValue = cache.__UNSAFE_SIGN_NONSEGWIT
122
+ cache.__UNSAFE_SIGN_NONSEGWIT = unsafe
123
+ try {
124
+ return await fn()
125
+ } finally {
126
+ cache.__UNSAFE_SIGN_NONSEGWIT = prevValue
127
+ }
128
+ }