@exodus/bitcoin-api 4.11.1 → 4.13.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,26 @@
|
|
|
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.13.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.12.0...@exodus/bitcoin-api@4.13.0) (2026-03-20)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* feat(bitcoin-api): add mempool rest client with insight parity (#7615)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [4.12.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.11.1...@exodus/bitcoin-api@4.12.0) (2026-03-20)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
* feat(bitcoin-api): add mempool ws client with insight parity (#7625)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
6
26
|
## [4.11.1](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.11.0...@exodus/bitcoin-api@4.11.1) (2026-03-19)
|
|
7
27
|
|
|
8
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/bitcoin-api",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@exodus/currency": "^6.0.1",
|
|
33
33
|
"@exodus/i18n-dummy": "^1.0.0",
|
|
34
34
|
"@exodus/key-identifier": "^1.3.0",
|
|
35
|
-
"@exodus/models": "^
|
|
35
|
+
"@exodus/models": "^13.0.0",
|
|
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",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"bip32-path": "^0.4.2",
|
|
42
42
|
"bs58check": "^3.0.1",
|
|
43
43
|
"delay": "^4.0.1",
|
|
44
|
+
"eventemitter3": "^5.0.4",
|
|
44
45
|
"events": "^3.3.0",
|
|
45
46
|
"lodash": "^4.17.21",
|
|
46
47
|
"minimalistic-assert": "^1.0.1",
|
|
@@ -62,5 +63,5 @@
|
|
|
62
63
|
"type": "git",
|
|
63
64
|
"url": "git+https://github.com/ExodusMovement/assets.git"
|
|
64
65
|
},
|
|
65
|
-
"gitHead": "
|
|
66
|
+
"gitHead": "49d472ae5c602ba68329227e0e0cd3a9b4568fba"
|
|
66
67
|
}
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,8 @@ export * from './balances.js'
|
|
|
3
3
|
export * from './btc-address.js'
|
|
4
4
|
export * from './btc-like-address.js'
|
|
5
5
|
export * from './btc-like-keys.js'
|
|
6
|
+
export { default as MempoolRestClient } from './insight-api-client/mempool-rest-client.js'
|
|
7
|
+
export { default as MempoolWSClient } from './insight-api-client/mempool-ws-client.js'
|
|
6
8
|
export { default as InsightAPIClient } from './insight-api-client/index.js'
|
|
7
9
|
export { default as InsightWSClient } from './insight-api-client/ws.js'
|
|
8
10
|
export { default as bip44Constants } from './constants/bip44.js'
|
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { safeString } from '@exodus/safe-string'
|
|
2
|
+
import { retry } from '@exodus/simple-retry'
|
|
3
|
+
import { TraceId } from '@exodus/traceparent'
|
|
4
|
+
import delay from 'delay'
|
|
5
|
+
import urlJoin from 'url-join'
|
|
6
|
+
|
|
7
|
+
const API_PAGE_SIZE = 25
|
|
8
|
+
const DEFAULT_PAGE_SIZE = 10
|
|
9
|
+
const RETRY_WAIT_TIMES = ['5s', '10s', '20s', '30s']
|
|
10
|
+
|
|
11
|
+
const MEMPOOL_HTTP_ERROR_MESSAGE = safeString`mempool-api-http-error`
|
|
12
|
+
const MEMPOOL_INSIGHT_JSON_ERROR_MESSAGE = safeString`mempool-insight-api-invalid-json`
|
|
13
|
+
const MEMPOOL_INSIGHT_MISSING_TXID_MESSAGE = safeString`mempool-insight-api-missing-txid`
|
|
14
|
+
const MEMPOOL_INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE = safeString`mempool-insight-api-http-error:broadcast`
|
|
15
|
+
const MEMPOOL_JSON_ERROR_MESSAGE = safeString`mempool-api-invalid-json`
|
|
16
|
+
const MEMPOOL_TEXT_ERROR_MESSAGE = safeString`mempool-api-invalid-text`
|
|
17
|
+
const MEMPOOL_HTTP_ERROR_TX_MESSAGE = safeString`mempool-api-http-error:tx`
|
|
18
|
+
const MEMPOOL_HTTP_ERROR_RAWTX_MESSAGE = safeString`mempool-api-http-error:rawtx`
|
|
19
|
+
const MEMPOOL_HTTP_ERROR_BALANCE_MESSAGE = safeString`mempool-api-http-error:balance`
|
|
20
|
+
const MEMPOOL_HTTP_ERROR_ADDR_TXS_MESSAGE = safeString`mempool-api-http-error:address-txs`
|
|
21
|
+
const MEMPOOL_HTTP_ERROR_UTXO_MESSAGE = safeString`mempool-api-http-error:utxo`
|
|
22
|
+
const MEMPOOL_HTTP_ERROR_PREVOUTS_MESSAGE = safeString`mempool-api-http-error:prevouts`
|
|
23
|
+
const MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE = safeString`mempool-api-http-error:outspends`
|
|
24
|
+
const MEMPOOL_HTTP_ERROR_STATUS_MESSAGE = safeString`mempool-api-http-error:status`
|
|
25
|
+
const MEMPOOL_HTTP_ERROR_FEES_MESSAGE = safeString`mempool-api-http-error:fees`
|
|
26
|
+
const MEMPOOL_HTTP_ERROR_CPFP_MESSAGE = safeString`mempool-api-http-error:cpfp`
|
|
27
|
+
const MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE = safeString`mempool-api-invalid-fees-payload`
|
|
28
|
+
const MEMPOOL_INVALID_VALUE_ERROR_MESSAGE = safeString`mempool-api-invalid-value`
|
|
29
|
+
|
|
30
|
+
const parseBroadcastErrorReason = (data) => {
|
|
31
|
+
if (!data) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(data)
|
|
37
|
+
return parsed?.error || data
|
|
38
|
+
} catch {
|
|
39
|
+
return data
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value)
|
|
44
|
+
|
|
45
|
+
const toBTCStringFromSatsString = (satsString) => {
|
|
46
|
+
let value = String(satsString)
|
|
47
|
+
while (value.length <= 8) value = `0${value}`
|
|
48
|
+
return `${value.slice(0, -8)}.${value.slice(-8)}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toBTCString = (sats) => {
|
|
52
|
+
if (isFiniteNumber(sats)) {
|
|
53
|
+
return toBTCStringFromSatsString(Math.trunc(sats))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof sats === 'string' || typeof sats === 'bigint') {
|
|
57
|
+
return toBTCStringFromSatsString(sats)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(MEMPOOL_INVALID_VALUE_ERROR_MESSAGE)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const toBTC = (sats) => {
|
|
64
|
+
return Number.parseFloat(toBTCString(sats))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const normalizeTxConfirmations = (status, tipHeight) => {
|
|
68
|
+
const isConfirmed = !!status?.confirmed
|
|
69
|
+
const blockHeight = status?.block_height
|
|
70
|
+
if (!isConfirmed || !Number.isInteger(blockHeight) || !Number.isInteger(tipHeight)) {
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Math.max(0, tipHeight - blockHeight + 1)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const getNextBlockMinimumFee = (blocks) => {
|
|
78
|
+
if (!Array.isArray(blocks) || blocks.length === 0) {
|
|
79
|
+
throw new Error(MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const firstBlock = blocks[0]
|
|
83
|
+
const feeRange = firstBlock && Array.isArray(firstBlock.feeRange) ? firstBlock.feeRange : null
|
|
84
|
+
if (!feeRange || feeRange.length === 0) {
|
|
85
|
+
throw new Error(MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Math.ceil(feeRange[0])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const getMinimumFee = ({ minimumFee, hourFee, configMinFeeRate, nextBlockMinimumFee }) => {
|
|
92
|
+
return Math.min(Math.max(minimumFee, configMinFeeRate), Math.min(hourFee, nextBlockMinimumFee))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const createHttpError = (response, message) => {
|
|
96
|
+
const error = new Error(message)
|
|
97
|
+
const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
|
|
98
|
+
if (traceId) {
|
|
99
|
+
error.traceId = traceId
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
error.code = `${response.status}`
|
|
103
|
+
return error
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function withRetry(fn, args = [], { waitTimes = RETRY_WAIT_TIMES } = {}) {
|
|
107
|
+
const fetchWithRetry = retry(fn, { delayTimesMs: waitTimes })
|
|
108
|
+
return fetchWithRetry(...args)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchJson(url, fetchOptions, { nullWhen404 = false, httpErrorMessage } = {}) {
|
|
112
|
+
const response = await fetch(url, fetchOptions)
|
|
113
|
+
if (nullWhen404 && response.status === 404) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw createHttpError(response, httpErrorMessage || MEMPOOL_HTTP_ERROR_MESSAGE)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
return await response.json()
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const error = new Error(MEMPOOL_JSON_ERROR_MESSAGE, { cause: err })
|
|
125
|
+
const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
|
|
126
|
+
if (traceId) {
|
|
127
|
+
error.traceId = traceId
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchText(url, fetchOptions, { nullWhen404 = false, httpErrorMessage } = {}) {
|
|
135
|
+
const response = await fetch(url, fetchOptions)
|
|
136
|
+
if (nullWhen404 && response.status === 404) {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw createHttpError(response, httpErrorMessage || MEMPOOL_HTTP_ERROR_MESSAGE)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
return await response.text()
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const error = new Error(MEMPOOL_TEXT_ERROR_MESSAGE, { cause: err })
|
|
148
|
+
const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
|
|
149
|
+
if (traceId) {
|
|
150
|
+
error.traceId = traceId
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const normalizeVin = (vin) => {
|
|
158
|
+
if (!vin || vin.is_coinbase) return {}
|
|
159
|
+
return {
|
|
160
|
+
txid: vin.txid,
|
|
161
|
+
vout: vin.vout,
|
|
162
|
+
addr: vin.prevout?.scriptpubkey_address,
|
|
163
|
+
value: toBTCString(vin.prevout?.value),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normalizeVout = (vout, index, outspends) => {
|
|
168
|
+
const outspend = Array.isArray(outspends) ? outspends[index] : undefined
|
|
169
|
+
const spentTxId =
|
|
170
|
+
outspend?.spent && typeof outspend?.txid === 'string' ? outspend.txid : undefined
|
|
171
|
+
const spentIndex = outspend?.spent && typeof outspend?.vin === 'number' ? outspend.vin : undefined
|
|
172
|
+
return {
|
|
173
|
+
n: Number.isInteger(vout?.n) ? vout.n : Number.isInteger(vout?.vout) ? vout.vout : index,
|
|
174
|
+
value: toBTCString(vout?.value),
|
|
175
|
+
spentTxId,
|
|
176
|
+
spentIndex,
|
|
177
|
+
scriptPubKey: {
|
|
178
|
+
hex: vout?.scriptpubkey,
|
|
179
|
+
addresses: vout?.scriptpubkey_address ? [vout.scriptpubkey_address] : [],
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const normalizeTx = (tx, tipHeight, outspends) => {
|
|
185
|
+
const normalizedVin = Array.isArray(tx?.vin) ? tx.vin.map((vin) => normalizeVin(vin)) : []
|
|
186
|
+
const normalizedVout = Array.isArray(tx?.vout)
|
|
187
|
+
? tx.vout.map((vout, index) => normalizeVout(vout, index, outspends))
|
|
188
|
+
: []
|
|
189
|
+
const vsize = Number.isInteger(tx?.weight) ? Math.ceil(tx.weight / 4) : undefined
|
|
190
|
+
const rbf =
|
|
191
|
+
Array.isArray(tx?.vin) &&
|
|
192
|
+
tx.vin.some((vin) => Number.isInteger(vin?.sequence) && vin.sequence < 0xff_ff_ff_fe)
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
...tx,
|
|
196
|
+
txid: tx?.txid,
|
|
197
|
+
vin: normalizedVin,
|
|
198
|
+
vout: normalizedVout,
|
|
199
|
+
time: tx?.status?.block_time,
|
|
200
|
+
blockheight: tx?.status?.block_height ?? -1,
|
|
201
|
+
fees: toBTC(tx?.fee),
|
|
202
|
+
vsize,
|
|
203
|
+
rbf,
|
|
204
|
+
confirmations: normalizeTxConfirmations(tx?.status, tipHeight),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default class MempoolRestClient {
|
|
209
|
+
constructor({
|
|
210
|
+
baseURL,
|
|
211
|
+
insightBaseURL,
|
|
212
|
+
retryWaitTimes = RETRY_WAIT_TIMES,
|
|
213
|
+
configMinFeeRate = 0,
|
|
214
|
+
} = {}) {
|
|
215
|
+
this._baseURL = baseURL
|
|
216
|
+
this._insightBaseURL = insightBaseURL
|
|
217
|
+
this._retryWaitTimes = retryWaitTimes
|
|
218
|
+
this._configMinFeeRate = configMinFeeRate
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setBaseUrl(baseURL) {
|
|
222
|
+
this._baseURL = baseURL
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
setInsightBaseUrl(baseURL) {
|
|
226
|
+
this._insightBaseURL = baseURL
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_apiUrl(path, { version } = {}) {
|
|
230
|
+
return urlJoin(this._baseURL, version === 'v1' ? '/api/v1' : '/api', path)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async _fetchTipHeight() {
|
|
234
|
+
const tipText = await fetchText(
|
|
235
|
+
this._apiUrl('/blocks/tip/height'),
|
|
236
|
+
{ timeout: 10_000 },
|
|
237
|
+
{ httpErrorMessage: MEMPOOL_HTTP_ERROR_STATUS_MESSAGE }
|
|
238
|
+
)
|
|
239
|
+
const tipHeight = Number.parseInt(tipText, 10)
|
|
240
|
+
if (!Number.isInteger(tipHeight) || tipHeight < 0) {
|
|
241
|
+
throw new Error(MEMPOOL_TEXT_ERROR_MESSAGE)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return tipHeight
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async _fetchTxOutspends(txid) {
|
|
248
|
+
const encodedTxid = encodeURIComponent(txid)
|
|
249
|
+
const outspends = await fetchJson(this._apiUrl(`/tx/${encodedTxid}/outspends`), undefined, {
|
|
250
|
+
nullWhen404: true,
|
|
251
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE,
|
|
252
|
+
})
|
|
253
|
+
return Array.isArray(outspends) ? outspends : []
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async _fetchTxOutspendsBatch(txids) {
|
|
257
|
+
const query = new URLSearchParams({ txids: txids.join(',') }).toString()
|
|
258
|
+
const result = await withRetry(
|
|
259
|
+
fetchJson,
|
|
260
|
+
[
|
|
261
|
+
this._apiUrl(`/txs/outspends?${query}`),
|
|
262
|
+
undefined,
|
|
263
|
+
{ nullWhen404: true, httpErrorMessage: MEMPOOL_HTTP_ERROR_OUTSPENDS_MESSAGE },
|
|
264
|
+
],
|
|
265
|
+
{ waitTimes: this._retryWaitTimes }
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return Array.isArray(result) ? result : []
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async _fetchOutspendsByTxids(txids) {
|
|
272
|
+
const txidList = Array.isArray(txids) ? txids : []
|
|
273
|
+
const outspendsByTxid = new Map()
|
|
274
|
+
if (txidList.length === 0) return outspendsByTxid
|
|
275
|
+
|
|
276
|
+
const BATCH_SIZE = 50
|
|
277
|
+
for (let i = 0; i < txidList.length; i += BATCH_SIZE) {
|
|
278
|
+
const batchTxids = txidList.slice(i, i + BATCH_SIZE)
|
|
279
|
+
const batched = await this._fetchTxOutspendsBatch(batchTxids)
|
|
280
|
+
|
|
281
|
+
for (const [j, batchTxid] of batchTxids.entries()) {
|
|
282
|
+
outspendsByTxid.set(batchTxid, Array.isArray(batched[j]) ? batched[j] : [])
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return outspendsByTxid
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async _fetchPrevouts(outpoints) {
|
|
290
|
+
return fetchJson(
|
|
291
|
+
this._apiUrl('/prevouts', { version: 'v1' }),
|
|
292
|
+
{
|
|
293
|
+
method: 'post',
|
|
294
|
+
headers: {
|
|
295
|
+
Accept: 'application/json',
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify(outpoints),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_PREVOUTS_MESSAGE,
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async _fetchMultiAddressTxPage(addresses, { afterTxId, maxTxs }) {
|
|
307
|
+
const query = new URLSearchParams()
|
|
308
|
+
if (afterTxId) {
|
|
309
|
+
query.set('after_txid', afterTxId)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (Number.isInteger(maxTxs) && maxTxs > 0) {
|
|
313
|
+
query.set('max_txs', `${maxTxs}`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const queryString = query.toString()
|
|
317
|
+
const path = '/addresses/txs' + (queryString ? `?${queryString}` : '')
|
|
318
|
+
const page = await withRetry(
|
|
319
|
+
fetchJson,
|
|
320
|
+
[
|
|
321
|
+
this._apiUrl(path),
|
|
322
|
+
{
|
|
323
|
+
method: 'post',
|
|
324
|
+
headers: {
|
|
325
|
+
Accept: 'application/json',
|
|
326
|
+
'Content-Type': 'application/json',
|
|
327
|
+
},
|
|
328
|
+
body: JSON.stringify(addresses),
|
|
329
|
+
},
|
|
330
|
+
{ httpErrorMessage: MEMPOOL_HTTP_ERROR_ADDR_TXS_MESSAGE },
|
|
331
|
+
],
|
|
332
|
+
{ waitTimes: this._retryWaitTimes }
|
|
333
|
+
)
|
|
334
|
+
const items = Array.isArray(page) ? page : []
|
|
335
|
+
const nextAfterTxId = items[items.length - 1]?.txid
|
|
336
|
+
const effectivePageSize = Number.isInteger(maxTxs) && maxTxs > 0 ? maxTxs : API_PAGE_SIZE
|
|
337
|
+
const hasMore = !!nextAfterTxId && items.length >= effectivePageSize
|
|
338
|
+
|
|
339
|
+
return { items, nextAfterTxId, hasMore }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async fetchBalance(address) {
|
|
343
|
+
const encodedAddress = encodeURIComponent(address)
|
|
344
|
+
const utxos = await fetchJson(this._apiUrl(`/address/${encodedAddress}/utxo`), undefined, {
|
|
345
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_BALANCE_MESSAGE,
|
|
346
|
+
})
|
|
347
|
+
const values = Array.isArray(utxos) ? utxos : []
|
|
348
|
+
const totalSats = values.reduce((sum, utxo) => sum + Number(utxo?.value || 0), 0)
|
|
349
|
+
return {
|
|
350
|
+
utxoCount: values.length,
|
|
351
|
+
balance: toBTC(totalSats),
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async fetchBlockHeight() {
|
|
356
|
+
return this._fetchTipHeight()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async fetchTx(txid) {
|
|
360
|
+
const tipHeightPromise = this._fetchTipHeight()
|
|
361
|
+
const encodedTxid = encodeURIComponent(txid)
|
|
362
|
+
const tx = await fetchJson(this._apiUrl(`/tx/${encodedTxid}`), undefined, {
|
|
363
|
+
nullWhen404: true,
|
|
364
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_TX_MESSAGE,
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
if (!tx) return null
|
|
368
|
+
|
|
369
|
+
const outspends =
|
|
370
|
+
Array.isArray(tx?.vout) && tx.vout.length > 0 ? await this._fetchTxOutspends(txid) : undefined
|
|
371
|
+
const tipHeight = await tipHeightPromise
|
|
372
|
+
return normalizeTx(tx, tipHeight, outspends)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async fetchTxObject(txid) {
|
|
376
|
+
return this.fetchTx(txid)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async fetchRawTx(txid) {
|
|
380
|
+
const encodedTxid = encodeURIComponent(txid)
|
|
381
|
+
const rawTx = await fetchText(this._apiUrl(`/tx/${encodedTxid}/hex`), undefined, {
|
|
382
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_RAWTX_MESSAGE,
|
|
383
|
+
})
|
|
384
|
+
return String(rawTx).trim()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async fetchTxData(addresses, requestOpts = {}) {
|
|
388
|
+
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
389
|
+
return { items: [] }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const from = Number.isInteger(requestOpts.from) ? Math.max(0, requestOpts.from) : 0
|
|
393
|
+
const to = Number.isInteger(requestOpts.to)
|
|
394
|
+
? Math.max(from, requestOpts.to)
|
|
395
|
+
: Math.max(from + DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE)
|
|
396
|
+
|
|
397
|
+
const maxTxs = Math.max(API_PAGE_SIZE, to - from)
|
|
398
|
+
const shouldIncludeSpent = requestOpts?.noSpent !== 1
|
|
399
|
+
let afterTxId
|
|
400
|
+
const dedupedTxs = new Map()
|
|
401
|
+
const tipHeightPromise = this._fetchTipHeight()
|
|
402
|
+
|
|
403
|
+
while (dedupedTxs.size < to) {
|
|
404
|
+
const previousCursor = afterTxId
|
|
405
|
+
const page = await this._fetchMultiAddressTxPage(addresses, { afterTxId, maxTxs })
|
|
406
|
+
afterTxId = page.nextAfterTxId
|
|
407
|
+
const txsNeedingOutspends = []
|
|
408
|
+
|
|
409
|
+
for (const tx of page.items) {
|
|
410
|
+
txsNeedingOutspends.push(tx)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const outspendsByTxid =
|
|
414
|
+
shouldIncludeSpent && txsNeedingOutspends.length > 0
|
|
415
|
+
? await this._fetchOutspendsByTxids(
|
|
416
|
+
txsNeedingOutspends
|
|
417
|
+
.filter((tx) => Array.isArray(tx?.vout) && tx.vout.length > 0)
|
|
418
|
+
.map((tx) => tx.txid)
|
|
419
|
+
)
|
|
420
|
+
: new Map()
|
|
421
|
+
|
|
422
|
+
for (const tx of txsNeedingOutspends) {
|
|
423
|
+
const outspends = shouldIncludeSpent ? outspendsByTxid.get(tx.txid) : undefined
|
|
424
|
+
const tipHeight = await tipHeightPromise
|
|
425
|
+
|
|
426
|
+
dedupedTxs.delete(tx.txid)
|
|
427
|
+
dedupedTxs.set(tx.txid, normalizeTx(tx, tipHeight, outspends))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (afterTxId === previousCursor || !page.hasMore) {
|
|
431
|
+
break
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
items: [...dedupedTxs.values()].slice(from, to),
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async fetchAllTxData(
|
|
441
|
+
addresses = [],
|
|
442
|
+
chunk = 25,
|
|
443
|
+
httpDelay = 2000,
|
|
444
|
+
shouldStopFetching = () => {}
|
|
445
|
+
) {
|
|
446
|
+
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
447
|
+
return []
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const dedupedTxs = new Map()
|
|
451
|
+
const tipHeightPromise = this._fetchTipHeight()
|
|
452
|
+
const maxTxs = Math.max(1, chunk)
|
|
453
|
+
let afterTxId
|
|
454
|
+
|
|
455
|
+
while (true) {
|
|
456
|
+
const txs = []
|
|
457
|
+
const previousCursor = afterTxId
|
|
458
|
+
const page = await this._fetchMultiAddressTxPage(addresses, { afterTxId, maxTxs })
|
|
459
|
+
afterTxId = page.nextAfterTxId
|
|
460
|
+
const pageTxs = []
|
|
461
|
+
|
|
462
|
+
for (const tx of page.items) {
|
|
463
|
+
pageTxs.push(tx)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const txidsNeedingOutspends = pageTxs
|
|
467
|
+
.filter((tx) => Array.isArray(tx?.vout) && tx.vout.length > 0)
|
|
468
|
+
.map((tx) => tx.txid)
|
|
469
|
+
|
|
470
|
+
const outspendsByTxid =
|
|
471
|
+
txidsNeedingOutspends.length > 0
|
|
472
|
+
? await this._fetchOutspendsByTxids(txidsNeedingOutspends)
|
|
473
|
+
: new Map()
|
|
474
|
+
|
|
475
|
+
for (const tx of pageTxs) {
|
|
476
|
+
const outspends =
|
|
477
|
+
Array.isArray(tx?.vout) && tx.vout.length > 0 ? outspendsByTxid.get(tx.txid) : undefined
|
|
478
|
+
const tipHeight = await tipHeightPromise
|
|
479
|
+
|
|
480
|
+
const normalizedTx = normalizeTx(tx, tipHeight, outspends)
|
|
481
|
+
txs.push(normalizedTx)
|
|
482
|
+
dedupedTxs.delete(tx.txid)
|
|
483
|
+
dedupedTxs.set(tx.txid, normalizedTx)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (afterTxId === previousCursor || !page.hasMore) {
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (txs.length > 0 && shouldStopFetching && (await shouldStopFetching(txs))) break
|
|
491
|
+
|
|
492
|
+
await delay(httpDelay)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return [...dedupedTxs.values()]
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async fetchUTXOs(address) {
|
|
499
|
+
const encodedAddress = encodeURIComponent(address)
|
|
500
|
+
const utxos = await fetchJson(this._apiUrl(`/address/${encodedAddress}/utxo`), undefined, {
|
|
501
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_UTXO_MESSAGE,
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
if (!Array.isArray(utxos) || utxos.length === 0) return []
|
|
505
|
+
const tipHeightPromise = this._fetchTipHeight()
|
|
506
|
+
const outpoints = utxos.map((utxo) => ({ txid: utxo.txid, vout: utxo.vout }))
|
|
507
|
+
|
|
508
|
+
const scriptsByOutpoint = new Map()
|
|
509
|
+
for (let i = 0; i < outpoints.length; i += 100) {
|
|
510
|
+
const batch = outpoints.slice(i, i + 100)
|
|
511
|
+
const prevouts = await this._fetchPrevouts(batch)
|
|
512
|
+
if (!Array.isArray(prevouts)) continue
|
|
513
|
+
|
|
514
|
+
for (const [j, outpoint] of batch.entries()) {
|
|
515
|
+
const script = prevouts[j]?.prevout?.scriptpubkey
|
|
516
|
+
if (typeof script === 'string') {
|
|
517
|
+
scriptsByOutpoint.set(`${outpoint.txid}:${outpoint.vout}`, script)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const tipHeight = await tipHeightPromise
|
|
523
|
+
|
|
524
|
+
return utxos.map((utxo) => ({
|
|
525
|
+
address,
|
|
526
|
+
txId: utxo.txid,
|
|
527
|
+
confirmations: normalizeTxConfirmations(utxo.status, tipHeight),
|
|
528
|
+
value: toBTC(utxo.value),
|
|
529
|
+
vout: utxo.vout,
|
|
530
|
+
height: utxo.status?.block_height ?? null,
|
|
531
|
+
script: scriptsByOutpoint.get(`${utxo.txid}:${utxo.vout}`),
|
|
532
|
+
}))
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async fetchFeeRate() {
|
|
536
|
+
const [mempoolFeeRate, blocks] = await Promise.all([
|
|
537
|
+
fetchJson(this._apiUrl('/fees/recommended', { version: 'v1' }), undefined, {
|
|
538
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
|
|
539
|
+
}),
|
|
540
|
+
fetchJson(this._apiUrl('/fees/mempool-blocks', { version: 'v1' }), undefined, {
|
|
541
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_FEES_MESSAGE,
|
|
542
|
+
}),
|
|
543
|
+
])
|
|
544
|
+
const fastestFee = mempoolFeeRate?.fastestFee
|
|
545
|
+
const halfHourFee = mempoolFeeRate?.halfHourFee
|
|
546
|
+
const hourFee = mempoolFeeRate?.hourFee
|
|
547
|
+
const economyFee = mempoolFeeRate?.economyFee
|
|
548
|
+
const baseMinimumFee = mempoolFeeRate?.minimumFee
|
|
549
|
+
const nextBlockMinimumFee = getNextBlockMinimumFee(blocks)
|
|
550
|
+
|
|
551
|
+
const minimumFee = getMinimumFee({
|
|
552
|
+
minimumFee: baseMinimumFee,
|
|
553
|
+
hourFee,
|
|
554
|
+
configMinFeeRate: this._configMinFeeRate,
|
|
555
|
+
nextBlockMinimumFee,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
if (
|
|
559
|
+
!isFiniteNumber(fastestFee) ||
|
|
560
|
+
!isFiniteNumber(halfHourFee) ||
|
|
561
|
+
!isFiniteNumber(hourFee) ||
|
|
562
|
+
!isFiniteNumber(economyFee) ||
|
|
563
|
+
!isFiniteNumber(baseMinimumFee) ||
|
|
564
|
+
!isFiniteNumber(nextBlockMinimumFee) ||
|
|
565
|
+
!isFiniteNumber(minimumFee)
|
|
566
|
+
) {
|
|
567
|
+
throw new Error(MEMPOOL_FEES_PAYLOAD_ERROR_MESSAGE)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
fastestFee,
|
|
572
|
+
halfHourFee,
|
|
573
|
+
hourFee,
|
|
574
|
+
minimumFee,
|
|
575
|
+
economyFee,
|
|
576
|
+
nextBlockMinimumFee,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async fetchUnconfirmedAncestorData(txid) {
|
|
581
|
+
const encodedTxid = encodeURIComponent(txid)
|
|
582
|
+
const data = await fetchJson(
|
|
583
|
+
this._apiUrl(`/cpfp/${encodedTxid}`, { version: 'v1' }),
|
|
584
|
+
undefined,
|
|
585
|
+
{
|
|
586
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_CPFP_MESSAGE,
|
|
587
|
+
}
|
|
588
|
+
)
|
|
589
|
+
const ancestors = Array.isArray(data?.ancestors) ? data.ancestors : []
|
|
590
|
+
const ancestorFees = ancestors.reduce((sum, ancestor) => {
|
|
591
|
+
if (!isFiniteNumber(ancestor?.fee)) return sum
|
|
592
|
+
return sum + ancestor.fee
|
|
593
|
+
}, 0)
|
|
594
|
+
const ancestorSize = ancestors.reduce((sum, ancestor) => {
|
|
595
|
+
if (!isFiniteNumber(ancestor?.weight)) return sum
|
|
596
|
+
return sum + Math.ceil(ancestor.weight / 4)
|
|
597
|
+
}, 0)
|
|
598
|
+
const selfFees = isFiniteNumber(data?.fee) ? data.fee : 0
|
|
599
|
+
const tx = await fetchJson(this._apiUrl(`/tx/${encodedTxid}`), undefined, {
|
|
600
|
+
nullWhen404: true,
|
|
601
|
+
httpErrorMessage: MEMPOOL_HTTP_ERROR_TX_MESSAGE,
|
|
602
|
+
})
|
|
603
|
+
const selfSize = isFiniteNumber(tx?.weight) ? Math.ceil(tx.weight / 4) : 0
|
|
604
|
+
|
|
605
|
+
const fees = ancestorFees + selfFees
|
|
606
|
+
const size = ancestorSize + selfSize
|
|
607
|
+
if (fees <= 0 || size <= 0) return { size: 0, fees: 0 }
|
|
608
|
+
return { size, fees: Math.round(fees) }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Keep broadcast behavior aligned with the existing insight client.
|
|
612
|
+
// Our Insight/Magnifier path fans out to Exodus-managed bitcoin nodes and has the
|
|
613
|
+
// operational behavior we already depend on when propagation is slow or a rejection
|
|
614
|
+
// is temporary. The mempool REST API alone is not a drop-in replacement for that.
|
|
615
|
+
async broadcastTx(rawTx) {
|
|
616
|
+
const payload = rawTx instanceof Uint8Array ? Buffer.from(rawTx).toString('hex') : rawTx
|
|
617
|
+
const response = await fetch(urlJoin(this._insightBaseURL, '/tx/send'), {
|
|
618
|
+
method: 'post',
|
|
619
|
+
headers: {
|
|
620
|
+
Accept: 'application/json',
|
|
621
|
+
'Content-Type': 'application/json',
|
|
622
|
+
},
|
|
623
|
+
body: JSON.stringify({ rawtx: payload }),
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
let data = await response.text()
|
|
627
|
+
|
|
628
|
+
if (!response.ok) {
|
|
629
|
+
console.warn(`Mempool Client's Insight Broadcast HTTP Error:`)
|
|
630
|
+
console.warn(response.statusText)
|
|
631
|
+
console.warn(data)
|
|
632
|
+
const error = createHttpError(response, MEMPOOL_INSIGHT_HTTP_ERROR_BROADCAST_MESSAGE)
|
|
633
|
+
error.reason = parseBroadcastErrorReason(data)
|
|
634
|
+
throw error
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
data = JSON.parse(data)
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.warn(`Mempool Client's Insight Broadcast JSON Parse Error:`, err.message, data)
|
|
641
|
+
const error = new Error(MEMPOOL_INSIGHT_JSON_ERROR_MESSAGE, { cause: err })
|
|
642
|
+
const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
|
|
643
|
+
if (traceId) {
|
|
644
|
+
error.traceId = traceId
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
throw error
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!data.txid) {
|
|
651
|
+
const error = new Error(MEMPOOL_INSIGHT_MISSING_TXID_MESSAGE)
|
|
652
|
+
const traceId = TraceId.fromResponse && TraceId.fromResponse(response)
|
|
653
|
+
if (traceId) {
|
|
654
|
+
error.traceId = traceId
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
throw error
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import EventEmitter from 'eventemitter3'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_RECONNECTION_DELAY = 10_000
|
|
4
|
+
const DEFAULT_RECONNECTION_DELAY_MAX = 30_000
|
|
5
|
+
const DEFAULT_PING_INTERVAL = 25_000
|
|
6
|
+
const BLOCK_SUBSCRIBED_ASSETS = new Set(['bitcoin', 'bitcoinregtest', 'bitcointestnet'])
|
|
7
|
+
|
|
8
|
+
const toMessageString = (data) => {
|
|
9
|
+
if (typeof data === 'string') return data
|
|
10
|
+
// Keep tiny compatibility for ws-style Buffer payloads.
|
|
11
|
+
if (data && typeof data.toString === 'function') return data.toString('utf8')
|
|
12
|
+
|
|
13
|
+
return ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const flattenAddressEntries = (data) => {
|
|
17
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return []
|
|
18
|
+
return Object.entries(data).filter(([address, payload]) => typeof address === 'string' && payload)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tryParseJSON = (rawData) => {
|
|
22
|
+
const message = toMessageString(rawData)
|
|
23
|
+
if (!message) return null
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(message)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.warn('mempool-ws-client parse failed', {
|
|
29
|
+
error: error instanceof Error ? error.message : error,
|
|
30
|
+
preview: message.slice(0, 200),
|
|
31
|
+
})
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const getReconnectDelay = (attempt, reconnectionDelay, reconnectionDelayMax) => {
|
|
37
|
+
const baseDelay = reconnectionDelay * 2 ** Math.max(0, attempt)
|
|
38
|
+
const cappedDelay = Math.min(baseDelay, reconnectionDelayMax)
|
|
39
|
+
const jitter = 0.8 + Math.random() * 0.4
|
|
40
|
+
return Math.round(cappedDelay * jitter)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const normalizeOptions = (assetName, opts = {}) => {
|
|
44
|
+
return {
|
|
45
|
+
reconnectionDelay: DEFAULT_RECONNECTION_DELAY,
|
|
46
|
+
reconnectionDelayMax: DEFAULT_RECONNECTION_DELAY_MAX,
|
|
47
|
+
pingIntervalMs: DEFAULT_PING_INTERVAL,
|
|
48
|
+
subscribeBlocks: BLOCK_SUBSCRIBED_ASSETS.has(assetName),
|
|
49
|
+
...opts,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default class MempoolWSClient extends EventEmitter {
|
|
54
|
+
constructor(url, assetName, { WebSocketClass = globalThis.WebSocket } = {}) {
|
|
55
|
+
super()
|
|
56
|
+
this.url = url
|
|
57
|
+
this.assetName = assetName
|
|
58
|
+
this.WebSocketClass = WebSocketClass
|
|
59
|
+
this.socket = null
|
|
60
|
+
this.addresses = []
|
|
61
|
+
this.options = null
|
|
62
|
+
this.reconnectAttempt = 0
|
|
63
|
+
this.reconnectTimer = null
|
|
64
|
+
this.pingInterval = null
|
|
65
|
+
this.closed = false
|
|
66
|
+
this.socketListeners = null
|
|
67
|
+
this.connected = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
connect(addresses = [], opts = {}) {
|
|
71
|
+
// Guard against accidental duplicate connect() calls creating parallel sockets.
|
|
72
|
+
if (this.socket || this.reconnectTimer) {
|
|
73
|
+
this.close()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.addresses = [...new Set(addresses.filter((address) => typeof address === 'string'))]
|
|
77
|
+
this.options = normalizeOptions(this.assetName, opts)
|
|
78
|
+
this.closed = false
|
|
79
|
+
this.reconnectAttempt = 0
|
|
80
|
+
this.#clearReconnectTimer()
|
|
81
|
+
this.#openSocket({ isReconnect: false })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#openSocket({ isReconnect }) {
|
|
85
|
+
if (this.closed) return
|
|
86
|
+
|
|
87
|
+
const socket = new this.WebSocketClass(this.url)
|
|
88
|
+
this.socket = socket
|
|
89
|
+
|
|
90
|
+
const onOpen = () => {
|
|
91
|
+
if (this.socket !== socket || this.closed) return
|
|
92
|
+
|
|
93
|
+
this.connected = true
|
|
94
|
+
this.reconnectAttempt = 0
|
|
95
|
+
this.emit('connect')
|
|
96
|
+
if (isReconnect) this.emit('reconnect')
|
|
97
|
+
this.#subscribe(socket)
|
|
98
|
+
this.#startPing()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onMessage = (event) => {
|
|
102
|
+
if (this.socket !== socket || this.closed) return
|
|
103
|
+
this.#handleMessage(event?.data)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const onError = (event) => {
|
|
107
|
+
// Some runtimes can stall without close; explicitly close to trigger reconnect flow.
|
|
108
|
+
if (this.socket !== socket || this.closed) return
|
|
109
|
+
console.warn('mempool-ws-client socket error', event)
|
|
110
|
+
try {
|
|
111
|
+
socket.close()
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const onClose = () => {
|
|
116
|
+
if (this.socket !== socket) return
|
|
117
|
+
const wasConnected = this.connected
|
|
118
|
+
this.connected = false
|
|
119
|
+
this.#clearPing()
|
|
120
|
+
if (wasConnected) this.emit('disconnect')
|
|
121
|
+
this.socket = null
|
|
122
|
+
this.socketListeners = null
|
|
123
|
+
this.#scheduleReconnect()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
socket.addEventListener('open', onOpen)
|
|
127
|
+
socket.addEventListener('message', onMessage)
|
|
128
|
+
socket.addEventListener('error', onError)
|
|
129
|
+
socket.addEventListener('close', onClose)
|
|
130
|
+
this.socketListeners = { onOpen, onMessage, onError, onClose }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#scheduleReconnect() {
|
|
134
|
+
if (this.closed || this.reconnectTimer) return
|
|
135
|
+
|
|
136
|
+
const reconnectDelay = getReconnectDelay(
|
|
137
|
+
this.reconnectAttempt,
|
|
138
|
+
this.options?.reconnectionDelay ?? DEFAULT_RECONNECTION_DELAY,
|
|
139
|
+
this.options?.reconnectionDelayMax ?? DEFAULT_RECONNECTION_DELAY_MAX
|
|
140
|
+
)
|
|
141
|
+
this.reconnectAttempt += 1
|
|
142
|
+
this.reconnectTimer = setTimeout(() => {
|
|
143
|
+
this.reconnectTimer = null
|
|
144
|
+
this.#openSocket({ isReconnect: true })
|
|
145
|
+
}, reconnectDelay)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#clearReconnectTimer() {
|
|
149
|
+
if (!this.reconnectTimer) return
|
|
150
|
+
clearTimeout(this.reconnectTimer)
|
|
151
|
+
this.reconnectTimer = null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#startPing() {
|
|
155
|
+
this.#clearPing()
|
|
156
|
+
const intervalMs = this.options?.pingIntervalMs
|
|
157
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return
|
|
158
|
+
|
|
159
|
+
this.pingInterval = setInterval(() => {
|
|
160
|
+
if (!this.socket || this.socket.readyState !== (this.WebSocketClass?.OPEN ?? 1)) return
|
|
161
|
+
this.#send(this.socket, { action: 'ping' })
|
|
162
|
+
}, intervalMs)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#clearPing() {
|
|
166
|
+
if (!this.pingInterval) return
|
|
167
|
+
clearInterval(this.pingInterval)
|
|
168
|
+
this.pingInterval = null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#send(socket, payload) {
|
|
172
|
+
if (!socket || socket.readyState !== (this.WebSocketClass?.OPEN ?? 1)) return
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
socket.send(JSON.stringify(payload))
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.warn('mempool-ws-client send failed', {
|
|
178
|
+
payload,
|
|
179
|
+
error: error instanceof Error ? error.message : error,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#subscribe(socket) {
|
|
185
|
+
if (this.addresses.length > 0) {
|
|
186
|
+
this.#send(socket, { 'track-addresses': this.addresses })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (this.options?.subscribeBlocks) {
|
|
190
|
+
this.#send(socket, { action: 'want', data: ['blocks'] })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#emitMessage(data, address) {
|
|
195
|
+
this.emit('message', {
|
|
196
|
+
address,
|
|
197
|
+
data,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#handleMessage(rawData) {
|
|
202
|
+
const payload = tryParseJSON(rawData)
|
|
203
|
+
if (!payload || typeof payload !== 'object') return
|
|
204
|
+
|
|
205
|
+
if (payload.block) {
|
|
206
|
+
this.emit('block', payload.block)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (payload['multi-address-transactions']) {
|
|
210
|
+
const entries = flattenAddressEntries(payload['multi-address-transactions'])
|
|
211
|
+
entries.forEach(([address, addressPayload]) => {
|
|
212
|
+
this.#emitMessage(addressPayload, address)
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
close() {
|
|
218
|
+
this.closed = true
|
|
219
|
+
const wasConnected = this.connected
|
|
220
|
+
this.connected = false
|
|
221
|
+
this.#clearReconnectTimer()
|
|
222
|
+
this.#clearPing()
|
|
223
|
+
|
|
224
|
+
if (!this.socket) return
|
|
225
|
+
const socket = this.socket
|
|
226
|
+
this.socket = null
|
|
227
|
+
const listeners = this.socketListeners
|
|
228
|
+
this.socketListeners = null
|
|
229
|
+
if (listeners) {
|
|
230
|
+
socket.removeEventListener('open', listeners.onOpen)
|
|
231
|
+
socket.removeEventListener('message', listeners.onMessage)
|
|
232
|
+
socket.removeEventListener('close', listeners.onClose)
|
|
233
|
+
socket.removeEventListener('error', listeners.onError)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
socket.close()
|
|
237
|
+
if (wasConnected) this.emit('disconnect')
|
|
238
|
+
}
|
|
239
|
+
}
|