@dashevo/dapi-client 5.0.3 → 5.1.0-dev.1

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.
@@ -1,13 +1,14 @@
1
1
  const EventEmitter = require('events');
2
2
  const { SpvChain, SPVError } = require('@dashevo/dash-spv');
3
+ const GrpcErrorCodes = require('@dashevo/grpc-common/lib/server/error/GrpcErrorCodes');
3
4
 
4
5
  const BlockHeadersReader = require('./BlockHeadersReader');
5
6
 
6
7
  /**
7
8
  * @typedef {BlockHeadersProviderOptions} BlockHeadersProviderOptions
8
9
  * @property {string} [network='testnet'] network type
9
- * @property {number} [maxParallelStreams=5] max parallel streams to read historical block headers
10
- * @property {number} [targetBatchSize=100000] a target batch size per stream
10
+ * @property {number} [maxParallelStreams=10] max parallel streams to read historical block headers
11
+ * @property {number} [targetBatchSize=10000] a target batch size per stream
11
12
  * @property {number} [fromBlockHeight=1] a target batch size per stream
12
13
  * @property {number} [maxRetries=10] max amount of retries per stream connection
13
14
  * @property {number} [powTargetSpacing] devnets only: the -powtargetspacing the
@@ -18,7 +19,9 @@ const BlockHeadersReader = require('./BlockHeadersReader');
18
19
  const defaultOptions = {
19
20
  network: 'testnet',
20
21
  maxParallelStreams: 10,
21
- targetBatchSize: 50000,
22
+ // Headers read ahead of the chain wait in memory, up to about
23
+ // maxParallelStreams * targetBatchSize of them
24
+ targetBatchSize: 10000,
22
25
  fromBlockHeight: 1,
23
26
  maxRetries: 10,
24
27
  };
@@ -53,19 +56,25 @@ class BlockHeadersProvider extends EventEmitter {
53
56
  ...options,
54
57
  };
55
58
 
56
- const { powTargetSpacing, minimumDifficultyBlocks } = this.options;
57
- const devnetOptions = powTargetSpacing !== undefined || minimumDifficultyBlocks !== undefined
58
- ? { powTargetSpacing, minimumDifficultyBlocks }
59
- : undefined;
60
- this.spvChain = new SpvChain(this.options.network, 100, devnetOptions);
61
-
62
59
  this.state = STATES.IDLE;
63
60
 
61
+ // How far below the tip continuous sync asks for headers again when new
62
+ // ones don't connect: DAPI streams only each new tip, so after a reorg or
63
+ // several blocks at once the parents have to be fetched by height
64
+ this.continuousLookback = 1;
65
+
64
66
  this.errorHandler = this.errorHandler.bind(this);
65
67
  this.headersHandler = this.headersHandler.bind(this);
68
+ this.chainConnectedHandler = this.chainConnectedHandler.bind(this);
66
69
  this.historicalDataObtainedHandler = this.historicalDataObtainedHandler.bind(this);
67
70
  this.createHistoricalSyncStream = createHistoricalSyncStream;
68
71
  this.createContinuousSyncStream = createContinuousSyncStream;
72
+
73
+ const { powTargetSpacing, minimumDifficultyBlocks } = this.options;
74
+ const devnetOptions = powTargetSpacing !== undefined || minimumDifficultyBlocks !== undefined
75
+ ? { powTargetSpacing, minimumDifficultyBlocks }
76
+ : undefined;
77
+ this.setSpvChain(new SpvChain(this.options.network, 100, devnetOptions));
69
78
  }
70
79
 
71
80
  /**
@@ -80,7 +89,11 @@ class BlockHeadersProvider extends EventEmitter {
80
89
  * @param {SpvChain} spvChain instance
81
90
  */
82
91
  setSpvChain(spvChain) {
92
+ if (this.spvChain) {
93
+ this.spvChain.removeListener(SpvChain.EVENTS.CONNECTED, this.chainConnectedHandler);
94
+ }
83
95
  this.spvChain = spvChain;
96
+ this.spvChain.on(SpvChain.EVENTS.CONNECTED, this.chainConnectedHandler);
84
97
  }
85
98
 
86
99
  /**
@@ -96,6 +109,9 @@ class BlockHeadersProvider extends EventEmitter {
96
109
  maxParallelStreams: this.options.maxParallelStreams,
97
110
  targetBatchSize: this.options.targetBatchSize,
98
111
  maxRetries: this.options.maxRetries,
112
+ getConnectedHeight: () => (this.spvChain.startBlockHeight === null
113
+ ? -Infinity
114
+ : this.spvChain.getTipHeight()),
99
115
  },
100
116
  this.createHistoricalSyncStream,
101
117
  this.createContinuousSyncStream,
@@ -159,10 +175,16 @@ class BlockHeadersProvider extends EventEmitter {
159
175
  this.blockHeadersReader
160
176
  .once(BlockHeadersReader.EVENTS.HISTORICAL_DATA_OBTAINED, this.historicalDataObtainedHandler);
161
177
 
162
- await this.blockHeadersReader.readHistorical(
163
- fromBlockHeight,
164
- toBlockHeight,
165
- );
178
+ try {
179
+ await this.blockHeadersReader.readHistorical(
180
+ fromBlockHeight,
181
+ toBlockHeight,
182
+ );
183
+ } catch (e) {
184
+ // init() adds them again on the next attempt
185
+ this.removeReaderListeners();
186
+ throw e;
187
+ }
166
188
 
167
189
  this.state = STATES.HISTORICAL_SYNC;
168
190
  this.emit(EVENTS.HISTORICAL_SYNC_STARTED);
@@ -175,7 +197,18 @@ class BlockHeadersProvider extends EventEmitter {
175
197
 
176
198
  await this.init();
177
199
  this.ensureChainRoot(fromBlockHeight);
178
- await this.blockHeadersReader.subscribeToNew(fromBlockHeight);
200
+ this.continuousLookback = 1;
201
+ this.continuousGapReported = false;
202
+ try {
203
+ await this.blockHeadersReader.subscribeToNew(
204
+ fromBlockHeight,
205
+ (retryError) => this.getContinuousRetryHeight(retryError),
206
+ );
207
+ } catch (e) {
208
+ // init() adds them again on the next attempt
209
+ this.removeReaderListeners();
210
+ throw e;
211
+ }
179
212
  this.state = STATES.CONTINUOUS_SYNC;
180
213
  this.emit(EVENTS.CONTINUOUS_SYNC_STARTED);
181
214
  }
@@ -225,22 +258,123 @@ class BlockHeadersProvider extends EventEmitter {
225
258
  // BlockHeadersReader might not correctly set startBlockHeight
226
259
  // which results in orphan chunks in SPV
227
260
 
261
+ let rejection = null;
262
+ let gapError = null;
228
263
  try {
229
- const headersAdded = this.spvChain.addHeaders(headers, headHeight);
230
-
231
- if (headersAdded.length) {
232
- // Calculate amount of removed headers in order to properly adjust head height
233
- // TODO(spv): move this logic to SpvChain?
234
- const difference = headers.length - headersAdded.length;
235
- this.emit(EVENTS.CHAIN_UPDATED, headersAdded, headHeight + difference);
264
+ // Headers that reach the main chain come back through chainConnectedHandler
265
+ this.spvChain.addHeaders(headers, headHeight);
266
+
267
+ if (this.state === STATES.CONTINUOUS_SYNC && this.spvChain.getOrphanChunks().length) {
268
+ // Their parents were never sent: ask again from further below the tip
269
+ this.spvChain.dropOrphans();
270
+ if (this.widenContinuousLookback()) {
271
+ gapError = new SPVError(`New headers don't connect to the chain even ${this.spvChain.confirmsBeforeFinal + 1} headers below its tip`);
272
+ }
273
+ rejection = new SPVError(`New headers don't connect to the chain, requesting them again from height ${this.getContinuousResumeHeight()}`);
236
274
  }
237
275
  } catch (e) {
238
- if (e instanceof SPVError) {
239
- reject(e);
240
- } else {
276
+ if (!(e instanceof SPVError)) {
241
277
  this.errorHandler(e);
278
+ return;
279
+ }
280
+ rejection = e;
281
+ if (this.state === STATES.CONTINUOUS_SYNC && this.isAtMaxLookback()) {
282
+ // Such as a reorg that would replace confirmed headers
283
+ gapError = e;
242
284
  }
243
285
  }
286
+
287
+ // Retry once, then report, outside the validation above
288
+ if (rejection) {
289
+ reject(rejection);
290
+ }
291
+ if (gapError) {
292
+ this.reportContinuousGap(gapError);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @private
298
+ * @param {BlockHeader[]} headers - the main chain from firstHeight on;
299
+ * after a reorg firstHeight is at or below the previous tip
300
+ * @param {number} firstHeight
301
+ */
302
+ chainConnectedHandler(headers, firstHeight) {
303
+ if (this.blockHeadersReader && typeof this.blockHeadersReader.chainAdvanced === 'function') {
304
+ this.blockHeadersReader.chainAdvanced();
305
+ }
306
+ this.continuousLookback = 1;
307
+ this.continuousGapReported = false;
308
+ this.emit(EVENTS.CHAIN_UPDATED, headers, firstHeight);
309
+ }
310
+
311
+ /**
312
+ * @private
313
+ * Continuous sync keeps retrying headers it can't add, but reports once,
314
+ * after looking back as far as it can, that it can't get back on the chain
315
+ * @param {Error} error
316
+ */
317
+ reportContinuousGap(error) {
318
+ if (!this.continuousGapReported && this.listenerCount(EVENTS.ERROR) > 0) {
319
+ this.continuousGapReported = true;
320
+ this.emit(EVENTS.ERROR, error);
321
+ }
322
+ }
323
+
324
+ /** @private */
325
+ isAtMaxLookback() {
326
+ return this.continuousLookback === this.spvChain.confirmsBeforeFinal + 1;
327
+ }
328
+
329
+ /**
330
+ * @private
331
+ * Makes continuous sync resubscribe from further below the tip
332
+ * @return {boolean} whether it already looked back as far as it can
333
+ */
334
+ widenContinuousLookback() {
335
+ const wasAtMax = this.isAtMaxLookback();
336
+ this.continuousLookback = Math.min(
337
+ this.continuousLookback * 2,
338
+ this.spvChain.confirmsBeforeFinal + 1,
339
+ );
340
+ return wasAtMax;
341
+ }
342
+
343
+ /**
344
+ * @private
345
+ * Height continuous sync resubscribes from after the stream failed
346
+ * @param {Error} [retryError] - the error that made the stream retry
347
+ * @return {number}
348
+ */
349
+ getContinuousRetryHeight(retryError) {
350
+ // DAPI has no block at the height asked for, as after a reorg to a
351
+ // shorter chain with more work; without headers arriving, only this
352
+ // moves the next request below the new tip
353
+ if (retryError && retryError.code === GrpcErrorCodes.NOT_FOUND
354
+ && this.state === STATES.CONTINUOUS_SYNC && this.spvChain.startBlockHeight !== null
355
+ && this.widenContinuousLookback()) {
356
+ const gapError = new Error(`DAPI has no block even ${this.spvChain.confirmsBeforeFinal + 1} headers below the chain tip: ${retryError.message}`);
357
+ // The stream schedules its retry after this returns: report once it
358
+ // has, so an error listener that throws can't stop the retry
359
+ setTimeout(() => {
360
+ if (this.state === STATES.CONTINUOUS_SYNC) {
361
+ this.reportContinuousGap(gapError);
362
+ }
363
+ }, 0);
364
+ }
365
+ return this.getContinuousResumeHeight();
366
+ }
367
+
368
+ /**
369
+ * @private
370
+ * @return {number}
371
+ */
372
+ getContinuousResumeHeight() {
373
+ if (this.spvChain.startBlockHeight === null) {
374
+ // No headers yet: wait for the first one where sync was asked to start
375
+ return this.spvChain.pendingStartBlockHeight || 1;
376
+ }
377
+ return Math.max(1, this.spvChain.getTipHeight() - this.continuousLookback + 1);
244
378
  }
245
379
 
246
380
  historicalDataObtainedHandler() {
@@ -15,6 +15,8 @@ const CLIENT_ALREADY_CLOSED_ERROR_MESSAGE = 'Client already closed - cannot .clo
15
15
  * @property {number} [maxParallelStreams]
16
16
  * @property {number} [targetBatchSize]
17
17
  * @property {number} [maxRetries]
18
+ * @property {function(): number} [getConnectedHeight] - height up to which
19
+ * headers joined the chain; bounds how far historical reads run ahead
18
20
  */
19
21
 
20
22
  class BlockHeadersReader extends EventEmitter {
@@ -32,13 +34,31 @@ class BlockHeadersReader extends EventEmitter {
32
34
  this.maxParallelStreams = options.maxParallelStreams;
33
35
  this.targetBatchSize = options.targetBatchSize;
34
36
  this.maxRetries = options.maxRetries;
37
+ this.getConnectedHeight = options.getConnectedHeight;
35
38
 
36
39
  /**
37
- * Holds references to the historical streams
40
+ * Holds references to the running historical streams, in batch order
38
41
  * @type {Stream[]}
39
42
  */
40
43
  this.historicalStreams = [];
41
44
 
45
+ /**
46
+ * Batches of the current historical read that started and haven't
47
+ * settled yet, in height order; null when not reading
48
+ * @type {{
49
+ * fromBlockHeight: number, count: number, stream: ?Stream, retry: ?Function, done: boolean
50
+ * }[]|null}
51
+ */
52
+ this.historicalBatches = null;
53
+
54
+ /**
55
+ * Where the current historical read's next batch starts, how large its
56
+ * batches are, and its last height
57
+ */
58
+ this.nextBatchHeight = 0;
59
+ this.historicalBatchSize = 0;
60
+ this.historicalToBlockHeight = 0;
61
+
42
62
  /**
43
63
  * Holds reference to the continuous sync stream
44
64
  * @type {Stream}
@@ -47,13 +67,17 @@ class BlockHeadersReader extends EventEmitter {
47
67
  }
48
68
 
49
69
  /**
50
- * Reads historical block heights using multiple streams
70
+ * Reads historical block headers in batches of about targetBatchSize, with
71
+ * up to maxParallelStreams batches in flight. A batch starts only within
72
+ * maxParallelStreams batches of the first one not yet settled: finished and,
73
+ * with getConnectedHeight, joined to the chain. So headers waiting ahead of
74
+ * the chain stay bounded.
51
75
  * @param {number} fromBlockHeight
52
76
  * @param {number} toBlockHeight
53
77
  * @returns {Promise<void>}
54
78
  */
55
79
  async readHistorical(fromBlockHeight, toBlockHeight) {
56
- if (this.historicalStreams.length) {
80
+ if (this.historicalBatches) {
57
81
  throw new Error('Historical streams are already running. Please stop them first.');
58
82
  }
59
83
 
@@ -66,37 +90,173 @@ class BlockHeadersReader extends EventEmitter {
66
90
  throw new Error(`Invalid total amount of headers to read: ${totalAmount}`);
67
91
  }
68
92
 
69
- const numStreams = Math.min(
70
- Math.max(Math.round(totalAmount / this.targetBatchSize), 1),
71
- this.maxParallelStreams,
72
- );
93
+ const batchCount = Math.max(Math.round(totalAmount / this.targetBatchSize), 1);
73
94
 
74
- const actualBatchSize = Math.ceil(totalAmount / numStreams);
95
+ // Batches are made as the window reaches them: toBlockHeight comes from
96
+ // a DAPI node, so the read must not allocate per batch up front
97
+ this.historicalBatches = [];
98
+ this.historicalBatchSize = Math.ceil(totalAmount / batchCount);
99
+ this.historicalToBlockHeight = toBlockHeight;
100
+ this.nextBatchHeight = fromBlockHeight;
75
101
 
76
- for (let batchIndex = 0; batchIndex < numStreams; batchIndex += 1) {
77
- const startingHeight = (batchIndex * actualBatchSize) + fromBlockHeight;
78
- const count = Math.min(actualBatchSize, toBlockHeight - startingHeight + 1);
102
+ try {
103
+ await this.startHistoricalBatches();
104
+ } catch (e) {
105
+ this.stopReadingHistorical();
106
+ throw e;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * @private
112
+ * Starts the batches the window allows
113
+ */
114
+ async startHistoricalBatches() {
115
+ const batches = this.historicalBatches;
116
+ while (batches === this.historicalBatches
117
+ && this.nextBatchHeight <= this.historicalToBlockHeight
118
+ && batches.length < this.maxParallelStreams) {
119
+ const batch = {
120
+ fromBlockHeight: this.nextBatchHeight,
121
+ count: Math.min(
122
+ this.historicalBatchSize,
123
+ this.historicalToBlockHeight - this.nextBatchHeight + 1,
124
+ ),
125
+ stream: null,
126
+ retry: null,
127
+ done: false,
128
+ };
129
+ batches.push(batch);
130
+ this.nextBatchHeight += batch.count;
79
131
 
80
- const subscribeWithRetries = this.createSubscribeToHistoricalBatch(this.maxRetries);
132
+ const subscribeWithRetries = this.createSubscribeToHistoricalBatch(this.maxRetries, batch);
81
133
  // eslint-disable-next-line no-await-in-loop
82
- const stream = await subscribeWithRetries(startingHeight, count);
83
- this.historicalStreams.push(stream);
134
+ const stream = await subscribeWithRetries(batch.fromBlockHeight, batch.count);
135
+ if (batches === this.historicalBatches) {
136
+ batch.stream = stream;
137
+ this.updateHistoricalStreams();
138
+ } else {
139
+ // Stopped while subscribing
140
+ this.cancelStream(stream);
141
+ }
84
142
  }
85
143
  }
86
144
 
87
- stopReadingHistorical() {
88
- while (this.historicalStreams.length) {
89
- const stream = this.historicalStreams.shift();
90
- this.cancelStream(stream);
145
+ /** @private */
146
+ updateHistoricalStreams() {
147
+ this.historicalStreams = (this.historicalBatches || [])
148
+ .filter(({ stream }) => stream)
149
+ .map(({ stream }) => stream);
150
+ }
151
+
152
+ /**
153
+ * @private
154
+ * @param {object} batch
155
+ * @param {Stream} stream
156
+ */
157
+ historicalBatchEnded(batch, stream) {
158
+ if (!batch) {
159
+ // A stream read on its own
160
+ this.emit(EVENTS.HISTORICAL_DATA_OBTAINED);
161
+ return;
91
162
  }
163
+
164
+ if (batch.stream !== stream || !this.historicalBatches) {
165
+ // Ended after a retry replaced it or reading stopped
166
+ return;
167
+ }
168
+
169
+ /* eslint-disable no-param-reassign */
170
+ batch.stream = null;
171
+ batch.done = true;
172
+ /* eslint-enable no-param-reassign */
173
+ this.updateHistoricalStreams();
174
+
175
+ const batches = this.historicalBatches;
176
+ if (this.nextBatchHeight > this.historicalToBlockHeight && batches.every(({ done }) => done)) {
177
+ // Every batch arrived; did those not settled yet all join the chain?
178
+ const connectedHeight = this.getConnectedHeight ? this.getConnectedHeight() : Infinity;
179
+ const unconnected = batches.find(({ fromBlockHeight, count }) => (
180
+ connectedHeight < fromBlockHeight + count - 1));
181
+ if (unconnected) {
182
+ this.stopReadingHistorical();
183
+ this.emit(EVENTS.ERROR, new Error(`Headers from height ${unconnected.fromBlockHeight} don't connect to the chain`));
184
+ return;
185
+ }
186
+ this.historicalBatches = null;
187
+ this.emit(EVENTS.HISTORICAL_DATA_OBTAINED);
188
+ return;
189
+ }
190
+
191
+ this.advanceHistoricalWindow();
192
+ }
193
+
194
+ /**
195
+ * Lets historical reads run further ahead once headers join the chain
196
+ */
197
+ chainAdvanced() {
198
+ if (this.historicalBatches) {
199
+ this.advanceHistoricalWindow();
200
+ }
201
+ }
202
+
203
+ /** @private */
204
+ advanceHistoricalWindow() {
205
+ const batches = this.historicalBatches;
206
+ const connectedHeight = this.getConnectedHeight ? this.getConnectedHeight() : Infinity;
207
+ // Retire the batches that arrived and joined the chain
208
+ while (batches.length) {
209
+ const { done, fromBlockHeight, count } = batches[0];
210
+ if (!done || connectedHeight < fromBlockHeight + count - 1) {
211
+ break;
212
+ }
213
+ batches.shift();
214
+ }
215
+
216
+ const windowFull = batches.length >= this.maxParallelStreams;
217
+ const inFlight = batches.some(({ done }) => !done);
218
+ if (windowFull && !inFlight) {
219
+ // Everything in the window arrived, but the first batch didn't connect
220
+ const { fromBlockHeight } = batches[0];
221
+ this.stopReadingHistorical();
222
+ this.emit(EVENTS.ERROR, new Error(`Headers from height ${fromBlockHeight} don't connect to the chain`));
223
+ return;
224
+ }
225
+
226
+ this.startHistoricalBatches().catch((e) => {
227
+ if (batches !== this.historicalBatches) {
228
+ // A later read replaced this one
229
+ return;
230
+ }
231
+ this.stopReadingHistorical();
232
+ this.emit(EVENTS.ERROR, e);
233
+ });
234
+ }
235
+
236
+ stopReadingHistorical() {
237
+ const streams = this.historicalStreams;
238
+ // Detach the batches, so retries in flight cancel the streams they open
239
+ (this.historicalBatches || []).forEach((batch) => {
240
+ /* eslint-disable no-param-reassign */
241
+ batch.stream = null;
242
+ batch.retry = null;
243
+ /* eslint-enable no-param-reassign */
244
+ });
245
+ this.historicalBatches = null;
246
+ this.historicalStreams = [];
247
+ streams.forEach((stream) => this.cancelStream(stream));
92
248
  }
93
249
 
94
250
  /**
95
251
  * Subscribes to continuously arriving block headers
96
252
  * @param {number} fromBlockHeight
253
+ * @param {function(Error=): number} [getResumeHeight] - height to resubscribe
254
+ * from after the stream drops or headers are rejected, given the error that
255
+ * caused the retry if there was one; by default the height of the last
256
+ * header received
97
257
  * @returns {Promise<ReconnectableStream>}
98
258
  */
99
- async subscribeToNew(fromBlockHeight) {
259
+ async subscribeToNew(fromBlockHeight, getResumeHeight) {
100
260
  if (this.continuousSyncStream) {
101
261
  throw new Error('Continuous sync has already been started');
102
262
  }
@@ -147,13 +307,14 @@ class BlockHeadersReader extends EventEmitter {
147
307
  }
148
308
  };
149
309
 
150
- const beforeReconnectHandler = (updateArguments) => {
310
+ const beforeReconnectHandler = (updateArguments, retryError) => {
311
+ const resumeHeight = getResumeHeight ? getResumeHeight(retryError) : lastKnownChainHeight;
151
312
  updateArguments({
152
- fromBlockHeight: lastKnownChainHeight,
313
+ fromBlockHeight: resumeHeight,
153
314
  count: 0,
154
315
  });
155
316
 
156
- lastKnownChainHeight -= 1;
317
+ lastKnownChainHeight = resumeHeight - 1;
157
318
  };
158
319
 
159
320
  const endHandler = () => {
@@ -189,9 +350,10 @@ class BlockHeadersReader extends EventEmitter {
189
350
  * and handles retry logic
190
351
  * @private
191
352
  * @param {number} [maxRetries] - maximum amount of retries
353
+ * @param {object} [batch] - the batch the stream reads, from readHistorical
192
354
  * @returns {function(*, *): Promise<Stream>}
193
355
  */
194
- createSubscribeToHistoricalBatch(maxRetries = 0) {
356
+ createSubscribeToHistoricalBatch(maxRetries = 0, batch = null) {
195
357
  let currentRetries = 0;
196
358
 
197
359
  /**
@@ -205,28 +367,64 @@ class BlockHeadersReader extends EventEmitter {
205
367
 
206
368
  const stream = await this.createHistoricalSyncStream(fromBlockHeight, count);
207
369
 
370
+ // Events of a stream its batch no longer reads from (it failed and a
371
+ // retry replaced it, reading stopped, or it already delivered its batch)
372
+ // are ignored. The reader detaches a stream before it cancels it, so a
373
+ // CANCELLED error from a current stream came from the server and is
374
+ // retried like any other.
375
+ let finished = false;
376
+ const isCurrent = () => !finished && (!batch || batch.stream === stream);
377
+
208
378
  const errorHandler = (streamError) => {
209
- if (streamError.code === GrpcErrorCodes.CANCELLED) {
210
- // TODO: consider reworking with COMMANDS instead
211
- // of producing a side effect that alters class state
212
- const index = this.historicalStreams.indexOf(stream);
213
- if (index >= 0) {
214
- this.historicalStreams.splice(index, 1);
215
- }
379
+ if (!isCurrent()) {
380
+ return;
381
+ }
382
+
383
+ if (headersObtained >= count) {
384
+ // It already delivered its whole batch
385
+ this.historicalBatchEnded(batch, stream);
386
+ return;
387
+ }
388
+
389
+ if (!batch && streamError.code === GrpcErrorCodes.CANCELLED) {
216
390
  return;
217
391
  }
218
392
 
219
393
  if (currentRetries < maxRetries) {
220
394
  const newFromBlockHeight = fromBlockHeight + headersObtained;
221
395
  const newCount = count - headersObtained;
396
+ currentRetries += 1;
397
+
398
+ // Detach the failed stream at once, so its late events can't end
399
+ // the batch while the retry connects
400
+ const retry = {};
401
+ if (batch) {
402
+ /* eslint-disable no-param-reassign */
403
+ batch.stream = null;
404
+ batch.retry = retry;
405
+ /* eslint-enable no-param-reassign */
406
+ this.updateHistoricalStreams();
407
+ }
222
408
 
223
- // TODO: do not retry in case newCount is zero
224
409
  subscribeWithRetries(newFromBlockHeight, newCount)
225
410
  .then((newStream) => {
226
- const index = this.historicalStreams.indexOf(stream);
227
- this.historicalStreams[index] = newStream;
228
- currentRetries += 1;
411
+ if (!batch) {
412
+ return;
413
+ }
414
+ if (batch.retry !== retry) {
415
+ // Reading stopped in the meantime
416
+ this.cancelStream(newStream);
417
+ return;
418
+ }
419
+ /* eslint-disable no-param-reassign */
420
+ batch.retry = null;
421
+ batch.stream = newStream;
422
+ /* eslint-enable no-param-reassign */
423
+ this.updateHistoricalStreams();
229
424
  }).catch((e) => {
425
+ if (batch && batch.retry !== retry) {
426
+ return;
427
+ }
230
428
  this.stopReadingHistorical();
231
429
  this.emit(EVENTS.ERROR, e);
232
430
  });
@@ -239,9 +437,16 @@ class BlockHeadersReader extends EventEmitter {
239
437
  const dataHandler = (data) => {
240
438
  const blockHeaders = data.getBlockHeaders();
241
439
 
242
- if (blockHeaders) {
243
- const headersList = blockHeaders.getHeadersList()
244
- .map((header) => new BlockHeader(Buffer.from(header)));
440
+ if (blockHeaders && isCurrent()) {
441
+ const rawHeaders = blockHeaders.getHeadersList();
442
+ if (headersObtained + rawHeaders.length > count) {
443
+ // Never take more than was asked for: the rest would wait in memory
444
+ errorHandler(new Error(`Stream sent more than the ${count} headers asked for`));
445
+ this.cancelStream(stream);
446
+ return;
447
+ }
448
+
449
+ const headersList = rawHeaders.map((header) => new BlockHeader(Buffer.from(header)));
245
450
 
246
451
  let rejected = false;
247
452
 
@@ -251,10 +456,9 @@ class BlockHeadersReader extends EventEmitter {
251
456
  */
252
457
  const rejectHeaders = (e) => {
253
458
  rejected = true;
254
- // Cancel stream and unsubscribe from all data events
255
- // because they are going th be re-created in errorHandler
256
- this.cancelStream(stream);
459
+ // Retry first, which detaches the stream, then cancel it
257
460
  errorHandler(e);
461
+ this.cancelStream(stream);
258
462
  };
259
463
 
260
464
  const batchHeadHeight = fromBlockHeight + headersObtained;
@@ -265,15 +469,27 @@ class BlockHeadersReader extends EventEmitter {
265
469
 
266
470
  if (!rejected) {
267
471
  headersObtained += headersList.length;
472
+
473
+ // Done without waiting for the server to end the stream: close it
474
+ // before releasing the batch's slot, so a server that never ends
475
+ // its streams can't keep more than the window open
476
+ if (headersObtained === count) {
477
+ finished = true;
478
+ this.cancelStream(stream);
479
+ this.historicalBatchEnded(batch, stream);
480
+ }
268
481
  }
269
482
  }
270
483
  };
271
484
 
272
485
  const endHandler = () => {
273
- const index = this.historicalStreams.indexOf(stream);
274
- this.historicalStreams.splice(index, 1);
275
- if (this.historicalStreams.length === 0) {
276
- this.emit(EVENTS.HISTORICAL_DATA_OBTAINED);
486
+ if (!isCurrent()) {
487
+ return;
488
+ }
489
+ if (headersObtained >= count) {
490
+ this.historicalBatchEnded(batch, stream);
491
+ } else {
492
+ errorHandler(new Error(`Stream ended after ${headersObtained} of ${count} headers`));
277
493
  }
278
494
  };
279
495
 
@@ -315,6 +531,10 @@ class BlockHeadersReader extends EventEmitter {
315
531
 
316
532
  cancelStream(stream) {
317
533
  stream.removeAllListeners();
534
+ // The CANCELLED error a cancelled stream emits must not go unheard
535
+ if (typeof stream.listenerCount === 'function' && stream.listenerCount('error') === 0) {
536
+ stream.on('error', () => {});
537
+ }
318
538
  this.cancelStreamTransport(stream);
319
539
  }
320
540
  }
@@ -1,5 +1,4 @@
1
1
  const sample = (arr) => arr[Math.floor(Math.random() * arr.length)];
2
- const networks = require('@dashevo/dashcore-lib/lib/networks');
3
2
 
4
3
  class ListDAPIAddressProvider {
5
4
  /**
@@ -20,28 +19,7 @@ class ListDAPIAddressProvider {
20
19
  * @returns {Promise<DAPIAddress|undefined>}
21
20
  */
22
21
  async getLiveAddress() {
23
- const liveAddresses = this.getLiveAddresses();
24
-
25
- const liveAddress = sample(liveAddresses);
26
-
27
- if (liveAddress === undefined) {
28
- return undefined;
29
- }
30
-
31
- // This is a temporary fix for a localhost masternode.
32
- // On macOS, internal docker IP is used to register masternode, and it's
33
- // not really possible to bind to that address, so that workaround is introduced.
34
- const network = networks.get(this.options.network);
35
- if (network && network.regtestEnabled) {
36
- const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length);
37
-
38
- liveAddress.protocol = 'https';
39
- liveAddress.host = '127.0.0.1';
40
- liveAddress.allowSelfSignedCertificate = true;
41
- liveAddress.port = 2443 + randomNodeIndex * 100;
42
- }
43
-
44
- return liveAddress;
22
+ return sample(this.getLiveAddresses());
45
23
  }
46
24
 
47
25
  /**
@@ -1,3 +1,5 @@
1
+ const networks = require('@dashevo/dashcore-lib/lib/networks');
2
+
1
3
  const DAPIAddress = require('./DAPIAddress');
2
4
 
3
5
  class SimplifiedMasternodeListDAPIAddressProvider {
@@ -5,9 +7,11 @@ class SimplifiedMasternodeListDAPIAddressProvider {
5
7
  * @param {SimplifiedMasternodeListProvider} smlProvider
6
8
  * @param {ListDAPIAddressProvider} listDAPIAddressProvider
7
9
  * @param {DAPIAddress[]} addressWhiteList
10
+ * @param {DAPIClientOptions} [options]
8
11
  */
9
- constructor(smlProvider, listDAPIAddressProvider, addressWhiteList) {
12
+ constructor(smlProvider, listDAPIAddressProvider, addressWhiteList, options = {}) {
10
13
  this.smlProvider = smlProvider;
14
+ this.options = options;
11
15
  this.listDAPIAddressProvider = listDAPIAddressProvider;
12
16
  this.addressWhiteStrings = addressWhiteList.map((dapiAddress) => dapiAddress.toString());
13
17
  }
@@ -58,6 +62,18 @@ class SimplifiedMasternodeListDAPIAddressProvider {
58
62
  ));
59
63
  }
60
64
 
65
+ // Local (regtest) masternodes register a docker-internal IP that is not
66
+ // reachable from the host, while their gateways are published on the host's
67
+ // loopback at the registered port with a self-signed certificate.
68
+ const network = networks.get(this.options.network);
69
+ if (network && network.regtestEnabled) {
70
+ filteredAddresses.forEach((address) => {
71
+ address.setHost('127.0.0.1');
72
+ // eslint-disable-next-line no-param-reassign
73
+ address.allowSelfSignedCertificate = true;
74
+ });
75
+ }
76
+
61
77
  this.listDAPIAddressProvider.setAddresses(filteredAddresses);
62
78
 
63
79
  return this.listDAPIAddressProvider.getLiveAddress();
@@ -93,6 +93,7 @@ function createDAPIAddressProviderFromOptions(options) {
93
93
  smlProvider,
94
94
  listDAPIAddressProvider,
95
95
  dapiAddressesWhiteList.map((rawAddress) => new DAPIAddress(rawAddress)),
96
+ options,
96
97
  );
97
98
  }
98
99
 
@@ -59,7 +59,7 @@ module.exports = {
59
59
  ],
60
60
  },
61
61
  local: {
62
- dapiAddresses: ['127.0.0.1'],
62
+ dapiAddresses: ['127.0.0.1:2443:self-signed'],
63
63
  network: 'regtest',
64
64
  },
65
65
  mainnet: {
@@ -21,11 +21,11 @@ class BlockHeadersWithChainLocksStreamMock extends EventEmitter {
21
21
  }
22
22
  }
23
23
 
24
- emit(event, data) {
24
+ emit(event, ...args) {
25
25
  if (event === 'error') {
26
26
  this.errored = true;
27
27
  }
28
- super.emit(event, data);
28
+ super.emit(event, ...args);
29
29
  }
30
30
 
31
31
  /**
@@ -256,8 +256,9 @@ class ReconnectableStream extends EventEmitter {
256
256
  newArgs = args;
257
257
  };
258
258
 
259
- // Emit beforeReconnect event in case parent would want to alter arguments
260
- this.emit(EVENTS.BEFORE_RECONNECT, updateArgsBeforeReconnect);
259
+ // Emit beforeReconnect event in case parent would want to alter arguments,
260
+ // with the error that caused the retry
261
+ this.emit(EVENTS.BEFORE_RECONNECT, updateArgsBeforeReconnect, e);
261
262
 
262
263
  // Wait before reconnecting
263
264
  wait(this.retryOnErrorDelay)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dashevo/dapi-client",
3
- "version": "5.0.3",
3
+ "version": "5.1.0-dev.1",
4
4
  "description": "Client library used to access Dash DAPI endpoints",
5
5
  "main": "lib/index.js",
6
6
  "contributors": [
@@ -26,11 +26,11 @@
26
26
  }
27
27
  ],
28
28
  "dependencies": {
29
- "@dashevo/dapi-grpc": "5.0.3",
30
- "@dashevo/dash-spv": "6.0.3",
29
+ "@dashevo/dapi-grpc": "5.1.0-dev.1",
30
+ "@dashevo/dash-spv": "6.1.0-dev.1",
31
31
  "@dashevo/dashcore-lib": "~0.26.1",
32
- "@dashevo/grpc-common": "5.0.3",
33
- "@dashevo/wasm-dpp": "5.0.3",
32
+ "@dashevo/grpc-common": "5.1.0-dev.1",
33
+ "@dashevo/wasm-dpp": "5.1.0-dev.1",
34
34
  "cbor": "^8.0.0",
35
35
  "google-protobuf": "^3.12.2",
36
36
  "undici": "^6.28.1",
@@ -72,7 +72,7 @@
72
72
  "webpack-cli": "^4.9.1"
73
73
  },
74
74
  "engines": {
75
- "node": ">=18.18"
75
+ "node": ">=22"
76
76
  },
77
77
  "files": [
78
78
  "docs",