diodejs 0.4.4 → 0.4.5

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/bindPort.js CHANGED
@@ -403,7 +403,7 @@ class BindPort {
403
403
  logger.info(() => `Port closed for ref: ${dataRef.toString('hex')}`);
404
404
  }
405
405
  } else {
406
- if (messageType != 'portopen' && messageType != 'portopen2' && messageType != 'ticket_request') {
406
+ if (messageType != 'portopen' && messageType != 'portopen2' && messageType != 'ticket_request' && messageType != 'response') {
407
407
  logger.warn(() => `Unknown unsolicited message type: ${messageType}`);
408
408
  }
409
409
  }
@@ -904,9 +904,19 @@ class BindPort {
904
904
  connection.addClientSocket(ref, clientSocket);
905
905
 
906
906
  // Handle data from client to device
907
- clientSocket.on('data', async (data) => {
907
+ clientSocket.on('data', (data) => {
908
+ clientSocket.pause();
908
909
  try {
909
- await rpc.portSend(ref, data);
910
+ rpc.portSend(ref, data)
911
+ .then(() => {
912
+ if (!clientSocket.destroyed) {
913
+ clientSocket.resume();
914
+ }
915
+ })
916
+ .catch((error) => {
917
+ logger.error(() => `Error sending data to device: ${error}`);
918
+ clientSocket.destroy();
919
+ });
910
920
  } catch (error) {
911
921
  logger.error(() => `Error sending data to device: ${error}`);
912
922
  clientSocket.destroy();
package/connection.js CHANGED
@@ -91,9 +91,13 @@ class DiodeConnection extends EventEmitter {
91
91
  // Add ticket batching configuration
92
92
  this.lastTicketUpdate = Date.now();
93
93
  this.accumulatedBytes = 0;
94
- this.ticketUpdateThreshold = parseInt(process.env.DIODE_TICKET_BYTES_THRESHOLD, 10) || 512000; // 512KB default
94
+ this.ticketUpdateThreshold = parseInt(process.env.DIODE_TICKET_BYTES_THRESHOLD, 10) || 4 * 1024 * 1024; // 4MB default
95
95
  this.ticketUpdateInterval = parseInt(process.env.DIODE_TICKET_UPDATE_INTERVAL, 10) || 30000; // 30 seconds default
96
96
  this.ticketUpdateTimer = null;
97
+ this.ticketUpdateInFlight = false;
98
+ this.pendingTicketUpdateForce = false;
99
+ this.lastAcceptedTicketBytes = 0;
100
+ this.lastRelayMeasuredBytes = 0;
97
101
 
98
102
  // Log the ticket batching settings
99
103
  logger.info(() => `Ticket batching settings - Bytes Threshold: ${this.ticketUpdateThreshold} bytes, Update Interval: ${this.ticketUpdateInterval}ms`);
@@ -144,6 +148,7 @@ class DiodeConnection extends EventEmitter {
144
148
  this._startTicketUpdateTimer();
145
149
  // Send the ticketv2 command
146
150
  try {
151
+ await this._syncMeasuredBytesWithRelay();
147
152
  const ticketCommand = await this.createTicketCommand();
148
153
  const response = await this.sendCommand(ticketCommand).catch(reject);
149
154
  resolve();
@@ -155,6 +160,7 @@ class DiodeConnection extends EventEmitter {
155
160
 
156
161
  this.socket.on('data', (data) => {
157
162
  // logger.debug(() => `Received data: ${data.toString('hex')}`);
163
+ this._recordTrafficBytes(data.length);
158
164
  try {
159
165
  this._handleData(data);
160
166
  } catch (error) {
@@ -371,17 +377,35 @@ class DiodeConnection extends EventEmitter {
371
377
  logger.debug(() => `Received response for requestId: ${requestId}`);
372
378
  logger.debug(() => `Response Type: '${responseType}'`);
373
379
 
374
- const { resolve, reject } = this.pendingRequests.get(requestId);
380
+ const pending = this.pendingRequests.get(requestId);
381
+ const { resolve, reject } = pending;
375
382
  try{
376
383
  if (responseType === 'response') {
377
384
  if (!Array.isArray(responseRaw) && makeReadable(responseRaw) === 'too_low') {
378
- this.fixResponse(responseData);
379
- // Re-send the ticket command
380
- this.createTicketCommand().then((ticketCommand) => {
381
- this.sendCommand(ticketCommand).then(resolve).catch(reject);
382
- }).catch(reject);
385
+ const originalCommand = pending && pending.commandArray;
386
+ const retryCount = pending && pending.ticketRetryCount ? pending.ticketRetryCount : 0;
387
+ const isTicketCommand = Array.isArray(originalCommand) &&
388
+ (originalCommand[0] === 'ticket' || originalCommand[0] === 'ticketv2');
389
+
390
+ if (isTicketCommand && retryCount < 1) {
391
+ this.fixResponse(responseData);
392
+ this.pendingRequests.delete(requestId);
393
+ this._syncMeasuredBytesWithRelay()
394
+ .catch((error) => {
395
+ logger.debug(() => `Unable to sync relay measured bytes after too_low: ${error}`);
396
+ })
397
+ .then(() => this.createTicketCommand())
398
+ .then((ticketCommand) => this.sendCommand(ticketCommand, { ticketRetryCount: retryCount + 1 }))
399
+ .then(resolve)
400
+ .catch(reject);
401
+ return;
402
+ }
403
+ this._recordTicketResponse(originalCommand, responseData);
383
404
  resolve(responseData);
405
+ this.pendingRequests.delete(requestId);
406
+ return;
384
407
  }
408
+ this._recordTicketResponse(pending && pending.commandArray, responseData);
385
409
  resolve(responseData);
386
410
  } else if (responseType === 'error') {
387
411
  if (responseData.length > 1) {
@@ -433,11 +457,23 @@ class DiodeConnection extends EventEmitter {
433
457
  }
434
458
 
435
459
  // Send a fresh ticket promptly to avoid disconnect
436
- this.createTicketCommand()
437
- .then((ticketCommand) => this.sendCommand(ticketCommand))
438
- .then(() => {
439
- this.accumulatedBytes = 0;
440
- this.lastTicketUpdate = Date.now();
460
+ this._syncMeasuredBytesWithRelay()
461
+ .catch((error) => {
462
+ logger.debug(() => `Unable to sync relay measured bytes for ticket_request: ${error}`);
463
+ })
464
+ .then(() => this.createTicketCommand())
465
+ .then((ticketCommand) => {
466
+ const ticketTotalBytes = parseUInt(ticketCommand[5]);
467
+ return this.sendCommand(ticketCommand).then((responseData) => ({ responseData, ticketTotalBytes }));
468
+ })
469
+ .then(({ responseData, ticketTotalBytes }) => {
470
+ const status = responseData && responseData[0] !== undefined ? parseResponseType(responseData[0]) : '';
471
+ if (status === 'thanks!') {
472
+ if (Number.isFinite(ticketTotalBytes)) {
473
+ this.accumulatedBytes = Math.max(0, this.totalBytes - ticketTotalBytes);
474
+ }
475
+ this.lastTicketUpdate = Date.now();
476
+ }
441
477
  })
442
478
  .catch((error) => {
443
479
  logger.error(() => `Error handling ticket_request: ${error}`);
@@ -446,26 +482,141 @@ class DiodeConnection extends EventEmitter {
446
482
  }
447
483
 
448
484
  fixResponse(response) {
449
- /* response is :
450
- [
451
- 'too_low',
452
- 1284,
453
- 666,
454
- 11,
455
- 135591,
456
- 'test',
457
- '0x01eb1726dd7286d2dab222ea5dfef7c820cd01c30936240f5780a6e468e731f3b55d4c963b3eb768663263b396555aa52be49d7d3ae2a9173732fa410ad46434f3'
458
- ]
459
- [3] is last totalConnections
460
- [4] is last totalBytes
485
+ /*
486
+ ticketv2 too_low responses are:
487
+ [
488
+ 'too_low',
489
+ chain_id,
490
+ epoch,
491
+ last_total_connections,
492
+ last_total_bytes,
493
+ local_address,
494
+ device_signature
495
+ ]
496
+ The byte value is the relay's last accepted paid floor. The live
497
+ unpaid measurement still comes from the relay "bytes" command.
461
498
  */
462
- const totalConnectionsBuffer = Buffer.from(response[3]);
463
- const totalBytesBuffer = Buffer.from(response[4]);
464
- this.totalConnections = parseInt(totalConnectionsBuffer.readUIntBE(0, totalConnectionsBuffer.length), 10) +1;
465
- this.totalBytes = parseInt(totalBytesBuffer.readUIntBE(0, totalBytesBuffer.length), 10) + 128000;
499
+ const lastTicket = this._parseTooLowTicketSummary(response);
500
+ if (Number.isFinite(lastTicket.totalConnections)) {
501
+ this.totalConnections = Math.max(this.totalConnections, lastTicket.totalConnections);
502
+ }
503
+ if (Number.isFinite(lastTicket.totalBytes)) {
504
+ this.lastAcceptedTicketBytes = Math.max(this.lastAcceptedTicketBytes, lastTicket.totalBytes);
505
+ const relayMeasuredBytes = Number.isFinite(this.lastRelayMeasuredBytes) ? this.lastRelayMeasuredBytes : 0;
506
+ const pendingBytes = Math.max(this.accumulatedBytes, relayMeasuredBytes, 0);
507
+ this.totalBytes = Math.max(this.totalBytes, lastTicket.totalBytes + pendingBytes + 1024);
508
+ this.accumulatedBytes = Math.max(this.accumulatedBytes, this.totalBytes - lastTicket.totalBytes);
509
+ }
510
+ }
511
+
512
+ _parseTooLowTicketSummary(response) {
513
+ if (!Array.isArray(response)) {
514
+ return { totalConnections: null, totalBytes: null };
515
+ }
516
+
517
+ const firstItem = response[0];
518
+ const firstItemType = typeof firstItem === 'string' || Buffer.isBuffer(firstItem) || firstItem instanceof Uint8Array
519
+ ? parseResponseType(firstItem)
520
+ : '';
521
+ let normalized = response;
522
+ if (firstItemType === 'response') {
523
+ const secondItem = response[1];
524
+ const secondItemType = typeof secondItem === 'string' || Buffer.isBuffer(secondItem) || secondItem instanceof Uint8Array
525
+ ? parseResponseType(secondItem)
526
+ : '';
527
+ normalized = secondItemType === 'too_low' ? response.slice(2) : response;
528
+ } else if (firstItemType === 'too_low') {
529
+ normalized = response.slice(1);
530
+ }
531
+
532
+ if (normalized.length >= 6) {
533
+ return {
534
+ version: 2,
535
+ chainId: parseUInt(normalized[0]),
536
+ epoch: parseUInt(normalized[1]),
537
+ totalConnections: parseUInt(normalized[2]),
538
+ totalBytes: parseUInt(normalized[3]),
539
+ localAddress: normalized[4],
540
+ deviceSignature: normalized[5],
541
+ };
542
+ }
543
+
544
+ if (normalized.length >= 5) {
545
+ return {
546
+ version: 1,
547
+ blockHash: normalized[0],
548
+ totalConnections: parseUInt(normalized[1]),
549
+ totalBytes: parseUInt(normalized[2]),
550
+ localAddress: normalized[3],
551
+ deviceSignature: normalized[4],
552
+ };
553
+ }
554
+
555
+ return { totalConnections: null, totalBytes: null };
556
+ }
557
+
558
+ _isTicketCommand(commandArray) {
559
+ return Array.isArray(commandArray) &&
560
+ (commandArray[0] === 'ticket' || commandArray[0] === 'ticketv2');
561
+ }
562
+
563
+ _ticketTotalBytes(commandArray) {
564
+ if (!this._isTicketCommand(commandArray)) return null;
565
+ const index = commandArray[0] === 'ticketv2' ? 5 : 4;
566
+ return parseUInt(commandArray[index]);
567
+ }
568
+
569
+ _recordTicketResponse(commandArray, responseData) {
570
+ if (!this._isTicketCommand(commandArray) || !Array.isArray(responseData)) return;
571
+ const status = responseData[0] !== undefined ? parseResponseType(responseData[0]) : '';
572
+ if (status !== 'thanks!') return;
573
+ const ticketTotalBytes = this._ticketTotalBytes(commandArray);
574
+ if (!Number.isFinite(ticketTotalBytes)) return;
575
+ this.lastAcceptedTicketBytes = Math.max(this.lastAcceptedTicketBytes, ticketTotalBytes);
576
+ this.accumulatedBytes = Math.max(0, this.totalBytes - ticketTotalBytes);
577
+ this.lastTicketUpdate = Date.now();
578
+ }
579
+
580
+ _recordTrafficBytes(bytesCount) {
581
+ if (!Number.isFinite(bytesCount) || bytesCount <= 0) return;
582
+ this.totalBytes += bytesCount;
583
+ this.accumulatedBytes += bytesCount;
584
+
585
+ if (this.accumulatedBytes >= this.ticketUpdateThreshold) {
586
+ this._updateTicketIfNeeded();
587
+ }
588
+ }
589
+
590
+ _parseRelaySignedInt(valueRaw) {
591
+ const encoded = parseUInt(valueRaw);
592
+ if (!Number.isFinite(encoded)) return null;
593
+ if (encoded % 2 === 0) return encoded / 2;
594
+ return -((encoded - 1) / 2);
595
+ }
596
+
597
+ async _syncMeasuredBytesWithRelay() {
598
+ if (!this.socket || this.socket.destroyed) return null;
599
+ const responseData = await this.sendCommand(['bytes']);
600
+ const measuredBytes = responseData && responseData[0] !== undefined
601
+ ? this._parseRelaySignedInt(responseData[0])
602
+ : null;
603
+ if (!Number.isFinite(measuredBytes) || measuredBytes <= 0) {
604
+ return measuredBytes;
605
+ }
606
+
607
+ this.lastRelayMeasuredBytes = measuredBytes;
608
+ const baseBytes = Number.isFinite(this.lastAcceptedTicketBytes) && this.lastAcceptedTicketBytes > 0
609
+ ? this.lastAcceptedTicketBytes
610
+ : 128000;
611
+ const targetTotalBytes = baseBytes + measuredBytes + 1024;
612
+ if (targetTotalBytes > this.totalBytes) {
613
+ this.totalBytes = targetTotalBytes;
614
+ this.accumulatedBytes = Math.max(this.accumulatedBytes, this.totalBytes - baseBytes);
615
+ }
616
+ return measuredBytes;
466
617
  }
467
618
 
468
- sendCommand(commandArray) {
619
+ sendCommand(commandArray, options = {}) {
469
620
  return new Promise((resolve, reject) => {
470
621
  this._ensureConnected().then(() => {
471
622
  const requestId = this._getNextRequestId();
@@ -473,7 +624,12 @@ class DiodeConnection extends EventEmitter {
473
624
  const commandWithId = [requestId, commandArray];
474
625
 
475
626
  // Store the promise callbacks to resolve/reject later
476
- this.pendingRequests.set(requestId, { resolve, reject });
627
+ this.pendingRequests.set(requestId, {
628
+ resolve,
629
+ reject,
630
+ commandArray,
631
+ ticketRetryCount: options.ticketRetryCount || 0,
632
+ });
477
633
 
478
634
  const commandBuffer = RLP.encode(commandWithId);
479
635
  const byteLength = commandBuffer.length; // Buffer/Uint8Array length is bytes
@@ -488,6 +644,7 @@ class DiodeConnection extends EventEmitter {
488
644
  // logger.debug(() => `Command buffer: ${message.toString('hex')}`);
489
645
 
490
646
  this.socket.write(message);
647
+ this._recordTrafficBytes(message.length);
491
648
  }).catch(reject);
492
649
  });
493
650
  }
@@ -499,9 +656,6 @@ class DiodeConnection extends EventEmitter {
499
656
  // Build the message as [requestId, [commandArray]]
500
657
  const commandWithId = [requestId, commandArray];
501
658
 
502
- // Store the promise callbacks to resolve/reject later
503
- this.pendingRequests.set(requestId, { resolve, reject });
504
-
505
659
  const commandBuffer = RLP.encode(commandWithId);
506
660
  const byteLength = commandBuffer.length; // Buffer/Uint8Array length is bytes
507
661
 
@@ -514,8 +668,8 @@ class DiodeConnection extends EventEmitter {
514
668
  logger.debug(() => `Sending command with requestId ${requestId}: ${commandArray}`);
515
669
  // logger.debug(() => `Command buffer: ${message.toString('hex')}`);
516
670
 
517
- this.socket.write(message);
518
- resolve();
671
+ this.socket.write(message, resolve);
672
+ this._recordTrafficBytes(message.length);
519
673
  }).catch(reject);
520
674
  });
521
675
  }
@@ -765,7 +919,7 @@ class DiodeConnection extends EventEmitter {
765
919
  }
766
920
 
767
921
  this.ticketUpdateTimer = setTimeout(() => {
768
- this._updateTicketIfNeeded(true);
922
+ this._updateTicketIfNeeded();
769
923
  }, this.ticketUpdateInterval);
770
924
  }
771
925
 
@@ -775,6 +929,11 @@ class DiodeConnection extends EventEmitter {
775
929
  if (!this.socket || this.socket.destroyed) {
776
930
  return;
777
931
  }
932
+
933
+ if (this.ticketUpdateInFlight) {
934
+ this.pendingTicketUpdateForce = this.pendingTicketUpdateForce || force;
935
+ return;
936
+ }
778
937
 
779
938
  const timeSinceLastUpdate = Date.now() - this.lastTicketUpdate;
780
939
 
@@ -783,18 +942,38 @@ class DiodeConnection extends EventEmitter {
783
942
  (this.accumulatedBytes >= this.ticketUpdateThreshold ||
784
943
  timeSinceLastUpdate >= this.ticketUpdateInterval))) {
785
944
 
945
+ this.ticketUpdateInFlight = true;
946
+ let accepted = false;
786
947
  try {
787
- if (this.accumulatedBytes > 0 || force) {
788
- logger.debug(() => `Updating ticket: accumulated ${this.accumulatedBytes} bytes, ${timeSinceLastUpdate}ms since last update`);
789
- const ticketCommand = await this.createTicketCommand();
790
- await this.sendCommand(ticketCommand);
791
-
792
- // Reset counters
793
- this.accumulatedBytes = 0;
794
- this.lastTicketUpdate = Date.now();
795
- }
948
+ if (this.accumulatedBytes > 0 || force) {
949
+ logger.debug(() => `Updating ticket: accumulated ${this.accumulatedBytes} bytes, ${timeSinceLastUpdate}ms since last update`);
950
+ await this._syncMeasuredBytesWithRelay().catch((error) => {
951
+ logger.debug(() => `Unable to sync relay measured bytes before ticket update: ${error}`);
952
+ });
953
+ const ticketCommand = await this.createTicketCommand();
954
+ const ticketTotalBytes = parseUInt(ticketCommand[5]);
955
+ const responseData = await this.sendCommand(ticketCommand);
956
+ const status = responseData && responseData[0] !== undefined ? parseResponseType(responseData[0]) : '';
957
+ if (status === 'thanks!') {
958
+ accepted = true;
959
+ if (Number.isFinite(ticketTotalBytes)) {
960
+ this.accumulatedBytes = Math.max(0, this.totalBytes - ticketTotalBytes);
961
+ }
962
+ this.lastTicketUpdate = Date.now();
963
+ }
964
+ }
796
965
  } catch (error) {
797
- logger.error(() => `Error updating ticket: ${error}`);
966
+ logger.error(() => `Error updating ticket: ${error}`);
967
+ } finally {
968
+ this.ticketUpdateInFlight = false;
969
+ }
970
+
971
+ if (accepted && (this.pendingTicketUpdateForce || this.accumulatedBytes >= this.ticketUpdateThreshold)) {
972
+ const pendingForce = this.pendingTicketUpdateForce;
973
+ this.pendingTicketUpdateForce = false;
974
+ setImmediate(() => this._updateTicketIfNeeded(pendingForce));
975
+ } else {
976
+ this.pendingTicketUpdateForce = false;
798
977
  }
799
978
  }
800
979
 
@@ -804,13 +983,7 @@ class DiodeConnection extends EventEmitter {
804
983
 
805
984
  // Add method to track bytes without immediate ticket update
806
985
  addBytes(bytesCount) {
807
- this.totalBytes += bytesCount;
808
- this.accumulatedBytes += bytesCount;
809
-
810
- // Optionally check if we should update ticket now
811
- if (this.accumulatedBytes >= this.ticketUpdateThreshold) {
812
- this._updateTicketIfNeeded();
813
- }
986
+ this._recordTrafficBytes(bytesCount);
814
987
  }
815
988
 
816
989
  // Method to set ticket batching options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diodejs",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "A JavaScript client for interacting with the Diode network. It provides functionalities to bind and publish ports, send RPC commands, and handle responses.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/publishPort.js CHANGED
@@ -231,7 +231,7 @@ class PublishPort extends EventEmitter {
231
231
  } else if (messageType === 'portclose') {
232
232
  this.handlePortClose(sessionIdRaw, messageContent, connection);
233
233
  } else {
234
- if (messageType !== 'ticket_request') {
234
+ if (messageType !== 'ticket_request' && messageType !== 'response') {
235
235
  logger.warn(() => `Unknown unsolicited message type: ${messageType}`);
236
236
  }
237
237
  }
@@ -736,7 +736,17 @@ class PublishPort extends EventEmitter {
736
736
  } else {
737
737
  localSocket.on('data', (data) => {
738
738
  // When data is received from the local service, send it back via Diode
739
- rpc.portSend(ref, data);
739
+ localSocket.pause();
740
+ rpc.portSend(ref, data)
741
+ .then(() => {
742
+ if (!localSocket.destroyed) {
743
+ localSocket.resume();
744
+ }
745
+ })
746
+ .catch((error) => {
747
+ logger.error(() => `Error sending data to device: ${error}`);
748
+ localSocket.destroy();
749
+ });
740
750
  });
741
751
 
742
752
  localSocket.on('end', () => {
package/rpc.js CHANGED
@@ -1,5 +1,12 @@
1
1
  //rpc.js
2
- const { makeReadable, parseRequestId, parseResponseType, parseReason, parseUInt, toBufferView } = require('./utils');
2
+ const {
3
+ makeReadable,
4
+ parseRequestId,
5
+ parseResponseType,
6
+ parseReason,
7
+ parseUInt,
8
+ toBufferView,
9
+ } = require('./utils');
3
10
  const logger = require('./logger');
4
11
 
5
12
  function decodeToken(value) {
@@ -89,6 +96,80 @@ class DiodeRPC {
89
96
  expiry: null,
90
97
  };
91
98
  }
99
+
100
+ async dioTraffic(options = {}) {
101
+ const chainId = options.chainId === undefined ? 1284 : options.chainId;
102
+ const params = options.epoch === undefined ? [chainId] : [chainId, options.epoch];
103
+
104
+ return this.nodeRpc('dio_traffic', params, options);
105
+ }
106
+
107
+ dio_traffic(options = {}) {
108
+ return this.dioTraffic(options);
109
+ }
110
+
111
+ async nodeRpc(method, params = [], options = {}) {
112
+ if (typeof fetch !== 'function') {
113
+ throw new Error('global fetch is required for node RPC calls');
114
+ }
115
+
116
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
117
+ ? Math.floor(options.timeoutMs)
118
+ : 30000;
119
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
120
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
121
+
122
+ try {
123
+ const response = await fetch(this._nodeRpcEndpoint(options), {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify({
127
+ jsonrpc: '2.0',
128
+ id: options.id || 1,
129
+ method,
130
+ params,
131
+ }),
132
+ signal: controller ? controller.signal : undefined,
133
+ });
134
+
135
+ let payload = {};
136
+ try {
137
+ payload = await response.json();
138
+ } catch (_) {
139
+ payload = {};
140
+ }
141
+
142
+ if (!response.ok || payload.error) {
143
+ const error = new Error(payload?.error?.message || `${method} failed with status ${response.status}`);
144
+ error.status = response.status;
145
+ error.response = payload;
146
+ throw error;
147
+ }
148
+
149
+ return payload.result;
150
+ } finally {
151
+ if (timer) {
152
+ clearTimeout(timer);
153
+ }
154
+ }
155
+ }
156
+
157
+ _nodeRpcEndpoint(options = {}) {
158
+ const protocol = options.protocol || 'https';
159
+ const rpcPort = Number.isFinite(options.rpcPort) && options.rpcPort > 0 ? options.rpcPort : 8443;
160
+ let host = options.host || '';
161
+ if (!host && this.connection) {
162
+ host = this.connection.host;
163
+ }
164
+ if (!host && this.connection && typeof this.connection.getServerRelayHost === 'function') {
165
+ host = this.connection.getServerRelayHost();
166
+ }
167
+ if (!host) {
168
+ throw new Error('No relay host available for node RPC call');
169
+ }
170
+ const formattedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
171
+ return `${protocol}://${formattedHost}:${rpcPort}/`;
172
+ }
92
173
 
93
174
  getBlockPeak() {
94
175
  return this.connection.sendCommand(['getblockpeak']).then((responseData) => {
@@ -223,10 +304,6 @@ class DiodeRPC {
223
304
  }
224
305
 
225
306
  async portSend(ref, data) {
226
- // Update bytes count but don't update ticket yet
227
- const bytesToSend = data.length;
228
- this.connection.addBytes(bytesToSend);
229
-
230
307
  // Maximum size that can be sent in a single message (less than 65535 to be safe)
231
308
  const MAX_CHUNK_SIZE = 65000;
232
309
 
@@ -240,3 +240,50 @@ test('API bind closes ref when local TCP client disconnects after portopen retur
240
240
  server.close();
241
241
  }
242
242
  });
243
+
244
+ test('API bind pauses local TCP socket while portSend is in flight', async () => {
245
+ const calls = [];
246
+ const send = deferred();
247
+ const ref = makeRef('12131415');
248
+ const relay = makeRelay('relay.example:41046', ref, calls);
249
+ const portSendCalls = [];
250
+ relay.RPC.portSend = async (...args) => {
251
+ portSendCalls.push(args);
252
+ return send.promise;
253
+ };
254
+ const manager = new FakeManager({
255
+ relays: [relay],
256
+ resolvedRelay: relay,
257
+ nearestRelay: relay,
258
+ });
259
+
260
+ const bind = new BindPort(manager, {
261
+ 0: {
262
+ targetPort: 8088,
263
+ deviceIdHex: '8a72468957504d50247a260deb0218d504dd091b',
264
+ protocol: 'tcp',
265
+ }
266
+ });
267
+ bind.addPort(0, 8088, '8a72468957504d50247a260deb0218d504dd091b', 'tcp');
268
+ const server = bind.servers.get(0);
269
+
270
+ try {
271
+ await once(server, 'listening');
272
+ const client = net.connect(server.address().port, '127.0.0.1');
273
+ await once(client, 'connect');
274
+ await waitFor(() => relay.hasClientSocket(ref));
275
+
276
+ client.write(Buffer.from('hello'));
277
+ await waitFor(() => portSendCalls.length === 1);
278
+
279
+ const acceptedSocket = relay.getClientSocket(ref);
280
+ assert.equal(acceptedSocket.isPaused(), true);
281
+
282
+ send.resolve();
283
+ await waitFor(() => acceptedSocket.isPaused() === false);
284
+
285
+ client.destroy();
286
+ } finally {
287
+ server.close();
288
+ }
289
+ });
@@ -0,0 +1,162 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { RLP } = require('@ethereumjs/rlp');
7
+
8
+ const DiodeConnection = require('../connection');
9
+
10
+ function makeConnection() {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'diode-ticket-retry-test-'));
12
+ return new DiodeConnection('relay.example', 41046, path.join(tempDir, 'keys.json'));
13
+ }
14
+
15
+ function encodeResponse(requestId, response) {
16
+ const payload = RLP.encode([requestId, response]);
17
+ const length = Buffer.alloc(2);
18
+ length.writeUInt16BE(payload.length, 0);
19
+ return Buffer.concat([length, Buffer.from(payload)]);
20
+ }
21
+
22
+ function tooLowResponse(overrides = {}) {
23
+ const totalConnections = overrides.totalConnections === undefined ? 11 : overrides.totalConnections;
24
+ const totalBytes = overrides.totalBytes === undefined ? 135591 : overrides.totalBytes;
25
+
26
+ return [
27
+ 'response',
28
+ 'too_low',
29
+ 1284,
30
+ 687,
31
+ totalConnections,
32
+ totalBytes,
33
+ Buffer.from([0]),
34
+ Buffer.from([1]),
35
+ ];
36
+ }
37
+
38
+ test('ticket too_low response retries once with repaired ticket', async () => {
39
+ const connection = makeConnection();
40
+ let retryCount = 0;
41
+
42
+ connection.fixResponse = () => {};
43
+ connection.createTicketCommand = async () => ['ticketv2'];
44
+ connection.sendCommand = async (command, options) => {
45
+ retryCount += 1;
46
+ assert.deepEqual(command, ['ticketv2']);
47
+ assert.equal(options.ticketRetryCount, 1);
48
+ return ['thanks!', 1];
49
+ };
50
+
51
+ const resultPromise = new Promise((resolve, reject) => {
52
+ connection.pendingRequests.set(1, {
53
+ resolve,
54
+ reject,
55
+ commandArray: ['ticketv2'],
56
+ ticketRetryCount: 0,
57
+ });
58
+ });
59
+
60
+ connection._handleData(encodeResponse(1, tooLowResponse()));
61
+ const result = await resultPromise;
62
+
63
+ assert.deepEqual(result, ['thanks!', 1]);
64
+ assert.equal(retryCount, 1);
65
+ });
66
+
67
+ test('ticket too_low response does not retry recursively', async () => {
68
+ const connection = makeConnection();
69
+ let retryCount = 0;
70
+
71
+ connection.fixResponse = () => {};
72
+ connection.createTicketCommand = async () => {
73
+ retryCount += 1;
74
+ return ['ticketv2'];
75
+ };
76
+
77
+ const resultPromise = new Promise((resolve, reject) => {
78
+ connection.pendingRequests.set(1, {
79
+ resolve,
80
+ reject,
81
+ commandArray: ['ticketv2'],
82
+ ticketRetryCount: 1,
83
+ });
84
+ });
85
+
86
+ connection._handleData(encodeResponse(1, tooLowResponse()));
87
+ const result = await resultPromise;
88
+
89
+ assert.equal(retryCount, 0);
90
+ assert.equal(Buffer.from(result[0]).toString('utf8'), 'too_low');
91
+ });
92
+
93
+ test('session responses do not swallow later unsolicited messages on the same session', async () => {
94
+ const connection = makeConnection();
95
+ const writes = [];
96
+
97
+ connection._ensureConnected = async () => {};
98
+ connection.socket = {
99
+ write(message, callback) {
100
+ writes.push(message);
101
+ callback();
102
+ },
103
+ };
104
+
105
+ await connection.sendCommandWithSessionId(['response', Buffer.from([1]), 'ok'], 7);
106
+
107
+ assert.equal(connection.pendingRequests.has(7), false);
108
+ assert.equal(writes.length, 1);
109
+
110
+ const unsolicited = new Promise((resolve) => {
111
+ connection.once('unsolicited', resolve);
112
+ });
113
+ connection._handleData(encodeResponse(7, ['portsend', Buffer.from([1]), Buffer.from('hello')]));
114
+
115
+ const message = await unsolicited;
116
+ assert.equal(Buffer.from(message[0]).readUIntBE(0, Buffer.from(message[0]).length), 7);
117
+ assert.equal(Buffer.from(message[1][0]).toString('utf8'), 'portsend');
118
+ });
119
+
120
+ test('too_low repair does not reduce local byte counters', () => {
121
+ const connection = makeConnection();
122
+ connection.totalConnections = 40;
123
+ connection.totalBytes = 1304576;
124
+
125
+ connection.fixResponse(tooLowResponse());
126
+
127
+ assert.equal(connection.totalConnections, 40);
128
+ assert.equal(connection.totalBytes, 1304576);
129
+ });
130
+
131
+ test('too_low repair uses node-reported paid byte floor', () => {
132
+ const connection = makeConnection();
133
+ connection.totalConnections = 4;
134
+ connection.totalBytes = 128000;
135
+ connection.accumulatedBytes = 2048;
136
+ connection.lastRelayMeasuredBytes = 4096;
137
+
138
+ connection.fixResponse(tooLowResponse({
139
+ totalConnections: 7,
140
+ totalBytes: 135591,
141
+ }));
142
+
143
+ assert.equal(connection.totalConnections, 7);
144
+ assert.equal(connection.lastAcceptedTicketBytes, 135591);
145
+ assert.equal(connection.totalBytes, 140711);
146
+ assert.equal(connection.accumulatedBytes, 5120);
147
+ });
148
+
149
+ test('ticket update keeps accumulated bytes when ticket is rejected', async () => {
150
+ const connection = makeConnection();
151
+ connection.socket = { destroyed: false };
152
+ connection.accumulatedBytes = 1000;
153
+ connection.lastTicketUpdate = 123;
154
+ connection.createTicketCommand = async () => ['ticketv2'];
155
+ connection.sendCommand = async () => ['too_low'];
156
+ connection._startTicketUpdateTimer = () => {};
157
+
158
+ await connection._updateTicketIfNeeded(true);
159
+
160
+ assert.equal(connection.accumulatedBytes, 1000);
161
+ assert.equal(connection.lastTicketUpdate, 123);
162
+ });
@@ -15,10 +15,17 @@ class FakeStreamSocket extends EventEmitter {
15
15
  this.remoteAddress = undefined;
16
16
  this.remotePort = undefined;
17
17
  this.writes = [];
18
+ this.pauseCalls = 0;
19
+ this.resumeCalls = 0;
18
20
  }
19
21
 
20
22
  setNoDelay() {}
21
- pause() {}
23
+ pause() {
24
+ this.pauseCalls += 1;
25
+ }
26
+ resume() {
27
+ this.resumeCalls += 1;
28
+ }
22
29
  write(data) {
23
30
  this.writes.push(data);
24
31
  }
@@ -145,6 +152,27 @@ function makeDeviceId(hexByte) {
145
152
  return Buffer.from(byte.repeat(20), 'hex');
146
153
  }
147
154
 
155
+ function deferred() {
156
+ let resolve;
157
+ let reject;
158
+ const promise = new Promise((res, rej) => {
159
+ resolve = res;
160
+ reject = rej;
161
+ });
162
+ return { promise, resolve, reject };
163
+ }
164
+
165
+ async function waitFor(predicate, timeoutMs = 1000) {
166
+ const startedAt = Date.now();
167
+ while (Date.now() - startedAt < timeoutMs) {
168
+ if (predicate()) {
169
+ return;
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, 10));
172
+ }
173
+ assert.equal(predicate(), true);
174
+ }
175
+
148
176
  test('PublishPort array input defaults host to 127.0.0.1', () => {
149
177
  const connection = new FakeConnection();
150
178
  const publishPort = new PublishPort(connection, [8080]);
@@ -211,6 +239,29 @@ test('TCP publish connects to configured host', () => {
211
239
  assert.deepEqual(connectCalls[0], { port: 8080, host: '192.168.1.10' });
212
240
  });
213
241
 
242
+ test('TCP publish pauses local service socket while portSend is in flight', async () => {
243
+ const connection = new FakeConnection();
244
+ const publishPort = new PublishPort(connection, [8080]);
245
+ const localSocket = new FakeStreamSocket();
246
+ const send = deferred();
247
+ connection.RPC.portSend = async (...args) => {
248
+ connection.portSendCalls.push(args);
249
+ return send.promise;
250
+ };
251
+
252
+ publishPort.setupLocalSocketHandlers(localSocket, makeRef('04'), 'tcp', connection.RPC, connection);
253
+ localSocket.emit('data', Buffer.from('hello'));
254
+
255
+ await waitFor(() => connection.portSendCalls.length === 1);
256
+ assert.equal(localSocket.pauseCalls, 1);
257
+ assert.equal(localSocket.resumeCalls, 0);
258
+
259
+ send.resolve();
260
+ await waitFor(() => localSocket.resumeCalls === 1);
261
+
262
+ publishPort.stopListening();
263
+ });
264
+
214
265
  test('TLS publish backend socket connects to configured host', () => {
215
266
  const connection = new FakeConnection();
216
267
  const publishPort = new PublishPort(connection, {
@@ -0,0 +1,124 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+
5
+ const DiodeRPC = require('../rpc');
6
+
7
+ function withServer(handler) {
8
+ return new Promise((resolve, reject) => {
9
+ const server = http.createServer(handler);
10
+ server.once('error', reject);
11
+ server.listen(0, '127.0.0.1', () => {
12
+ resolve({
13
+ port: server.address().port,
14
+ close: () => new Promise((done) => server.close(done)),
15
+ });
16
+ });
17
+ });
18
+ }
19
+
20
+ async function readJson(req) {
21
+ const chunks = [];
22
+ for await (const chunk of req) {
23
+ chunks.push(chunk);
24
+ }
25
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
26
+ }
27
+
28
+ function makeRpc(port) {
29
+ return new DiodeRPC({ host: '127.0.0.1' }).dioTraffic({
30
+ protocol: 'http',
31
+ rpcPort: port,
32
+ chainId: 1284,
33
+ epoch: 687,
34
+ });
35
+ }
36
+
37
+ test('dioTraffic posts dio_traffic to the connected relay node RPC endpoint', async () => {
38
+ let requestBody = null;
39
+ const result = {
40
+ chain_id: '0x504',
41
+ epoch: '0x2af',
42
+ fleets: {},
43
+ };
44
+ const server = await withServer(async (req, res) => {
45
+ requestBody = await readJson(req);
46
+ res.setHeader('content-type', 'application/json');
47
+ res.end(JSON.stringify({
48
+ jsonrpc: '2.0',
49
+ id: requestBody.id,
50
+ result,
51
+ }));
52
+ });
53
+
54
+ try {
55
+ const response = await makeRpc(server.port);
56
+
57
+ assert.deepEqual(response, result);
58
+ assert.equal(requestBody.method, 'dio_traffic');
59
+ assert.deepEqual(requestBody.params, [1284, 687]);
60
+ } finally {
61
+ await server.close();
62
+ }
63
+ });
64
+
65
+ test('dioTraffic defaults to chain 1284 and omits epoch when not supplied', async () => {
66
+ let requestBody = null;
67
+ const server = await withServer(async (req, res) => {
68
+ requestBody = await readJson(req);
69
+ res.setHeader('content-type', 'application/json');
70
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: requestBody.id, result: { fleets: {} } }));
71
+ });
72
+
73
+ try {
74
+ const rpc = new DiodeRPC({ host: '127.0.0.1' });
75
+
76
+ await rpc.dioTraffic({
77
+ protocol: 'http',
78
+ rpcPort: server.port,
79
+ });
80
+
81
+ assert.deepEqual(requestBody.params, [1284]);
82
+ } finally {
83
+ await server.close();
84
+ }
85
+ });
86
+
87
+ test('dio_traffic aliases dioTraffic', async () => {
88
+ let requestBody = null;
89
+ const server = await withServer(async (req, res) => {
90
+ requestBody = await readJson(req);
91
+ res.setHeader('content-type', 'application/json');
92
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: requestBody.id, result: { fleets: {} } }));
93
+ });
94
+
95
+ try {
96
+ const rpc = new DiodeRPC({ host: '127.0.0.1' });
97
+
98
+ await rpc.dio_traffic({
99
+ protocol: 'http',
100
+ rpcPort: server.port,
101
+ chainId: 1284,
102
+ });
103
+
104
+ assert.deepEqual(requestBody.params, [1284]);
105
+ } finally {
106
+ await server.close();
107
+ }
108
+ });
109
+
110
+ test('dioTraffic throws JSON-RPC errors', async () => {
111
+ const server = await withServer(async (_req, res) => {
112
+ res.setHeader('content-type', 'application/json');
113
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, error: { message: 'bad params' } }));
114
+ });
115
+
116
+ try {
117
+ await assert.rejects(
118
+ () => makeRpc(server.port),
119
+ /bad params/,
120
+ );
121
+ } finally {
122
+ await server.close();
123
+ }
124
+ });