@exodus/bitcoin-api 4.9.5 → 4.10.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.10.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.6...@exodus/bitcoin-api@4.10.0) (2026-03-11)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: extract trace id in bitcoin (#7278)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+
18
+ * fix(bitcoin-api): avoid restoring spent dropped inputs (#7559)
19
+
20
+
21
+
22
+ ## [4.9.6](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.5...@exodus/bitcoin-api@4.9.6) (2026-03-02)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+
28
+ * fix(bitcoin-api): normalize witnessUtxo values for PSBT input (#7497)
29
+
30
+
31
+
6
32
  ## [4.9.5](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.4...@exodus/bitcoin-api@4.9.5) (2026-02-14)
7
33
 
8
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.9.5",
3
+ "version": "4.10.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",
@@ -36,6 +36,7 @@
36
36
  "@exodus/safe-string": "^1.4.0",
37
37
  "@exodus/send-validation-model": "^1.0.0",
38
38
  "@exodus/simple-retry": "^0.0.6",
39
+ "@exodus/traceparent": "^3.0.1",
39
40
  "bech32": "^1.1.3",
40
41
  "bip32-path": "^0.4.2",
41
42
  "bs58check": "^3.0.1",
@@ -61,5 +62,5 @@
61
62
  "type": "git",
62
63
  "url": "git+https://github.com/ExodusMovement/assets.git"
63
64
  },
64
- "gitHead": "8c4fcde522beb978df6a5099e0fa9e34bb4a7e04"
65
+ "gitHead": "2d0154787aed614543dcfcc0f4c216d7057fcbb2"
65
66
  }
@@ -1,5 +1,6 @@
1
1
  import { safeString } from '@exodus/safe-string'
2
2
  import { retry } from '@exodus/simple-retry'
3
+ import { TraceId } from '@exodus/traceparent'
3
4
  import delay from 'delay'
4
5
  import lodash from 'lodash'
5
6
  import urlJoin from 'url-join'
@@ -23,6 +24,19 @@ const INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE = safeString`insight-api-http-error:b
23
24
  const INSIGHT_HTTP_ERROR_CLAIMABLE_MESSAGE = safeString`insight-api-http-error:claimable`
24
25
  const INSIGHT_HTTP_ERROR_UNCLAIMED_MESSAGE = safeString`insight-api-http-error:unclaimed`
25
26
 
27
+ const parseBroadcastErrorReason = (data) => {
28
+ if (!data) {
29
+ return null
30
+ }
31
+
32
+ try {
33
+ const parsed = JSON.parse(data)
34
+ return parsed?.error || data
35
+ } catch {
36
+ return data
37
+ }
38
+ }
39
+
26
40
  const fetchJson = async (
27
41
  url,
28
42
  fetchOptions,
@@ -35,8 +49,11 @@ const fetchJson = async (
35
49
  return null
36
50
  }
37
51
 
52
+ const traceId = TraceId.fromResponse(response)
53
+
38
54
  if (!response.ok) {
39
55
  const error = new Error(httpErrorMessage)
56
+ error.traceId = traceId
40
57
  error.code = `${response.status}`
41
58
  throw error
42
59
  }
@@ -44,7 +61,9 @@ const fetchJson = async (
44
61
  try {
45
62
  return await response.json()
46
63
  } catch (err) {
47
- throw new Error(INSIGHT_JSON_ERROR_MESSAGE, { cause: err })
64
+ const error = new Error(INSIGHT_JSON_ERROR_MESSAGE, { cause: err })
65
+ error.traceId = traceId
66
+ throw error
48
67
  }
49
68
  }
50
69
 
@@ -204,12 +223,16 @@ export default class InsightAPIClient {
204
223
  const response = await fetch(url, fetchOptions)
205
224
  let data = await response.text()
206
225
 
226
+ const traceId = TraceId.fromResponse(response)
227
+
207
228
  if (!response.ok) {
208
229
  console.warn('Insight Broadcast HTTP Error:')
209
230
  console.warn(response.statusText)
210
231
  console.warn(data)
211
232
  const error = new Error(INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE)
233
+ error.traceId = traceId
212
234
  error.code = `${response.status}`
235
+ error.reason = parseBroadcastErrorReason(data)
213
236
  throw error
214
237
  }
215
238
 
@@ -221,7 +244,9 @@ export default class InsightAPIClient {
221
244
  }
222
245
 
223
246
  if (!data.txid) {
224
- throw new Error(INSIGHT_MISSING_TXID_MESSAGE)
247
+ const error = new Error(INSIGHT_MISSING_TXID_MESSAGE)
248
+ error.traceId = traceId
249
+ throw error
225
250
  }
226
251
  }
227
252
 
@@ -6,6 +6,7 @@ import { SubType, writePsbtGlobalField, writePsbtOutputField } from './psbt-prop
6
6
  import {
7
7
  getAddressType,
8
8
  getPurposeXPubs,
9
+ normalizeWitnessUtxoValue,
9
10
  setPsbtVersionIfNotBitcoin,
10
11
  validatePurpose,
11
12
  } from './psbt-utils.js'
@@ -112,7 +113,7 @@ function createPsbtInput({
112
113
 
113
114
  if (isWrappedSegwitAddress || isSegwitAddress || isTaprootAddress) {
114
115
  psbtInput.witnessUtxo = {
115
- value: input.value,
116
+ value: normalizeWitnessUtxoValue(input.value),
116
117
  script: Buffer.from(input.script, 'hex'),
117
118
  }
118
119
  }
@@ -132,7 +133,7 @@ function createPsbtInput({
132
133
  // vendor forks, malformed historical data). When that happens we fall back to a
133
134
  // witness-only record and rely on the signer to opt-in to __UNSAFE_SIGN_NONSEGWIT.
134
135
  psbtInput.witnessUtxo = {
135
- value: input.value,
136
+ value: normalizeWitnessUtxoValue(input.value),
136
137
  script: Buffer.from(input.script, 'hex'),
137
138
  }
138
139
  }
package/src/psbt-utils.js CHANGED
@@ -132,6 +132,39 @@ export async function withUnsafeNonSegwit({ psbt, fn, unsafe = true }) {
132
132
  }
133
133
  }
134
134
 
135
+ export function normalizeWitnessUtxoValue(value) {
136
+ if (typeof value === 'number') {
137
+ if (!Number.isSafeInteger(value)) {
138
+ throw new TypeError('witnessUtxo value does not fit in a safe integer')
139
+ }
140
+
141
+ return value
142
+ }
143
+
144
+ if (typeof value === 'bigint') {
145
+ if (!Number.isSafeInteger(Number(value))) {
146
+ throw new TypeError('witnessUtxo value does not fit in a safe integer')
147
+ }
148
+
149
+ return Number(value)
150
+ }
151
+
152
+ if (Buffer.isBuffer(value)) {
153
+ if (value.length !== 8) {
154
+ throw new Error(`Unexpected witnessUtxo value buffer size: ${value.length}`)
155
+ }
156
+
157
+ const parsed = value.readBigUInt64LE(0)
158
+ if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) {
159
+ throw new Error('witnessUtxo value does not fit in a safe integer')
160
+ }
161
+
162
+ return Number(parsed)
163
+ }
164
+
165
+ throw new TypeError(`Unsupported witnessUtxo value type: ${typeof value}`)
166
+ }
167
+
135
168
  export function setPsbtVersionIfNotBitcoin(psbt, assetName) {
136
169
  if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
137
170
  }
@@ -632,9 +632,16 @@ export class BitcoinMonitorScanner {
632
632
  continue
633
633
  }
634
634
 
635
- const tx = await insightClient.fetchTx(utxo.txId)
636
- if (tx) {
637
- // previously spent tx still exists, readd utxo
635
+ let prevTx = null
636
+ try {
637
+ prevTx = await insightClient.fetchTxObject(utxo.txId)
638
+ } catch {
639
+ prevTx = null
640
+ }
641
+
642
+ const prevOutput = prevTx?.vout?.find((output) => output.n === utxo.vout)
643
+ if (prevOutput && !prevOutput.spentTxId) {
644
+ // Only readd inputs whose exact outpoint is still unspent.
638
645
  utxosToAdd.push(utxo)
639
646
  }
640
647
  }
@@ -1,5 +1,7 @@
1
1
  import { retry } from '@exodus/simple-retry'
2
2
 
3
+ const getErrorText = (error) => [error?.message, error?.reason].filter(Boolean).join(' ')
4
+
3
5
  /**
4
6
  * Broadcast a signed transaction to the Bitcoin network with retry logic
5
7
  * @param {Object} params
@@ -14,14 +16,15 @@ export async function broadcastTransaction({ asset, rawTx }) {
14
16
  try {
15
17
  return await asset.api.broadcastTx(rawTxHex)
16
18
  } catch (e) {
19
+ const errorText = getErrorText(e)
17
20
  // Mark certain errors as final (non-retryable)
18
21
  if (
19
- /missing inputs/i.test(e.message) ||
20
- /absurdly-high-fee/.test(e.message) ||
21
- /too-long-mempool-chain/.test(e.message) ||
22
- /txn-mempool-conflict/.test(e.message) ||
23
- /tx-size/.test(e.message) ||
24
- /txn-already-in-mempool/.test(e.message)
22
+ /(missing inputs|missingorspent)/i.test(errorText) ||
23
+ /absurdly-high-fee/.test(errorText) ||
24
+ /too-long-mempool-chain/.test(errorText) ||
25
+ /txn-mempool-conflict/.test(errorText) ||
26
+ /tx-size/.test(errorText) ||
27
+ /txn-already-in-mempool/.test(errorText)
25
28
  ) {
26
29
  e.finalError = true
27
30
  }
@@ -37,7 +40,7 @@ export async function broadcastTransaction({ asset, rawTx }) {
37
40
  try {
38
41
  await broadcastTxWithRetry(rawTxHex)
39
42
  } catch (err) {
40
- if (err.message.includes('txn-already-in-mempool')) {
43
+ if (getErrorText(err).includes('txn-already-in-mempool')) {
41
44
  // Not an error, transaction is already broadcast
42
45
  console.log('Transaction is already in the mempool.')
43
46
  return
@@ -6,6 +6,8 @@ import { extractTransactionContext } from '../psbt-parser.js'
6
6
  import { broadcastTransaction } from './broadcast-tx.js'
7
7
  import { updateAccountState, updateTransactionLog } from './update-state.js'
8
8
 
9
+ const getErrorText = (error) => [error?.message, error?.reason].filter(Boolean).join(' ')
10
+
9
11
  const checkTxExists = async ({ asset, txId }) => {
10
12
  try {
11
13
  const tx = await asset.insightClient.fetchTx(txId)
@@ -212,7 +214,7 @@ export const sendTxFactory = ({
212
214
  if (txExists) {
213
215
  console.warn(`tx-send: ${assetName} tx already broadcast`, txId)
214
216
  } else {
215
- if (/insight broadcast http error.*missing inputs/i.test(err.message)) {
217
+ if (/(missing inputs|missingorspent)/i.test(getErrorText(err))) {
216
218
  err.txInfo = JSON.stringify({
217
219
  amount: sendAmount.toDefaultString({ unit: true }),
218
220
  fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(),
@@ -2,7 +2,7 @@ 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
- import { setPsbtVersionIfNotBitcoin } from '../psbt-utils.js'
5
+ import { normalizeWitnessUtxoValue, setPsbtVersionIfNotBitcoin } from '../psbt-utils.js'
6
6
  import { getMaximumFeeRate } from './maximum-fee-rates.js'
7
7
 
8
8
  /**
@@ -115,7 +115,10 @@ function createPsbtFromTxData({
115
115
 
116
116
  if (isSegwitAddress || isTaprootAddress) {
117
117
  // taproot outputs only require the value and the script, not the full transaction
118
- txIn.witnessUtxo = { value, script: Buffer.from(script, 'hex') }
118
+ txIn.witnessUtxo = {
119
+ value: normalizeWitnessUtxoValue(value),
120
+ script: Buffer.from(script, 'hex'),
121
+ }
119
122
  }
120
123
 
121
124
  const rawTx = (rawTxs || []).find((t) => t.txId === txId)
@@ -130,7 +133,10 @@ function createPsbtFromTxData({
130
133
  // temp fix for https://exodusio.slack.com/archives/CP202D90Q/p1671014704829939 until bitcoinjs could parse a mweb tx without failing
131
134
  console.warn(`Setting psbt.__CACHE.__UNSAFE_SIGN_NONSEGWIT = true for asset ${assetName}`)
132
135
  psbt.__CACHE.__UNSAFE_SIGN_NONSEGWIT = true
133
- txIn.witnessUtxo = { value, script: Buffer.from(script, 'hex') }
136
+ txIn.witnessUtxo = {
137
+ value: normalizeWitnessUtxoValue(value),
138
+ script: Buffer.from(script, 'hex'),
139
+ }
134
140
  }
135
141
  }
136
142