@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 +26 -0
- package/package.json +3 -2
- package/src/insight-api-client/index.js +27 -2
- package/src/psbt-builder.js +3 -2
- package/src/psbt-utils.js +33 -0
- package/src/tx-log/bitcoin-monitor-scanner.js +10 -3
- package/src/tx-send/broadcast-tx.js +10 -7
- package/src/tx-send/index.js +3 -1
- package/src/tx-sign/default-prepare-for-signing.js +9 -3
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
const error = new Error(INSIGHT_MISSING_TXID_MESSAGE)
|
|
248
|
+
error.traceId = traceId
|
|
249
|
+
throw error
|
|
225
250
|
}
|
|
226
251
|
}
|
|
227
252
|
|
package/src/psbt-builder.js
CHANGED
|
@@ -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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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(
|
|
20
|
-
/absurdly-high-fee/.test(
|
|
21
|
-
/too-long-mempool-chain/.test(
|
|
22
|
-
/txn-mempool-conflict/.test(
|
|
23
|
-
/tx-size/.test(
|
|
24
|
-
/txn-already-in-mempool/.test(
|
|
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.
|
|
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
|
package/src/tx-send/index.js
CHANGED
|
@@ -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 (/
|
|
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 = {
|
|
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 = {
|
|
136
|
+
txIn.witnessUtxo = {
|
|
137
|
+
value: normalizeWitnessUtxoValue(value),
|
|
138
|
+
script: Buffer.from(script, 'hex'),
|
|
139
|
+
}
|
|
134
140
|
}
|
|
135
141
|
}
|
|
136
142
|
|