@exodus/bitcoin-api 4.11.1 → 4.12.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,16 @@
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.12.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.11.1...@exodus/bitcoin-api@4.12.0) (2026-03-20)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(bitcoin-api): add mempool ws client with insight parity (#7625)
13
+
14
+
15
+
6
16
  ## [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
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.11.1",
3
+ "version": "4.12.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",
@@ -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": "2933d00c85191b547e822a292f1884b8d6d9e6d7"
66
+ "gitHead": "f1f7c3ad9d55f774c9c6a09434139d46f605e43c"
66
67
  }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ 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 MempoolWSClient } from './insight-api-client/mempool-ws-client.js'
6
7
  export { default as InsightAPIClient } from './insight-api-client/index.js'
7
8
  export { default as InsightWSClient } from './insight-api-client/ws.js'
8
9
  export { default as bip44Constants } from './constants/bip44.js'
@@ -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
+ }