edge-currency-plugins 3.14.0 → 3.16.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.
@@ -86,6 +86,11 @@ var _events = require('events');
86
86
 
87
87
 
88
88
 
89
+
90
+
91
+
92
+
93
+
89
94
 
90
95
 
91
96
 
@@ -105,6 +110,11 @@ var EngineEvent; (function (EngineEvent) {
105
110
  const NEW_ADDRESS_TRANSACTION = 'address:transaction:changed'; EngineEvent["NEW_ADDRESS_TRANSACTION"] = NEW_ADDRESS_TRANSACTION;
106
111
  const ADDRESSES_CHECKED = 'addresses:checked'; EngineEvent["ADDRESSES_CHECKED"] = ADDRESSES_CHECKED;
107
112
  const TXIDS_CHANGED = 'txids:changed'; EngineEvent["TXIDS_CHANGED"] = TXIDS_CHANGED;
113
+ /**
114
+ * The engine disconnected a server on purpose because it was found to be
115
+ * out of sync. Data fetched through it may be behind the chain.
116
+ */
117
+ const SERVER_DROPPED = 'server:dropped'; EngineEvent["SERVER_DROPPED"] = SERVER_DROPPED;
108
118
  const CONNECTION_OPEN = 'connection:open'; EngineEvent["CONNECTION_OPEN"] = CONNECTION_OPEN;
109
119
  const CONNECTION_CLOSE = 'connection:close'; EngineEvent["CONNECTION_CLOSE"] = CONNECTION_CLOSE;
110
120
  const CONNECTION_TIMER = 'connection:timer'; EngineEvent["CONNECTION_TIMER"] = CONNECTION_TIMER;
@@ -10,9 +10,48 @@ var _memlet = require('memlet');
10
10
 
11
11
 
12
12
 
13
+
13
14
  var _ServerScores = require('./ServerScores');
14
15
 
15
16
 
17
+ /**
18
+ * How long a server dropped for being out of sync stays out of the pick
19
+ * order. Long enough for a lagging indexer to catch up, short enough that a
20
+ * recovered server is tried again without an app restart. After expiry the
21
+ * server is probed again on connect and dropped again if still behind.
22
+ */
23
+ const SERVER_QUARANTINE_MS = 5 * 60 * 1000; exports.SERVER_QUARANTINE_MS = SERVER_QUARANTINE_MS
24
+
25
+ /**
26
+ * Runs a server's health probe, or hands back the one started for the same
27
+ * URI less than `minIntervalMs` ago, whether it is still in flight or has
28
+ * already answered.
29
+ */
30
+
31
+
32
+
33
+
34
+
35
+
36
+ function makeShareServerProbe() {
37
+ // Server URI -> the latest probe and when it started (ms since epoch)
38
+ const latestProbes = new Map
39
+
40
+
41
+ ()
42
+
43
+ return async (uri, minIntervalMs, probe) => {
44
+ const now = Date.now()
45
+ const latest = latestProbes.get(uri)
46
+ if (latest != null && now - latest.startTime < minIntervalMs) {
47
+ return await latest.result
48
+ }
49
+ const result = probe()
50
+ latestProbes.set(uri, { startTime: now, result })
51
+ return await result
52
+ }
53
+ } exports.makeShareServerProbe = makeShareServerProbe;
54
+
16
55
  // The filename for ServerInfoCache data (see ServerScores.ts)
17
56
  // Perhaps this should be in ServerScores.ts file, but that'll take some refactoring
18
57
  const SERVER_CACHE_FILE = 'serverCache.json'
@@ -38,6 +77,21 @@ const SERVER_CACHE_FILE = 'serverCache.json'
38
77
 
39
78
 
40
79
 
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
41
95
 
42
96
 
43
97
 
@@ -97,6 +151,20 @@ const SERVER_CACHE_FILE = 'serverCache.json'
97
151
  let engines = []
98
152
  const memlet = _memlet.makeMemlet.call(void 0, pluginDisklet)
99
153
 
154
+ // Server URI -> time (ms since epoch) at which the quarantine expires
155
+ const quarantineExpiry = new Map()
156
+
157
+ const isServerQuarantined = (uri) => {
158
+ const expiry = quarantineExpiry.get(uri)
159
+ if (expiry == null) return false
160
+ if (expiry > Date.now()) return true
161
+ quarantineExpiry.delete(uri)
162
+ return false
163
+ }
164
+
165
+ const hasQuarantinedServers = () =>
166
+ Array.from(quarantineExpiry.keys()).some(isServerQuarantined)
167
+
100
168
  let serverCache = {
101
169
  customServers: {},
102
170
  enableCustomServers: defaultSettings.enableCustomServers,
@@ -182,6 +250,9 @@ const SERVER_CACHE_FILE = 'serverCache.json'
182
250
 
183
251
  return {
184
252
  'pluginState.servers_': sanitizeServerList(selectedServerList),
253
+ quarantinedServers: Array.from(quarantineExpiry.keys())
254
+ .filter(isServerQuarantined)
255
+ .map(sanitizeServerUri),
185
256
  infoServers,
186
257
  customServers: Object.keys(serverCache.customServers).map(
187
258
  sanitizeServerUri
@@ -213,6 +284,15 @@ const SERVER_CACHE_FILE = 'serverCache.json'
213
284
  serverScores.serverScoreUp(getSelectedServerList(), uri, score)
214
285
  },
215
286
 
287
+ quarantineServer(uri, durationMs = exports.SERVER_QUARANTINE_MS) {
288
+ log.warn(`${pluginId} - quarantining ${uri} for ${durationMs}ms`)
289
+ quarantineExpiry.set(uri, Date.now() + durationMs)
290
+ },
291
+
292
+ isServerQuarantined,
293
+ hasQuarantinedServers,
294
+ shareServerProbe: makeShareServerProbe(),
295
+
216
296
  async clearCache() {
217
297
  serverScores.clearServerScoreTimes()
218
298
  serverCacheDirty = true
@@ -223,8 +303,14 @@ const SERVER_CACHE_FILE = 'serverCache.json'
223
303
  numServersWanted,
224
304
  includePatterns = []
225
305
  ) {
306
+ // Withhold quarantined servers before ranking, so they cannot take
307
+ // slots that lower-ranked healthy servers would otherwise fill:
308
+ const eligibleServers = {}
309
+ for (const [uri, serverInfo] of Object.entries(getSelectedServerList())) {
310
+ if (!isServerQuarantined(uri)) eligibleServers[uri] = serverInfo
311
+ }
226
312
  return serverScores.getServers(
227
- getSelectedServerList(),
313
+ eligibleServers,
228
314
  numServersWanted,
229
315
  includePatterns
230
316
  )
@@ -1,4 +1,5 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var _cleaners = require('cleaners');
2
+
2
3
  var _urijs = require('uri-js');
3
4
 
4
5
  var _EngineEmitter = require('../../plugin/EngineEmitter');
@@ -23,6 +24,26 @@ var _socketQueue = require('../network/socketQueue');
23
24
  var _constants = require('./constants');
24
25
 
25
26
 
27
+ /**
28
+ * What the last REST health probe said about a connected server:
29
+ * - `pending`: the probe has not answered yet.
30
+ * - `healthy`: Blockbook reported `inSync: true`.
31
+ * - `unhealthy`: Blockbook reported `inSync: false`.
32
+ * - `unknown`: the server has no HTTP twin to probe, or the probe failed
33
+ * (network error, non-200, unparseable body). Never treated as unhealthy.
34
+ */
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
26
47
 
27
48
 
28
49
 
@@ -115,6 +136,33 @@ var _constants = require('./constants');
115
136
  */
116
137
  const BROADCAST_ATTEMPT_TIMEOUT_MS = 30000; exports.BROADCAST_ATTEMPT_TIMEOUT_MS = BROADCAST_ATTEMPT_TIMEOUT_MS
117
138
 
139
+ /**
140
+ * Upper bound on a REST health probe. The probe runs off the socket keepalive
141
+ * and must never hold the connection's timer for long.
142
+ */
143
+ const HEALTH_PROBE_TIMEOUT_MS = 10000; exports.HEALTH_PROBE_TIMEOUT_MS = HEALTH_PROBE_TIMEOUT_MS
144
+
145
+ /**
146
+ * Probes of one server closer together than this share a single request,
147
+ * across every wallet of the plugin. The connect path and the first
148
+ * keepalive can land within milliseconds of each other, and N wallets each
149
+ * run their own keepalive; one answer serves them all.
150
+ */
151
+ const HEALTH_PROBE_MIN_INTERVAL_MS = 10000; exports.HEALTH_PROBE_MIN_INTERVAL_MS = HEALTH_PROBE_MIN_INTERVAL_MS
152
+
153
+ /**
154
+ * The part of Blockbook's REST `GET /api/` response the health probe reads.
155
+ * The WebSocket `getInfo` message does not carry `inSync`, so the probe has
156
+ * to go over HTTP. Blockbook itself masks the first five seconds of every
157
+ * resync, so a `false` here means the indexer has been behind its backend
158
+ * for longer than that, or the backend RPC is failing.
159
+ */
160
+ const asBlockbookHealth = _cleaners.asObject.call(void 0, {
161
+ blockbook: _cleaners.asObject.call(void 0, {
162
+ inSync: _cleaners.asBoolean
163
+ })
164
+ })
165
+
118
166
  const withTimeout = async (
119
167
  promise,
120
168
  ms,
@@ -145,9 +193,146 @@ const withTimeout = async (
145
193
  } = config
146
194
  const { serverConfigs = [] } = pluginInfo.engineInfo
147
195
  const { broadcastTimeoutMs = exports.BROADCAST_ATTEMPT_TIMEOUT_MS } = config
196
+ const {
197
+ healthProbeMinIntervalMs = exports.HEALTH_PROBE_MIN_INTERVAL_MS,
198
+ healthProbeTimeoutMs = exports.HEALTH_PROBE_TIMEOUT_MS,
199
+ keepAliveMs,
200
+ wakeUpMs
201
+ } = config
148
202
  log('Making server states')
149
203
 
150
204
  const serverStatesCache = {}
205
+
206
+ // Edge's own Blockbook servers expose both a socket and a REST endpoint on
207
+ // the same host. Index the REST URIs by host so a socket URI can find its
208
+ // twin for the health probe. Third-party sockets (Trezor, NOWNodes) have no
209
+ // twin here and are never probed.
210
+ const httpUriByHost = {}
211
+ for (const serverConfig of serverConfigs) {
212
+ if (serverConfig.type !== 'blockbook') continue
213
+ for (const httpUri of serverConfig.uris) {
214
+ const { host } = _urijs.parse.call(void 0, httpUri)
215
+ if (host != null) httpUriByHost[host] = httpUri
216
+ }
217
+ }
218
+
219
+ const findHttpTwin = (uri) => {
220
+ const { host } = _urijs.parse.call(void 0, uri)
221
+ return host == null ? undefined : httpUriByHost[host]
222
+ }
223
+
224
+ const probeServerHealth = async (uri) => {
225
+ const httpUri = findHttpTwin(uri)
226
+ if (httpUri == null) return 'unknown'
227
+ const probe = async () => {
228
+ const response = await io.fetchCors(`${httpUri}/api/`, { method: 'GET' })
229
+ if (!response.ok) {
230
+ log.warn(`${uri} health probe: HTTP ${response.status}`)
231
+ return 'unknown'
232
+ }
233
+ const { blockbook } = asBlockbookHealth(await response.json())
234
+ return blockbook.inSync ? 'healthy' : 'unhealthy'
235
+ }
236
+ try {
237
+ // The bound covers the body too; a server can send its headers and
238
+ // then stall:
239
+ return await withTimeout(
240
+ probe(),
241
+ healthProbeTimeoutMs,
242
+ `Timeout for health probe to ${httpUri}`
243
+ )
244
+ } catch (error) {
245
+ log.warn(`${uri} health probe failed: ${String(error)}`)
246
+ return 'unknown'
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Disconnects a server on purpose. On platforms that deliver a close event
252
+ * for a locally closed socket (the app's WebView does) the socket's close
253
+ * path also runs, scoring the server down and throwing its in-flight
254
+ * requests. The refill is scheduled here regardless, so replacing the
255
+ * connection never depends on that event arriving.
256
+ */
257
+ const dropServer = (uri, reason) => {
258
+ const serverState = serverStatesCache[uri]
259
+ if (serverState == null) return
260
+ log.warn(`${uri} dropped: ${reason}`)
261
+ // Keep the refill from picking it straight back; its score is still
262
+ // near the top since it answered every ping:
263
+ pluginState.quarantineServer(uri)
264
+ _utils.removeItem.call(void 0, serverStatesCache, uri)
265
+ serverState.blockbook.disconnect().catch((error) => {
266
+ log.error(`${uri} disconnect failed: ${String(error)}`)
267
+ })
268
+ reconnect()
269
+ // Let the engine re-query anything this server answered:
270
+ engineEmitter.emit(_EngineEmitter.EngineEvent.SERVER_DROPPED, uri)
271
+ }
272
+
273
+ /**
274
+ * Whether some other connection could carry the wallet if `uri` went
275
+ * away: open, and not known to be out of sync. A peer whose probe has not
276
+ * answered yet does not count; it may be about to fail the same way.
277
+ */
278
+ const hasUsablePeer = (uri) =>
279
+ Object.entries(serverStatesCache).some(
280
+ ([otherUri, state]) =>
281
+ otherUri !== uri &&
282
+ state.blockbook.isConnected &&
283
+ (state.health === 'healthy' || state.health === 'unknown')
284
+ )
285
+
286
+ /**
287
+ * Drops every server known to be out of sync, as long as the wallet keeps
288
+ * at least one usable connection. A stale server that is the only leg is
289
+ * kept: behind the chain beats no data at all. It is re-evaluated on every
290
+ * probe result, so it goes as soon as a usable peer shows up.
291
+ *
292
+ * Only one stale leg is kept. Two stale servers would otherwise keep each
293
+ * other, filling every connection slot so the refill never reaches a
294
+ * server that could replace them.
295
+ */
296
+ const dropUnhealthyServers = () => {
297
+ let isStaleLegKept = false
298
+ for (const [uri, state] of Object.entries(serverStatesCache)) {
299
+ if (state.health !== 'unhealthy') continue
300
+ if (!hasUsablePeer(uri) && !isStaleLegKept) {
301
+ log.warn(
302
+ `${uri} reports inSync: false but is the only usable connection`
303
+ )
304
+ isStaleLegKept = true
305
+ continue
306
+ }
307
+ dropServer(uri, 'Blockbook reports inSync: false')
308
+ }
309
+ }
310
+
311
+ const checkServerHealth = (uri) => {
312
+ const serverState = serverStatesCache[uri]
313
+ if (serverState == null) return
314
+ pluginState
315
+ .shareServerProbe(
316
+ uri,
317
+ healthProbeMinIntervalMs,
318
+ async () => await probeServerHealth(uri)
319
+ )
320
+ .then(health => {
321
+ const serverState = serverStatesCache[uri]
322
+ // The connection may have gone away while the probe was in flight:
323
+ if (serverState == null) return
324
+ if (serverState.health !== health) {
325
+ log(`${uri} health: ${serverState.health} -> ${health}`)
326
+ }
327
+ serverState.health = health
328
+ // This result may make this server droppable, or make it the peer
329
+ // that lets a previously kept stale server go:
330
+ dropUnhealthyServers()
331
+ })
332
+ .catch((error) => {
333
+ log.error(`${uri} health check failed: ${String(error)}`)
334
+ })
335
+ }
151
336
  let isEngineOn = true
152
337
  let serverList = []
153
338
  let reconnectCounter = 0
@@ -181,6 +366,8 @@ const withTimeout = async (
181
366
  const queryTime = Date.now() - queryDate
182
367
  log(`${uri} returned healthCheck in ${queryTime}ms`)
183
368
  pluginState.serverScoreUp(uri, queryTime)
369
+ // Periodic probe, once per keepalive:
370
+ checkServerHealth(uri)
184
371
  }
185
372
  )
186
373
  engineEmitter.on(
@@ -199,7 +386,11 @@ const withTimeout = async (
199
386
  _EngineEmitter.EngineEvent.BLOCK_HEIGHT_CHANGED,
200
387
  (uri, blockHeight) => {
201
388
  log(`${uri} block height changed to ${blockHeight}`)
202
- serverStatesCache[uri].blockHeight = blockHeight
389
+ const serverState = serverStatesCache[uri]
390
+ // The connection may already be gone, as the sibling handler above
391
+ // allows for:
392
+ if (serverState == null) return
393
+ serverState.blockHeight = blockHeight
203
394
  }
204
395
  )
205
396
 
@@ -207,18 +398,26 @@ const withTimeout = async (
207
398
 
208
399
 
209
400
 
210
- const makeServerStatesCacheEntry = (blockbook) => ({
401
+ const makeServerStatesCacheEntry = (
402
+ uri,
403
+ blockbook
404
+ ) => ({
211
405
  blockbook,
212
406
  blockSubscriptionStatus: 'unsubscribed',
213
407
  txids: new Set(),
214
408
  addresses: new Set(),
215
- blockHeight: 0
409
+ blockHeight: 0,
410
+ health: findHttpTwin(uri) == null ? 'unknown' : 'pending'
216
411
  })
217
412
 
218
413
  const reconnect = () => {
219
414
  if (isEngineOn) {
220
415
  log(`attempting server reconnect number ${reconnectCounter}`)
221
416
  const reconnectionDelay = Math.max(5, reconnectCounter++) * 1000
417
+ // One pending reconnect at a time. A drop and the close event it
418
+ // causes both land here, and a timer that was overwritten rather than
419
+ // cleared would outlive stop() and switch the engine back on.
420
+ clearTimeout(reconnectTimer)
222
421
  reconnectTimer = setTimeout(() => {
223
422
  clearTimeout(reconnectTimer)
224
423
  instance.refillServers()
@@ -244,6 +443,9 @@ const withTimeout = async (
244
443
  // Skip reconnecting to an existing connection
245
444
  if (serverStatesCache[uri] != null) continue
246
445
 
446
+ // The list may predate a quarantine:
447
+ if (pluginState.isServerQuarantined(uri)) continue
448
+
247
449
  // Validate the URI of server to make sure it is valid
248
450
  const parsed = _urijs.parse.call(void 0, uri)
249
451
  if (
@@ -254,7 +456,7 @@ const withTimeout = async (
254
456
  continue
255
457
  }
256
458
 
257
- // Ranomize the URI picking
459
+ // Randomize the URI picking
258
460
  chanceToBePicked -= chanceToBePicked > 0.5 ? 0.25 : 0
259
461
  if (Math.random() > chanceToBePicked) {
260
462
  serverList.push(uri)
@@ -331,17 +533,25 @@ const withTimeout = async (
331
533
  log,
332
534
  taskGeneratorFn,
333
535
  socketEmitter,
334
- walletId: walletInfo.id
536
+ walletId: walletInfo.id,
537
+ keepAliveMs,
538
+ wakeUpMs
335
539
  })
336
540
  }
337
541
 
338
542
  // Make new ServerStates instance
339
- serverStatesCache[uri] = makeServerStatesCacheEntry(blockbook)
543
+ serverStatesCache[uri] = makeServerStatesCacheEntry(uri, blockbook)
340
544
 
341
545
  // Initialize blockbook connection for server
342
546
  blockbook
343
547
  .connect()
344
548
  .then(async () => {
549
+ // This connection is now open and counts as a usable peer, which
550
+ // may be what a stale server kept for lack of one was waiting on.
551
+ // Done here rather than on the socket's open event, which fires
552
+ // before the connect promise marks the blockbook connected:
553
+ dropUnhealthyServers()
554
+
345
555
  // Fetch block height from blockbook server
346
556
  const startTime = Date.now()
347
557
  const { bestHeight: blockHeight } = await blockbook.fetchInfo()
@@ -356,11 +566,29 @@ const withTimeout = async (
356
566
  // Increment server score using response time
357
567
  const responseTime = Date.now() - startTime
358
568
  pluginState.serverScoreUp(uri, responseTime)
569
+
570
+ // On-connect probe. The keepalive may or may not have fired by
571
+ // now, depending on how fast the socket opened, so this is the
572
+ // one place that guarantees a probe before the server does work:
573
+ checkServerHealth(uri)
359
574
  })
360
575
  .catch(e => {
361
576
  log.error(`${JSON.stringify(e.message)}`)
362
577
  })
363
578
  }
579
+
580
+ // A quarantined server is withheld from the candidate list, so a wallet
581
+ // left short of connections would otherwise stay that way until an
582
+ // unrelated close event. Keep trying while a quarantine is in force; the
583
+ // server comes back into the list when it expires. The reconnect delay
584
+ // grows with each empty pass, so this does not spin.
585
+ if (
586
+ Object.keys(serverStatesCache).length < _constants.MAX_CONNECTIONS &&
587
+ serverList.length === 0 &&
588
+ pluginState.hasQuarantinedServers()
589
+ ) {
590
+ reconnect()
591
+ }
364
592
  }
365
593
 
366
594
  const deferredWithServerScoring = (
@@ -581,6 +809,10 @@ const withTimeout = async (
581
809
  return serverStatesCache[uri].blockHeight
582
810
  },
583
811
 
812
+ getServerHealth(uri) {
813
+ return _optionalChain([serverStatesCache, 'access', _2 => _2[uri], 'optionalAccess', _3 => _3.health])
814
+ },
815
+
584
816
  getServerList() {
585
817
  return serverList
586
818
  },
@@ -674,7 +906,7 @@ const withTimeout = async (
674
906
  serverState.addresses.delete(address)
675
907
  }
676
908
  // Reject the deferredAddressSub promise if provided
677
- _optionalChain([deferredAddressSub, 'optionalAccess', _2 => _2.reject, 'call', _3 => _3(err)])
909
+ _optionalChain([deferredAddressSub, 'optionalAccess', _4 => _4.reject, 'call', _5 => _5(err)])
678
910
  })
679
911
 
680
912
  blockbook.watchAddresses(