@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 +10 -0
- package/package.json +3 -2
- package/src/index.js +1 -0
- package/src/insight-api-client/mempool-ws-client.js +239 -0
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.
|
|
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": "
|
|
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
|
+
}
|