@exodus/bitcoin-api 4.15.0 → 4.15.2

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.15.2](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.15.1...@exodus/bitcoin-api@4.15.2) (2026-05-27)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: reconnect mempool websocket on missed pong (#8141)
13
+
14
+
15
+
16
+ ## [4.15.1](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.15.0...@exodus/bitcoin-api@4.15.1) (2026-05-19)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: block btc empty-output transactions (#8086)
23
+
24
+
25
+
6
26
  ## [4.15.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.14.7...@exodus/bitcoin-api@4.15.0) (2026-05-18)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.15.0",
3
+ "version": "4.15.2",
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",
@@ -63,5 +63,5 @@
63
63
  "type": "git",
64
64
  "url": "git+https://github.com/ExodusMovement/assets.git"
65
65
  },
66
- "gitHead": "266396ec25f5aa6ce87887d0df7f0e882e83f1aa"
66
+ "gitHead": "c5d3a519f291e17f6b95cb0804e6d86d71be1dd8"
67
67
  }
@@ -3,6 +3,7 @@ import EventEmitter from 'eventemitter3'
3
3
  const DEFAULT_RECONNECTION_DELAY = 10_000
4
4
  const DEFAULT_RECONNECTION_DELAY_MAX = 30_000
5
5
  const DEFAULT_PING_INTERVAL = 25_000
6
+ const DEFAULT_PONG_TIMEOUT = 10_000
6
7
  const BLOCK_SUBSCRIBED_ASSETS = new Set(['bitcoin', 'bitcoinregtest', 'bitcointestnet'])
7
8
 
8
9
  const toMessageString = (data) => {
@@ -45,6 +46,7 @@ const normalizeOptions = (assetName, opts = {}) => {
45
46
  reconnectionDelay: DEFAULT_RECONNECTION_DELAY,
46
47
  reconnectionDelayMax: DEFAULT_RECONNECTION_DELAY_MAX,
47
48
  pingIntervalMs: DEFAULT_PING_INTERVAL,
49
+ pongTimeoutMs: DEFAULT_PONG_TIMEOUT,
48
50
  subscribeBlocks: BLOCK_SUBSCRIBED_ASSETS.has(assetName),
49
51
  ...opts,
50
52
  }
@@ -62,6 +64,7 @@ export default class MempoolWSClient extends EventEmitter {
62
64
  this.reconnectAttempt = 0
63
65
  this.reconnectTimer = null
64
66
  this.pingInterval = null
67
+ this.pongTimeout = null
65
68
  this.closed = false
66
69
  this.socketListeners = null
67
70
  this.connected = false
@@ -104,12 +107,9 @@ export default class MempoolWSClient extends EventEmitter {
104
107
  }
105
108
 
106
109
  const onError = (event) => {
107
- // Some runtimes can stall without close; explicitly close to trigger reconnect flow.
108
110
  if (this.socket !== socket || this.closed) return
109
111
  console.warn('mempool-ws-client socket error', event)
110
- try {
111
- socket.close()
112
- } catch {}
112
+ this.#recycleSocket(socket)
113
113
  }
114
114
 
115
115
  const onClose = () => {
@@ -117,6 +117,7 @@ export default class MempoolWSClient extends EventEmitter {
117
117
  const wasConnected = this.connected
118
118
  this.connected = false
119
119
  this.#clearPing()
120
+ this.#clearPongTimeout()
120
121
  if (wasConnected) this.emit('disconnect')
121
122
  this.socket = null
122
123
  this.socketListeners = null
@@ -159,6 +160,7 @@ export default class MempoolWSClient extends EventEmitter {
159
160
  this.pingInterval = setInterval(() => {
160
161
  if (!this.socket || this.socket.readyState !== (this.WebSocketClass?.OPEN ?? 1)) return
161
162
  this.#send(this.socket, { action: 'ping' })
163
+ this.#startPongTimeout(this.socket)
162
164
  }, intervalMs)
163
165
  }
164
166
 
@@ -168,6 +170,55 @@ export default class MempoolWSClient extends EventEmitter {
168
170
  this.pingInterval = null
169
171
  }
170
172
 
173
+ #startPongTimeout(socket) {
174
+ this.#clearPongTimeout()
175
+ const timeoutMs = this.options?.pongTimeoutMs
176
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return
177
+
178
+ this.pongTimeout = setTimeout(() => {
179
+ if (this.socket !== socket || this.closed) return
180
+
181
+ console.warn('mempool-ws-client pong timeout')
182
+ this.#recycleSocket(socket)
183
+ }, timeoutMs)
184
+ }
185
+
186
+ #clearPongTimeout() {
187
+ if (!this.pongTimeout) return
188
+ clearTimeout(this.pongTimeout)
189
+ this.pongTimeout = null
190
+ }
191
+
192
+ #removeSocketListeners(socket) {
193
+ const listeners = this.socketListeners
194
+ this.socketListeners = null
195
+ if (!listeners) return
196
+
197
+ socket.removeEventListener('open', listeners.onOpen)
198
+ socket.removeEventListener('message', listeners.onMessage)
199
+ socket.removeEventListener('close', listeners.onClose)
200
+ socket.removeEventListener('error', listeners.onError)
201
+ }
202
+
203
+ #recycleSocket(socket) {
204
+ if (this.socket !== socket) return
205
+
206
+ const wasConnected = this.connected
207
+ this.connected = false
208
+ this.#clearPing()
209
+ this.#clearPongTimeout()
210
+ this.socket = null
211
+ this.#removeSocketListeners(socket)
212
+
213
+ if (wasConnected) this.emit('disconnect')
214
+ this.#scheduleReconnect()
215
+
216
+ try {
217
+ if (typeof socket.terminate === 'function') socket.terminate()
218
+ else socket.close()
219
+ } catch {}
220
+ }
221
+
171
222
  #send(socket, payload) {
172
223
  if (!socket || socket.readyState !== (this.WebSocketClass?.OPEN ?? 1)) return
173
224
 
@@ -202,6 +253,10 @@ export default class MempoolWSClient extends EventEmitter {
202
253
  const payload = tryParseJSON(rawData)
203
254
  if (!payload || typeof payload !== 'object') return
204
255
 
256
+ if (payload.pong === true) {
257
+ this.#clearPongTimeout()
258
+ }
259
+
205
260
  if (payload.block) {
206
261
  this.emit('block', payload.block)
207
262
  }
@@ -220,18 +275,12 @@ export default class MempoolWSClient extends EventEmitter {
220
275
  this.connected = false
221
276
  this.#clearReconnectTimer()
222
277
  this.#clearPing()
278
+ this.#clearPongTimeout()
223
279
 
224
280
  if (!this.socket) return
225
281
  const socket = this.socket
226
282
  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
- }
283
+ this.#removeSocketListeners(socket)
235
284
 
236
285
  socket.close()
237
286
  if (wasConnected) this.emit('disconnect')
@@ -261,6 +261,16 @@ function createChangeOutput({ selectedUtxos, totalAmount, fee, replaceTx, change
261
261
  return null
262
262
  }
263
263
 
264
+ function assertTransactionHasOutputs({ outputs, bumpTxId }) {
265
+ if (outputs.length > 0) return
266
+
267
+ throw new Error(
268
+ bumpTxId
269
+ ? `Cannot bump transaction ${bumpTxId}: no spendable output after fees`
270
+ : 'Cannot create transaction without outputs'
271
+ )
272
+ }
273
+
264
274
  async function createUnsignedTx({
265
275
  inputs,
266
276
  outputs,
@@ -480,6 +490,8 @@ const transferHandler = {
480
490
  changeAddress: context.changeAddress,
481
491
  })
482
492
 
493
+ assertTransactionHasOutputs({ outputs, bumpTxId })
494
+
483
495
  // Track our own outputs and their purposes. Today this is only the change
484
496
  // output; in the future we may add self-send primaries when applicable.
485
497
  const outputAddressPurposesMap = Object.create(null)