edge-currency-plugins 3.12.0 → 3.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.
@@ -313,6 +313,12 @@ var _types3 = require('../utxobased/db/types');
313
313
 
314
314
 
315
315
 
316
+
317
+
318
+
319
+
320
+
321
+
316
322
 
317
323
 
318
324
 
@@ -91,6 +91,47 @@ var _constants = require('./constants');
91
91
 
92
92
 
93
93
 
94
+
95
+
96
+
97
+
98
+
99
+
100
+ /**
101
+ * How long broadcastTx gives the first wave (our sockets and Edge's own HTTP
102
+ * servers) before it also sends the transaction to the NOWNodes HTTP servers.
103
+ * NOWNodes is a third party, so it should only see a transaction when our own
104
+ * infrastructure has not already carried it. A first wave that fails outright
105
+ * does not wait this long; the second wave fires as soon as it has all failed.
106
+ */
107
+ const NOWNODES_BROADCAST_DELAY_MS = 2000; exports.NOWNODES_BROADCAST_DELAY_MS = NOWNODES_BROADCAST_DELAY_MS
108
+
109
+ /**
110
+ * Every broadcast attempt is counted toward "all attempts failed", so every
111
+ * attempt must settle. Sockets already expire a request after 30 seconds
112
+ * (Socket.ts); HTTP has no such bound of its own, and a server that accepts
113
+ * the connection and never answers would otherwise hold the whole broadcast
114
+ * open. This mirrors the socket figure.
115
+ */
116
+ const BROADCAST_ATTEMPT_TIMEOUT_MS = 30000; exports.BROADCAST_ATTEMPT_TIMEOUT_MS = BROADCAST_ATTEMPT_TIMEOUT_MS
117
+
118
+ const withTimeout = async (
119
+ promise,
120
+ ms,
121
+ message
122
+ ) => {
123
+ let timer
124
+ const timeout = new Promise((resolve, reject) => {
125
+ timer = setTimeout(() => {
126
+ reject(new Error(message))
127
+ }, ms)
128
+ })
129
+ try {
130
+ return await Promise.race([promise, timeout])
131
+ } finally {
132
+ if (timer != null) clearTimeout(timer)
133
+ }
134
+ }
94
135
 
95
136
  function makeServerStates(config) {
96
137
  const {
@@ -103,6 +144,7 @@ var _constants = require('./constants');
103
144
  walletInfo
104
145
  } = config
105
146
  const { serverConfigs = [] } = pluginInfo.engineInfo
147
+ const { broadcastTimeoutMs = exports.BROADCAST_ATTEMPT_TIMEOUT_MS } = config
106
148
  log('Making server states')
107
149
 
108
150
  const serverStatesCache = {}
@@ -366,112 +408,171 @@ var _constants = require('./constants');
366
408
  }
367
409
 
368
410
  const instance = {
411
+ /**
412
+ * Broadcast in two waves:
413
+ *
414
+ * 1. Immediately: every blockbook in the connection cache (whether or not
415
+ * its socket has finished connecting; a queued request transmits as
416
+ * soon as the socket opens, and times out otherwise) and every
417
+ * `blockbook` HTTP server, which are Edge's own.
418
+ * 2. After NOWNODES_BROADCAST_DELAY_MS, or as soon as every first-wave
419
+ * attempt has failed, whichever comes first: the `blockbook-nownode`
420
+ * HTTP servers. These are a third party and only see the transaction
421
+ * when our own infrastructure has not already carried it.
422
+ *
423
+ * The first success wins. The promise rejects only after every attempt
424
+ * has failed. Earlier versions only used HTTP when no socket was
425
+ * connected, so a single socket that looked connected but never answered
426
+ * could fail the whole broadcast without any HTTP attempt.
427
+ */
369
428
  async broadcastTx(transaction) {
370
429
  return await new Promise((resolve, reject) => {
371
- let resolved = false
372
- let bad = 0
373
-
374
- const wsUris = Object.keys(serverStatesCache).filter(
375
- uri => serverStatesCache[uri].blockbook != null
376
- )
377
-
378
- // Determine if there are any connected blockbook instances
379
- const isAnyBlockbookConnected = wsUris.some(
380
- uri => serverStatesCache[uri].blockbook.isConnected
381
- )
382
-
383
- if (isAnyBlockbookConnected) {
384
- for (const uri of wsUris) {
385
- const { blockbook } = serverStatesCache[uri]
386
- if (blockbook == null) continue
387
- blockbook
388
- .broadcastTx(transaction)
389
- .then(response => {
390
- if (!resolved) {
391
- resolved = true
392
- resolve(response.result)
393
- }
394
- })
395
- .catch((e) => {
396
- if (++bad === wsUris.length) {
397
- const msg = e != null ? `With error ${e.message}` : ''
398
- log.error(
399
- `broadcastTx fail: ${JSON.stringify(transaction)}\n${msg}`
400
- )
401
- reject(e)
402
- }
403
- })
430
+
431
+
432
+
433
+
434
+
435
+ const httpAttempt = (
436
+ uri,
437
+ headers
438
+ ) => ({
439
+ uri,
440
+ run: async () => {
441
+ const response = await io.fetchCors(`${uri}/api/v2/sendtx/`, {
442
+ method: 'POST',
443
+ headers,
444
+ body: transaction.signedTx
445
+ })
446
+ if (!response.ok) {
447
+ throw new Error(
448
+ `Failed to broadcast transaction via Blockbook: HTTP ${response.status}`
449
+ )
450
+ }
451
+ const json = await response.json()
452
+ return _blockbookApi.asBlockbookResponse.call(void 0, (0, _blockbookApi.asBroadcastTxResponse))(json).result
404
453
  }
454
+ })
455
+
456
+ //
457
+ // Build both waves up front, so `attempts` is the full count and an
458
+ // all-fail first wave cannot reject before the second has run.
459
+ //
460
+ const firstWave = []
461
+ const secondWave = []
462
+
463
+ for (const uri of Object.keys(serverStatesCache)) {
464
+ const { blockbook } = serverStatesCache[uri]
465
+ if (blockbook == null) continue
466
+ firstWave.push({
467
+ uri,
468
+ run: async () => (await blockbook.broadcastTx(transaction)).result
469
+ })
405
470
  }
406
471
 
407
- // Broadcast through any HTTP URI that may be configured, only if no
408
- // blockbook instances are connected.
409
- if (!isAnyBlockbookConnected) {
410
- // This is for the future when we want to get HTTP servers from the user
411
- // settings:
412
- // const httpUris = pluginState.getLocalServers(Infinity, [
413
- // /^http(?:s)?:/i
414
- // ])
415
-
416
- const { nowNodesApiKey } = initOptions
417
- const nowNodeUris = serverConfigs
418
- .filter(config => config.type === 'blockbook-nownode')
419
- .map(config => config.uris)
420
- .flat(1)
421
-
422
- // If there are no HTTP servers, reject the promise
423
- if (nowNodeUris.length < 1) {
424
- // If no HTTP servers are available, and we had no connected blockbook
425
- // instances, reject the promise with a message indicating no
426
- // available connections.
472
+ // This is for the future when we want to get HTTP servers from the user
473
+ // settings:
474
+ // const httpUris = pluginState.getLocalServers(Infinity, [
475
+ // /^http(?:s)?:/i
476
+ // ])
477
+ const { nowNodesApiKey } = initOptions
478
+ for (const config of serverConfigs) {
479
+ if (config.type === 'blockbook-nownode') {
480
+ // NOWNodes requires the key, and the key must not go anywhere else:
481
+ if (nowNodesApiKey == null) {
482
+ log.warn(
483
+ 'broadcastTx: skipping NOWNodes HTTP servers (no nowNodesApiKey)'
484
+ )
485
+ continue
486
+ }
487
+ for (const uri of config.uris) {
488
+ secondWave.push(httpAttempt(uri, { 'api-key': nowNodesApiKey }))
489
+ }
490
+ } else {
491
+ for (const uri of config.uris) {
492
+ firstWave.push(httpAttempt(uri, {}))
493
+ }
494
+ }
495
+ }
496
+
497
+ const attempts = firstWave.length + secondWave.length
498
+ if (attempts === 0) {
499
+ reject(
500
+ new Error('No available connections. Check your internet signal.')
501
+ )
502
+ return
503
+ }
504
+
505
+ let resolved = false
506
+ let failures = 0
507
+ const failureMessages = []
508
+ let secondWaveFired = false
509
+ let secondWaveTimer
510
+
511
+ const onSuccess = (uri, txid) => {
512
+ if (resolved) return
513
+ resolved = true
514
+ if (secondWaveTimer != null) clearTimeout(secondWaveTimer)
515
+ log(`broadcastTx succeeded via ${uri}: ${txid}`)
516
+ resolve(txid)
517
+ }
518
+ const onFailure = (uri, error) => {
519
+ const message = error instanceof Error ? error.message : String(error)
520
+ failureMessages.push(`${uri}: ${message}`)
521
+ log.warn(`broadcastTx attempt failed for ${uri}: ${message}`)
522
+ failures++
523
+ if (resolved) return
524
+ if (failures === attempts) {
525
+ log.error(
526
+ `broadcastTx fail: ${JSON.stringify(
527
+ transaction
528
+ )}\n${failureMessages.join('\n')}`
529
+ )
427
530
  reject(
428
- new Error('No available connections. Check your internet signal.')
531
+ error instanceof Error
532
+ ? error
533
+ : new Error(`Broadcast failed: ${failureMessages.join('; ')}`)
429
534
  )
430
535
  return
431
536
  }
432
-
433
- // If there is no key for the NowNode servers:
434
- if (nowNodesApiKey == null) {
435
- reject(new Error('Missing connection key for fallback servers.'))
436
- return
537
+ // Every first-wave attempt has failed, so there is nothing left to
538
+ // wait for. Fire the second wave now rather than sit out the delay.
539
+ if (!secondWaveFired && failures >= firstWave.length) {
540
+ fireSecondWave()
437
541
  }
438
-
439
- for (const uri of nowNodeUris) {
440
- log.warn('Falling back to NOWNode server broadcast over HTTP:', uri)
441
-
442
- // HTTP Fallback
443
- io.fetchCors(`${uri}/api/v2/sendtx/`, {
444
- method: 'POST',
445
- headers: {
446
- 'api-key': nowNodesApiKey
447
- },
448
- body: transaction.signedTx
542
+ }
543
+ const fire = (attempt) => {
544
+ withTimeout(
545
+ attempt.run(),
546
+ broadcastTimeoutMs,
547
+ `Timeout for broadcast to ${attempt.uri}`
548
+ )
549
+ .then(txid => {
550
+ onSuccess(attempt.uri, txid)
449
551
  })
450
- .then(async response => {
451
- if (!response.ok) {
452
- throw new Error(
453
- `Failed to broadcast transaction via Blockbook: HTTP ${response.status}`
454
- )
455
- }
456
- const json = await response.json()
457
- return _blockbookApi.asBlockbookResponse.call(void 0, (0, _blockbookApi.asBroadcastTxResponse))(json)
458
- })
459
- .then(response => {
460
- if (!resolved) {
461
- resolved = true
462
- resolve(response.result)
463
- }
464
- })
465
- .catch((e) => {
466
- if (++bad === nowNodeUris.length) {
467
- const msg = e != null ? `With error ${e.message}` : ''
468
- log.error(
469
- `broadcastTx fail: ${JSON.stringify(transaction)}\n${msg}`
470
- )
471
- reject(e)
472
- }
473
- })
474
- }
552
+ .catch((error) => {
553
+ onFailure(attempt.uri, error)
554
+ })
555
+ }
556
+ const fireSecondWave = () => {
557
+ if (secondWaveFired) return
558
+ secondWaveFired = true
559
+ if (secondWaveTimer != null) clearTimeout(secondWaveTimer)
560
+ if (secondWave.length === 0) return
561
+ log.warn(
562
+ 'broadcastTx: first wave has not resolved, trying NOWNodes servers'
563
+ )
564
+ for (const attempt of secondWave) fire(attempt)
565
+ }
566
+
567
+ for (const attempt of firstWave) fire(attempt)
568
+ if (firstWave.length === 0) {
569
+ // Nothing of our own to try first:
570
+ fireSecondWave()
571
+ } else if (secondWave.length > 0) {
572
+ secondWaveTimer = setTimeout(
573
+ fireSecondWave,
574
+ exports.NOWNODES_BROADCAST_DELAY_MS
575
+ )
475
576
  }
476
577
  })
477
578
  },
@@ -20,8 +20,8 @@ const currencyInfo = {
20
20
 
21
21
  // Explorers:
22
22
  blockExplorer: 'https://blockchair.com/bitcoin/block/%s',
23
- addressExplorer: 'https://blockchair.com/bitcoin/address/%s',
24
- transactionExplorer: 'https://blockchair.com/bitcoin/transaction/%s',
23
+ addressExplorer: 'https://mempool.space/address/%s',
24
+ transactionExplorer: 'https://mempool.space/tx/%s',
25
25
 
26
26
  denominations: [
27
27
  { name: 'BTC', multiplier: '100000000', symbol: '₿' },
@@ -53,7 +53,7 @@ const currencyInfo = {
53
53
  const engineInfo = {
54
54
  serverConfigs: [
55
55
  {
56
- type: 'blockbook-nownode',
56
+ type: 'blockbook',
57
57
  uris: ['https://btc-wusa1.edge.app', 'https://btc-eu1.edge.app']
58
58
  },
59
59
  {
@@ -53,7 +53,7 @@ const currencyInfo = {
53
53
  const engineInfo = {
54
54
  serverConfigs: [
55
55
  {
56
- type: 'blockbook-nownode',
56
+ type: 'blockbook',
57
57
  uris: ['https://bch-eusa1.edge.app']
58
58
  },
59
59
  {
@@ -52,7 +52,7 @@ const currencyInfo = {
52
52
  const engineInfo = {
53
53
  serverConfigs: [
54
54
  {
55
- type: 'blockbook-nownode',
55
+ type: 'blockbook',
56
56
  uris: ['https://dash-wusa1.edge.app']
57
57
  },
58
58
  {
@@ -46,7 +46,7 @@ const currencyInfo = {
46
46
  const engineInfo = {
47
47
  serverConfigs: [
48
48
  {
49
- type: 'blockbook-nownode',
49
+ type: 'blockbook',
50
50
  uris: ['https://dgb-eu1.edge.app']
51
51
  },
52
52
  {
@@ -48,7 +48,7 @@ const currencyInfo = {
48
48
  const engineInfo = {
49
49
  serverConfigs: [
50
50
  {
51
- type: 'blockbook-nownode',
51
+ type: 'blockbook',
52
52
  uris: ['https://doge-eusa1.edge.app']
53
53
  },
54
54
  {
@@ -20,8 +20,8 @@ var _commonInfo = require('./commonInfo');
20
20
 
21
21
  // Explorers:
22
22
  blockExplorer: 'https://blockchair.com/litecoin/block/%s',
23
- addressExplorer: 'https://blockchair.com/litecoin/address/%s',
24
- transactionExplorer: 'https://blockchair.com/litecoin/transaction/%s',
23
+ addressExplorer: 'https://litecoinspace.org/address/%s',
24
+ transactionExplorer: 'https://litecoinspace.org/tx/%s',
25
25
 
26
26
  denominations: [
27
27
  { name: 'LTC', multiplier: '100000000', symbol: 'Ł' },
@@ -50,7 +50,7 @@ var _commonInfo = require('./commonInfo');
50
50
  const engineInfo = {
51
51
  serverConfigs: [
52
52
  {
53
- type: 'blockbook-nownode',
53
+ type: 'blockbook',
54
54
  uris: ['https://ltc-wusa1.edge.app']
55
55
  },
56
56
  {
@@ -44,7 +44,7 @@ const currencyInfo = {
44
44
  const engineInfo = {
45
45
  serverConfigs: [
46
46
  {
47
- type: 'blockbook-nownode',
47
+ type: 'blockbook',
48
48
  uris: ['https://pivx-wusa1.edge.app']
49
49
  },
50
50
  {
@@ -42,7 +42,7 @@ const currencyInfo = {
42
42
  const engineInfo = {
43
43
  serverConfigs: [
44
44
  {
45
- type: 'blockbook-nownode',
45
+ type: 'blockbook',
46
46
  uris: ['https://qtum-wusa1.edge.app']
47
47
  }
48
48
  ],
@@ -48,7 +48,7 @@ const currencyInfo = {
48
48
  const engineInfo = {
49
49
  serverConfigs: [
50
50
  {
51
- type: 'blockbook-nownode',
51
+ type: 'blockbook',
52
52
  uris: ['https://vtc-wusa1.edge.app']
53
53
  }
54
54
  ],
@@ -45,7 +45,7 @@ var _commonInfo = require('./commonInfo');
45
45
  const engineInfo = {
46
46
  serverConfigs: [
47
47
  {
48
- type: 'blockbook-nownode',
48
+ type: 'blockbook',
49
49
  uris: ['https://firo-eusa1.edge.app']
50
50
  },
51
51
  {
@@ -177,8 +177,16 @@ var _Socket = require('./Socket');
177
177
 
178
178
 
179
179
  {
180
- const value = yield { ...message }
181
- deferred.resolve(value)
180
+ try {
181
+ const value = yield { ...message }
182
+ deferred.resolve(value)
183
+ } catch (error) {
184
+ // The socket reports a failed request (an error response, the
185
+ // request timeout, the socket closing) by throwing into this
186
+ // generator. Without this catch the deferred stays pending forever
187
+ // and the caller hangs instead of seeing the failure.
188
+ deferred.reject(error)
189
+ }
182
190
  return false
183
191
  }
184
192
  const generator = taskGeneratorFn()
@@ -116,7 +116,13 @@ export interface EngineInfo {
116
116
  txSpecificHandling?: (tx: TransactionData, specialTx: unknown) => TransactionData;
117
117
  }
118
118
  export interface ServerConfig {
119
- type: 'blockbook-nownode';
119
+ /**
120
+ * - `blockbook`: a public Blockbook HTTP server. No credentials are sent.
121
+ * - `blockbook-nownode`: a NOWNodes Blockbook HTTP server. Requests carry
122
+ * the `nowNodesApiKey` init option as the `api-key` header, and the
123
+ * server is skipped when no key is configured.
124
+ */
125
+ type: 'blockbook' | 'blockbook-nownode';
120
126
  uris: string[];
121
127
  }
122
128
  /**
@@ -23,6 +23,11 @@ interface ServerStateConfig {
23
23
  pluginInfo: PluginInfo;
24
24
  pluginState: PluginState;
25
25
  walletInfo: SafeWalletInfo;
26
+ /**
27
+ * Upper bound on any single broadcast attempt. Defaults to the same 30
28
+ * seconds the socket layer uses for a request. Exposed for tests.
29
+ */
30
+ broadcastTimeoutMs?: number;
26
31
  }
27
32
  export interface ServerStates {
28
33
  setPickNextTaskCB: (callback: (uri: string) => AsyncGenerator<WsTask<unknown> | boolean>) => void;
@@ -46,5 +51,21 @@ export interface ServerStates {
46
51
  transactionSpecialQueryTask: (serverUri: string, txId: string) => WsTaskGenerator<unknown>;
47
52
  utxoListQueryTask: (serverUri: string, address: string) => WsTaskGenerator<AddressUtxosResponse>;
48
53
  }
54
+ /**
55
+ * How long broadcastTx gives the first wave (our sockets and Edge's own HTTP
56
+ * servers) before it also sends the transaction to the NOWNodes HTTP servers.
57
+ * NOWNodes is a third party, so it should only see a transaction when our own
58
+ * infrastructure has not already carried it. A first wave that fails outright
59
+ * does not wait this long; the second wave fires as soon as it has all failed.
60
+ */
61
+ export declare const NOWNODES_BROADCAST_DELAY_MS = 2000;
62
+ /**
63
+ * Every broadcast attempt is counted toward "all attempts failed", so every
64
+ * attempt must settle. Sockets already expire a request after 30 seconds
65
+ * (Socket.ts); HTTP has no such bound of its own, and a server that accepts
66
+ * the connection and never answers would otherwise hold the whole broadcast
67
+ * open. This mirrors the socket figure.
68
+ */
69
+ export declare const BROADCAST_ATTEMPT_TIMEOUT_MS = 30000;
49
70
  export declare function makeServerStates(config: ServerStateConfig): ServerStates;
50
71
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-currency-plugins",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Edge currency plugins to fetch and process wallet accounts, balances and transactions.",
5
5
  "homepage": "https://edge.app/",
6
6
  "repository": "https://github.com/EdgeApp/edge-currency-plugins",