diodejs 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,6 +64,27 @@ async function main() {
64
64
  main();
65
65
  ```
66
66
 
67
+ Fleet contract selection is configured on the manager:
68
+ ```javascript
69
+ const { DiodeClientManager } = require('diodejs');
70
+
71
+ async function main() {
72
+ const client = new DiodeClientManager({
73
+ keyLocation: './db/keys.json',
74
+ fleetContract: '0x1111111111111111111111111111111111111111',
75
+ });
76
+
77
+ await client.connect();
78
+
79
+ // Applies on the next ticket generated by each active relay connection.
80
+ client.setFleetContract('0x2222222222222222222222222222222222222222');
81
+ }
82
+
83
+ main();
84
+ ```
85
+
86
+ If `fleetContract` is omitted, tickets continue using the default contract `0x6000000000000000000000000000000000000000`.
87
+
67
88
  ### Multi-Relay Connections (Recommended)
68
89
 
69
90
  You can connect to multiple Diode relays and automatically route binds to the relay where the target device is connected.
@@ -305,9 +326,11 @@ async function main() {
305
326
 
306
327
  // Option 2: Object with port configurations for public/private access control
307
328
  const publishedPortsWithConfig = {
308
- 8080: { mode: 'public' }, // Public port, accessible by any device
329
+ 8080: { mode: 'public' }, // Public port on 127.0.0.1, accessible by any device
330
+ 8081: { mode: 'public', host: '192.168.1.10' }, // Forward to another reachable host
309
331
  3000: {
310
332
  mode: 'private',
333
+ host: 'backend.internal',
311
334
  whitelist: ['0x1234abcd5678...', '0x9876fedc5432...'] // Only these devices can connect
312
335
  }
313
336
  };
@@ -330,6 +353,7 @@ main();
330
353
  - `options.port` (number, optional): Port to use when `options.host` has no port. Defaults to `41046`.
331
354
  - `options.hosts` (string[] or comma-separated string, optional): Explicit relay list.
332
355
  - `options.keyLocation` (string, optional): Key storage path (default: `./db/keys.json`).
356
+ - `options.fleetContract` (string, optional): Fleet contract used for relay tickets. Must be a 20-byte EVM address hex string. Defaults to `0x6000000000000000000000000000000000000000`.
333
357
  - `options.deviceCacheTtlMs` (number, optional): Cache TTL for device relay resolution (default: `30000`).
334
358
  - `options.relaySelection` (object, optional): Relay ranking and probing options.
335
359
  - `enabled` (boolean, optional): Enables smart relay ranking. Defaults to `true`.
@@ -365,6 +389,7 @@ main();
365
389
 
366
390
  - **Methods**:
367
391
  - `connect()`: Probes the required initial relays, merges provider and optional `dio_network` candidates, ranks the successful relays by latency, trims the warm relay set, and returns a promise. In live network discovery mode this starts from a region-diverse seed bootstrap subset plus the bounded discovery sample, then continues probing the remaining seeds in the background.
392
+ - `setFleetContract(address)`: Updates the fleet contract used for future ticket generation on managed connections. Accepts a 20-byte EVM address hex string and returns the manager instance.
368
393
  - `getNearestConnection()`: Returns the preferred connected relay. With relay selection enabled, this is the lowest-latency scored relay.
369
394
  - `getConnectionForDevice(deviceId)`: Resolves and returns a relay connection for the device. Returns a promise.
370
395
  - `getConnections()`: Returns a list of active connections.
@@ -428,13 +453,14 @@ Connections returned by `getConnections()` or `getConnectionForDevice()` emit `r
428
453
  - `connection` (DiodeClientManager): An instance of `DiodeClientManager`.
429
454
  - `publishedPorts` (array|object): Either:
430
455
  - An array of ports to publish (all public mode)
431
- - An object mapping ports to their configuration: `{ port: { mode: 'public'|'private', whitelist: ['0x123...'] } }`
456
+ - An object mapping ports to their configuration: `{ port: { mode: 'public'|'private', whitelist: ['0x123...'], host: '127.0.0.1' } }`
432
457
  - `_certPath` (string): Has no functionality and maintained for backward compatibility.
433
458
 
434
459
  - **Methods**:
435
460
  - `addPort(port, config)`: Adds a new port to publish. Config is optional and defaults to public mode.
436
461
  - `port` (number): The port number to publish.
437
- - `config` (object): Optional configuration with `mode` ('public'|'private') and `whitelist` array.
462
+ - `config` (object): Optional configuration with `mode` ('public'|'private'), `whitelist` array, and `host` string.
463
+ - `host` (string, optional): Target IP or hostname for the published service. Defaults to `127.0.0.1`.
438
464
  - `removePort(port)`: Removes a published port.
439
465
  - `port` (number): The port number to remove.
440
466
  - `addPorts(ports)`: Adds multiple ports at once (equivalent to the constructor's publishedPorts parameter).
package/clientManager.js CHANGED
@@ -6,6 +6,7 @@ const DiodeConnection = require('./connection');
6
6
  const DiodeRPC = require('./rpc');
7
7
  const { fetchNetworkDirectory } = require('./networkDiscoveryClient');
8
8
  const logger = require('./logger');
9
+ const { DEFAULT_FLEET_CONTRACT, normalizeFleetContractAddress } = require('./utils');
9
10
 
10
11
  const DEFAULT_DIODE_ADDRS = [
11
12
  'as1.prenet.diode.io:41046',
@@ -176,11 +177,29 @@ class DiodeClientManager extends EventEmitter {
176
177
  this._startupCoverageComplete = false;
177
178
  this._lastNetworkDiscoveryStats = null;
178
179
  this._lastDeviceResolutionTrace = null;
180
+ this.fleetContract = DEFAULT_FLEET_CONTRACT;
181
+
182
+ if (options.fleetContract !== undefined) {
183
+ this.setFleetContract(options.fleetContract);
184
+ }
179
185
 
180
186
  this.initialHosts = this._buildInitialHosts(options);
181
187
  this._loadRelayScores();
182
188
  }
183
189
 
190
+ setFleetContract(address) {
191
+ const normalizedFleetContract = normalizeFleetContractAddress(address);
192
+ this.fleetContract = normalizedFleetContract;
193
+
194
+ for (const connection of this.connections) {
195
+ if (connection && typeof connection.setFleetContract === 'function') {
196
+ connection.setFleetContract(normalizedFleetContract);
197
+ }
198
+ }
199
+
200
+ return this;
201
+ }
202
+
184
203
  _buildRelaySelectionOptions(options = {}) {
185
204
  const relaySelection = options && typeof options === 'object' ? options : {};
186
205
  const legacyWarmConnections = parsePositiveInteger(relaySelection.desiredWarmConnections, NaN);
@@ -1157,6 +1176,9 @@ class DiodeClientManager extends EventEmitter {
1157
1176
  connection._managerHostKey = hostKey;
1158
1177
  this.connections.push(connection);
1159
1178
  this.connectionByHost.set(hostKey, connection);
1179
+ if (typeof connection.setFleetContract === 'function') {
1180
+ connection.setFleetContract(this.fleetContract);
1181
+ }
1160
1182
  if (typeof connection.setLocalAddressProvider === 'function') {
1161
1183
  connection.setLocalAddressProvider(() => this._localAddressHintFor(connection));
1162
1184
  }
package/connection.js CHANGED
@@ -3,7 +3,19 @@ const tls = require('tls');
3
3
  const fs = require('fs');
4
4
  const { RLP } = require('@ethereumjs/rlp');
5
5
  const EventEmitter = require('events');
6
- const { makeReadable, parseRequestId, parseResponseType, parseReason, parseUInt, generateCert, ensureDirectoryExistence, loadOrGenerateKeyPair, toBufferView } = require('./utils');
6
+ const {
7
+ makeReadable,
8
+ parseRequestId,
9
+ parseResponseType,
10
+ parseReason,
11
+ parseUInt,
12
+ generateCert,
13
+ ensureDirectoryExistence,
14
+ loadOrGenerateKeyPair,
15
+ toBufferView,
16
+ DEFAULT_FLEET_CONTRACT,
17
+ normalizeFleetContractAddress,
18
+ } = require('./utils');
7
19
  const { Buffer } = require('buffer'); // Import Buffer
8
20
  const asn1 = require('asn1.js');
9
21
  const secp256k1 = require('secp256k1');
@@ -48,6 +60,8 @@ class DiodeConnection extends EventEmitter {
48
60
  this.certPem = null;
49
61
  this._serverEthAddress = null; // cache after first read
50
62
  this.localAddressProvider = null;
63
+ this.fleetContractHex = DEFAULT_FLEET_CONTRACT;
64
+ this.fleetContract = Buffer.from(DEFAULT_FLEET_CONTRACT.slice(2), 'hex');
51
65
  // Load or generate keypair
52
66
  this.keyPair = loadOrGenerateKeyPair(this.keyLocation);
53
67
 
@@ -283,6 +297,13 @@ class DiodeConnection extends EventEmitter {
283
297
  return this;
284
298
  }
285
299
 
300
+ setFleetContract(fleetContract) {
301
+ const normalizedFleetContract = normalizeFleetContractAddress(fleetContract);
302
+ this.fleetContractHex = normalizedFleetContract;
303
+ this.fleetContract = Buffer.from(normalizedFleetContract.slice(2), 'hex');
304
+ return this;
305
+ }
306
+
286
307
  // Update close method to prevent reconnection when intentionally closing
287
308
  close() {
288
309
  if (this.ticketUpdateTimer) {
@@ -592,7 +613,7 @@ class DiodeConnection extends EventEmitter {
592
613
 
593
614
  async createTicketSignature(serverIdBuffer, totalConnections, totalBytes, localAddress, epoch) {
594
615
  const chainId = 1284;
595
- const fleetContractBuffer = ethUtil.toBuffer('0x6000000000000000000000000000000000000000'); // 20-byte Buffer
616
+ const fleetContractBuffer = this.fleetContract;
596
617
 
597
618
  const localAddressBytes = Buffer.isBuffer(localAddress) || localAddress instanceof Uint8Array
598
619
  ? toBufferView(localAddress)
@@ -637,7 +658,7 @@ class DiodeConnection extends EventEmitter {
637
658
 
638
659
  async createTicketCommand() {
639
660
  const chainId = 1284;
640
- const fleetContract = ethUtil.toBuffer('0x6000000000000000000000000000000000000000')
661
+ const fleetContract = this.fleetContract;
641
662
  let localAddress = '';
642
663
  if (typeof this.localAddressProvider === 'function') {
643
664
  try {
@@ -3,8 +3,13 @@ const { makeReadable } = require('../utils');
3
3
 
4
4
  async function main() {
5
5
  const keyLocation = './db/keys.json';
6
+ const fleetContract = process.env.DIODE_FLEET_CONTRACT;
7
+ const nextFleetContract = process.env.DIODE_NEXT_FLEET_CONTRACT;
6
8
 
7
- const client = new DiodeClientManager({ keyLocation });
9
+ const client = new DiodeClientManager({
10
+ keyLocation,
11
+ ...(fleetContract ? { fleetContract } : {}),
12
+ });
8
13
  await client.connect();
9
14
  const [connection] = client.getConnections();
10
15
  if (!connection) {
@@ -15,6 +20,11 @@ async function main() {
15
20
  try {
16
21
  const address = connection.getEthereumAddress();
17
22
  console.log('Address:', address);
23
+ console.log('Current fleet contract:', connection.fleetContractHex);
24
+ if (nextFleetContract) {
25
+ client.setFleetContract(nextFleetContract);
26
+ console.log('Updated fleet contract for future tickets:', connection.fleetContractHex);
27
+ }
18
28
  const ping = await rpc.ping();
19
29
  console.log('Ping:', ping);
20
30
  const blockPeak = await rpc.getBlockPeak();
@@ -0,0 +1,45 @@
1
+ const { DiodeClientManager } = require('../index');
2
+
3
+ const keyLocation = './db/keys.json';
4
+ const initialFleetContract = process.env.DIODE_FLEET_CONTRACT || '0x1111111111111111111111111111111111111111';
5
+ const nextFleetContract = process.env.DIODE_NEXT_FLEET_CONTRACT || '0x2222222222222222222222222222222222222222';
6
+ const shouldConnect = process.env.DIODE_CONNECT === 'true';
7
+
8
+ async function main() {
9
+ const client = new DiodeClientManager({
10
+ keyLocation,
11
+ fleetContract: initialFleetContract,
12
+ });
13
+
14
+ console.log('Initial manager fleet contract:', client.fleetContract);
15
+
16
+ if (!shouldConnect) {
17
+ client.setFleetContract(nextFleetContract);
18
+ console.log('Updated manager fleet contract:', client.fleetContract);
19
+ console.log('Set DIODE_CONNECT=true to connect and observe the updated contract on active relay connections.');
20
+ return;
21
+ }
22
+
23
+ try {
24
+ await client.connect();
25
+ console.log(`Connected relay count: ${client.getConnections().length}`);
26
+ for (const connection of client.getConnections()) {
27
+ console.log(`Before update ${connection.host}:${connection.port} fleet contract: ${connection.fleetContractHex}`);
28
+ }
29
+
30
+ client.setFleetContract(nextFleetContract);
31
+ console.log('Updated manager fleet contract:', client.fleetContract);
32
+ for (const connection of client.getConnections()) {
33
+ console.log(`After update ${connection.host}:${connection.port} fleet contract: ${connection.fleetContractHex}`);
34
+ }
35
+
36
+ console.log('The new fleet contract will be used on the next ticket generated by each connection.');
37
+ } finally {
38
+ client.close();
39
+ }
40
+ }
41
+
42
+ main().catch((error) => {
43
+ console.error('Fleet contract example failed:', error);
44
+ process.exitCode = 1;
45
+ });
@@ -10,9 +10,10 @@ async function startPublishing() {
10
10
 
11
11
  // Create a PublishPort instance with initial ports
12
12
  const publishPort = new PublishPort(client, {
13
- 3000: { mode: 'public' },
13
+ 3000: { mode: 'public', host: '192.168.1.10' },
14
14
  8080: {
15
15
  mode: 'private',
16
+ host: 'backend.internal',
16
17
  whitelist: ['0xca1e71d8105a598810578fb6042fa8cbc1e7f039'] // Replace with actual addresses
17
18
  }
18
19
  }, keyLocation);
@@ -29,7 +30,7 @@ async function startPublishing() {
29
30
  // After 15 seconds, add multiple ports
30
31
  setTimeout(() => {
31
32
  console.log("Adding multiple ports");
32
- publishPort.addPort(3000,{ mode: 'public' });
33
+ publishPort.addPort(3000,{ mode: 'public', host: '127.0.0.1' });
33
34
  console.log('Updated published ports:', publishPort.getPublishedPorts());
34
35
  }, 15000);
35
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diodejs",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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
@@ -38,6 +38,25 @@ function normalizeDeviceId(raw) {
38
38
  return '';
39
39
  }
40
40
 
41
+ function normalizePublishedPortConfig(config = {}) {
42
+ const hasHost = Object.prototype.hasOwnProperty.call(config, 'host');
43
+ const rawHost = hasHost ? config.host : '127.0.0.1';
44
+ if (typeof rawHost !== 'string') {
45
+ throw new TypeError('PublishPort config.host must be a non-empty string');
46
+ }
47
+
48
+ const host = rawHost.trim();
49
+ if (!host) {
50
+ throw new TypeError('PublishPort config.host must be a non-empty string');
51
+ }
52
+
53
+ return {
54
+ mode: config.mode || 'public',
55
+ whitelist: Array.isArray(config.whitelist) ? config.whitelist : [],
56
+ host,
57
+ };
58
+ }
59
+
41
60
  class DiodeSocket extends Duplex {
42
61
  constructor(ref, rpc) {
43
62
  super({ readableHighWaterMark: 256 * 1024, writableHighWaterMark: 256 * 1024, allowHalfOpen: false });
@@ -93,14 +112,11 @@ class PublishPort extends EventEmitter {
93
112
  const portNum = parseInt(port, 10);
94
113
 
95
114
  // Normalize the configuration
96
- const portConfig = {
97
- mode: config.mode || 'public',
98
- whitelist: Array.isArray(config.whitelist) ? config.whitelist : []
99
- };
115
+ const portConfig = normalizePublishedPortConfig(config);
100
116
 
101
117
  // Add to map
102
118
  this.publishedPorts.set(portNum, portConfig);
103
- logger.info(() => `Added published port ${portNum} with mode: ${portConfig.mode}`);
119
+ logger.info(() => `Added published port ${portNum} with mode: ${portConfig.mode}, host: ${portConfig.host}`);
104
120
 
105
121
  return true;
106
122
  }
@@ -186,6 +202,10 @@ class PublishPort extends EventEmitter {
186
202
  return rpc;
187
203
  }
188
204
 
205
+ _getPublishedPortConfig(port) {
206
+ return this.publishedPorts.get(port);
207
+ }
208
+
189
209
  startListening() {
190
210
  if (this._listening) return this; // idempotent
191
211
  // Listen for unsolicited messages from the connection
@@ -269,7 +289,7 @@ class PublishPort extends EventEmitter {
269
289
  }
270
290
 
271
291
  // Get port configuration and check whitelist if in private mode
272
- const portConfig = this.publishedPorts.get(port);
292
+ const portConfig = this._getPublishedPortConfig(port);
273
293
  if (portConfig.mode === 'private' && Array.isArray(portConfig.whitelist)) {
274
294
  if (!portConfig.whitelist.includes(deviceId)) {
275
295
  logger.warn(() => `Device ${deviceId} is not whitelisted for port ${port}. Rejecting request.`);
@@ -281,15 +301,15 @@ class PublishPort extends EventEmitter {
281
301
 
282
302
  // Handle based on protocol
283
303
  if (protocol === 'tcp') {
284
- this.handleTCPConnection(sessionId, ref, port, deviceId, connection);
304
+ this.handleTCPConnection(sessionId, ref, port, deviceId, portConfig, connection);
285
305
  } else if (protocol === 'tls') {
286
306
  if (isHandshake) {
287
307
  this.handleTLSHandshake(sessionId, ref, port, deviceId, connection);
288
308
  } else {
289
- this.handleTLSConnection(sessionId, ref, port, deviceId, connection);
309
+ this.handleTLSConnection(sessionId, ref, port, deviceId, portConfig, connection);
290
310
  }
291
311
  } else if (protocol === 'udp') {
292
- this.handleUDPConnection(sessionId, ref, port, deviceId, connection);
312
+ this.handleUDPConnection(sessionId, ref, port, deviceId, portConfig, connection);
293
313
  } else {
294
314
  logger.warn(() => `Unsupported protocol: ${protocol}`);
295
315
  rpc.sendError(sessionId, ref, `Unsupported protocol: ${protocol}`);
@@ -449,7 +469,7 @@ class PublishPort extends EventEmitter {
449
469
  }
450
470
 
451
471
  // Get port configuration and check whitelist if in private mode
452
- const portConfig = this.publishedPorts.get(port);
472
+ const portConfig = this._getPublishedPortConfig(port);
453
473
  if (portConfig.mode === 'private' && Array.isArray(portConfig.whitelist)) {
454
474
  if (!portConfig.whitelist.includes(deviceId)) {
455
475
  logger.warn(() => `Device ${deviceId} is not whitelisted for port ${port}. Rejecting request.`);
@@ -473,6 +493,7 @@ class PublishPort extends EventEmitter {
473
493
  const session = {
474
494
  physicalPort,
475
495
  port,
496
+ host: portConfig.host,
476
497
  protocol,
477
498
  deviceId: deviceId.toLowerCase(),
478
499
  connection,
@@ -527,7 +548,7 @@ class PublishPort extends EventEmitter {
527
548
 
528
549
  handleNativeTCPRelay(sessionId, physicalPortRef, session, connection) {
529
550
  const rpc = this._getRpcFor(connection);
530
- const { physicalPort, port, deviceId } = session;
551
+ const { physicalPort, port, host, deviceId } = session;
531
552
  let responded = false;
532
553
  const sendOk = () => {
533
554
  if (responded) return;
@@ -544,7 +565,7 @@ class PublishPort extends EventEmitter {
544
565
  const relaySocket = net.connect({ host: relayHost, port: physicalPort }, () => {
545
566
  relaySocket.setNoDelay(true);
546
567
  });
547
- const localSocket = net.connect({ port }, () => {
568
+ const localSocket = net.connect({ port, host }, () => {
548
569
  localSocket.setNoDelay(true);
549
570
  });
550
571
  localSocket.pause();
@@ -616,7 +637,7 @@ class PublishPort extends EventEmitter {
616
637
 
617
638
  handleNativeUDPRelay(sessionId, physicalPortRef, session, connection) {
618
639
  const rpc = this._getRpcFor(connection);
619
- const { physicalPort, port, deviceId } = session;
640
+ const { physicalPort, port, host, deviceId } = session;
620
641
  let responded = false;
621
642
  const sendOk = () => {
622
643
  if (responded) return;
@@ -672,7 +693,7 @@ class PublishPort extends EventEmitter {
672
693
  relayReady = true;
673
694
  maybeReady();
674
695
  });
675
- localSocket.connect(port, '127.0.0.1', () => {
696
+ localSocket.connect(port, host, () => {
676
697
  localReady = true;
677
698
  maybeReady();
678
699
  });
@@ -706,12 +727,12 @@ class PublishPort extends EventEmitter {
706
727
  }
707
728
  }
708
729
 
709
- handleTCPConnection(sessionId, ref, port, deviceId, connection) {
730
+ handleTCPConnection(sessionId, ref, port, deviceId, portConfig, connection) {
710
731
  const rpc = this._getRpcFor(connection);
711
732
  // Create a TCP connection to the local service on the specified port
712
- const localSocket = net.connect({ port: port }, () => {
733
+ const localSocket = net.connect({ port, host: portConfig.host }, () => {
713
734
  localSocket.setNoDelay(true);
714
- logger.info(() => `Connected to local TCP service on port ${port}`);
735
+ logger.info(() => `Connected to local TCP service on ${portConfig.host}:${port}`);
715
736
  // Send success response
716
737
  rpc.sendResponse(sessionId, ref, 'ok');
717
738
  });
@@ -720,10 +741,10 @@ class PublishPort extends EventEmitter {
720
741
  this.setupLocalSocketHandlers(localSocket, ref, 'tcp', rpc, connection);
721
742
 
722
743
  // Store the local socket with the ref using connection's method
723
- connection.addConnection(ref, { socket: localSocket, protocol: 'tcp', port, deviceId });
744
+ connection.addConnection(ref, { socket: localSocket, protocol: 'tcp', port, host: portConfig.host, deviceId });
724
745
  }
725
746
 
726
- handleTLSConnection(sessionId, ref, port, deviceId, connection) {
747
+ handleTLSConnection(sessionId, ref, port, deviceId, portConfig, connection) {
727
748
  const rpc = this._getRpcFor(connection);
728
749
  // Create a DiodeSocket instance
729
750
  const diodeSocket = new DiodeSocket(ref, rpc);
@@ -748,8 +769,8 @@ class PublishPort extends EventEmitter {
748
769
  });
749
770
  tlsSocket.setNoDelay(true);
750
771
  // Connect to the local service (TCP or TLS as needed)
751
- const localSocket = net.connect({ port: port }, () => {
752
- logger.info(() => `Connected to local TCP service on port ${port}`);
772
+ const localSocket = net.connect({ port, host: portConfig.host }, () => {
773
+ logger.info(() => `Connected to local TCP service on ${portConfig.host}:${port}`);
753
774
  // Send success response
754
775
  rpc.sendResponse(sessionId, ref, 'ok');
755
776
  });
@@ -775,11 +796,12 @@ class PublishPort extends EventEmitter {
775
796
  localSocket,
776
797
  protocol: 'tls',
777
798
  port,
799
+ host: portConfig.host,
778
800
  deviceId,
779
801
  });
780
802
  }
781
803
 
782
- handleUDPConnection(sessionId, ref, port, deviceId, connection) {
804
+ handleUDPConnection(sessionId, ref, port, deviceId, portConfig, connection) {
783
805
  const rpc = this._getRpcFor(connection);
784
806
  // Create a UDP socket
785
807
  const localSocket = dgram.createSocket('udp4');
@@ -795,7 +817,7 @@ class PublishPort extends EventEmitter {
795
817
  });
796
818
 
797
819
  // Store the remote address and port from the Diode client
798
- const remoteInfo = {port, address: '127.0.0.1'};
820
+ const remoteInfo = { port, address: portConfig.host };
799
821
 
800
822
  // Send success response
801
823
  rpc.sendResponse(sessionId, ref, 'ok');
@@ -806,10 +828,11 @@ class PublishPort extends EventEmitter {
806
828
  protocol: 'udp',
807
829
  remoteInfo,
808
830
  port,
831
+ host: portConfig.host,
809
832
  deviceId
810
833
  });
811
834
 
812
- logger.info(() => `UDP connection set up on port ${port}`);
835
+ logger.info(() => `UDP connection set up for ${portConfig.host}:${port}`);
813
836
 
814
837
  // Handle messages from the local UDP service
815
838
  localSocket.on('message', (msg, rinfo) => {
@@ -835,7 +858,7 @@ class PublishPort extends EventEmitter {
835
858
  const connectionInfo = connection.getConnection(ref);
836
859
  // Check if the port is still open and address is still in whitelist
837
860
  if (connectionInfo) {
838
- const { socket: localSocket, protocol, remoteInfo, port, deviceId } = connectionInfo;
861
+ const { socket: localSocket, protocol, remoteInfo, port, host, deviceId } = connectionInfo;
839
862
 
840
863
  if (!this.publishedPorts.has(port)) {
841
864
  logger.warn(() => `Port ${port} is not published. Sending portclose.`);
@@ -844,7 +867,7 @@ class PublishPort extends EventEmitter {
844
867
  return;
845
868
  }
846
869
 
847
- const portConfig = this.publishedPorts.get(port);
870
+ const portConfig = this._getPublishedPortConfig(port);
848
871
  if (portConfig.mode === 'private' && Array.isArray(portConfig.whitelist)) {
849
872
  if (!portConfig.whitelist.includes(deviceId)) {
850
873
  logger.warn(() => `Device ${deviceId} is not whitelisted for port ${port}. Sending portclose.`);
@@ -865,7 +888,7 @@ class PublishPort extends EventEmitter {
865
888
 
866
889
  // Update remoteInfo if not set
867
890
  if (!localSocket.remoteAddress) {
868
- localSocket.remoteAddress = '127.0.0.1'; // Assuming local service is on localhost
891
+ localSocket.remoteAddress = host || (remoteInfo && remoteInfo.address) || portConfig.host;
869
892
  localSocket.remotePort = port;
870
893
  }
871
894
  } else if (protocol === 'tcp') {
@@ -8,6 +8,7 @@ const { WebSocketServer } = require('ws');
8
8
 
9
9
  const DiodeClientManager = require('../clientManager');
10
10
  const { fetchNetworkDirectory } = require('../networkDiscoveryClient');
11
+ const { DEFAULT_FLEET_CONTRACT } = require('../utils');
11
12
 
12
13
  const networkSnapshot = JSON.parse(
13
14
  fs.readFileSync(path.join(__dirname, 'fixtures', 'dio-network-snapshot.json'), 'utf8')
@@ -48,6 +49,8 @@ class FakeConnection extends EventEmitter {
48
49
  this.socket = { destroyed: false };
49
50
  this.closeCount = 0;
50
51
  this.serverEthereumAddress = options.serverEthereumAddress || '0x' + Buffer.from(hostKey).toString('hex').slice(0, 40).padEnd(40, '0');
52
+ this.fleetContract = null;
53
+ this.fleetContractUpdates = [];
51
54
  this.RPC = {
52
55
  ping: async () => {
53
56
  await delay(options.pingDelayMs || 0);
@@ -66,6 +69,12 @@ class FakeConnection extends EventEmitter {
66
69
  this.localAddressProvider = provider;
67
70
  }
68
71
 
72
+ setFleetContract(address) {
73
+ this.fleetContract = address;
74
+ this.fleetContractUpdates.push(address);
75
+ return this;
76
+ }
77
+
69
78
  close() {
70
79
  this.closeCount += 1;
71
80
  this.socket.destroyed = true;
@@ -117,6 +126,75 @@ class TestClientManager extends DiodeClientManager {
117
126
  }
118
127
  }
119
128
 
129
+ test('constructor-provided fleet contract propagates to new managed connections', async () => {
130
+ const tempDir = makeTempDir();
131
+ const keyLocation = path.join(tempDir, 'keys.json');
132
+ const connection = new FakeConnection('custom:41046');
133
+ const manager = new TestClientManager({
134
+ keyLocation,
135
+ hosts: ['custom:41046'],
136
+ fleetContract: '1234567890abcdef1234567890ABCDEF12345678',
137
+ relaySelection: { scoreCachePath: null, networkDiscovery: { enabled: false } },
138
+ }, new Map([
139
+ ['custom:41046', { connection }],
140
+ ]));
141
+
142
+ await manager.connect();
143
+
144
+ assert.equal(connection.fleetContract, '0x1234567890abcdef1234567890abcdef12345678');
145
+ manager.close();
146
+ });
147
+
148
+ test('setFleetContract updates existing and future managed connections', async () => {
149
+ const tempDir = makeTempDir();
150
+ const keyLocation = path.join(tempDir, 'keys.json');
151
+ const manager = new TestClientManager({
152
+ keyLocation,
153
+ relaySelection: { scoreCachePath: null, networkDiscovery: { enabled: false } },
154
+ });
155
+ const existing = new FakeConnection('existing:41046');
156
+ const future = new FakeConnection('future:41046');
157
+
158
+ manager._registerConnection(existing, 'existing:41046');
159
+ manager.hostBehaviors.set('future:41046', { connection: future });
160
+
161
+ manager.setFleetContract('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd');
162
+ await manager._ensureConnection('future:41046');
163
+
164
+ assert.equal(existing.fleetContract, '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd');
165
+ assert.equal(future.fleetContract, '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd');
166
+ manager.close();
167
+ });
168
+
169
+ test('invalid fleet contract values fail fast', () => {
170
+ const tempDir = makeTempDir();
171
+ const keyLocation = path.join(tempDir, 'keys.json');
172
+
173
+ assert.throws(
174
+ () => new DiodeClientManager({ keyLocation, fleetContract: '0x1234', relaySelection: { scoreCachePath: null } }),
175
+ /fleetContract must be a 20-byte EVM address hex string/i,
176
+ );
177
+
178
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null } });
179
+ assert.throws(
180
+ () => manager.setFleetContract('not-a-contract'),
181
+ /fleetContract must be a 20-byte EVM address hex string/i,
182
+ );
183
+ manager.close();
184
+ });
185
+
186
+ test('default fleet contract is applied when manager config is omitted', () => {
187
+ const tempDir = makeTempDir();
188
+ const keyLocation = path.join(tempDir, 'keys.json');
189
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null } });
190
+ const connection = new FakeConnection('default:41046');
191
+
192
+ manager._registerConnection(connection, 'default:41046');
193
+
194
+ assert.equal(connection.fleetContract, DEFAULT_FLEET_CONTRACT);
195
+ manager.close();
196
+ });
197
+
120
198
  test('tested candidates rank by measured latency', () => {
121
199
  const tempDir = makeTempDir();
122
200
  const keyLocation = path.join(tempDir, 'keys.json');
@@ -0,0 +1,71 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const DiodeConnection = require('../connection');
8
+ const { DEFAULT_FLEET_CONTRACT } = require('../utils');
9
+
10
+ function makeTempDir() {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'diode-fleet-test-'));
12
+ }
13
+
14
+ function makeConnection() {
15
+ const tempDir = makeTempDir();
16
+ const keyLocation = path.join(tempDir, 'keys.json');
17
+ const connection = new DiodeConnection('relay.example', 41046, keyLocation);
18
+ connection.RPC = {
19
+ getEpoch: async () => 77,
20
+ };
21
+ connection._waitForServerEthereumAddress = async () => Buffer.from('aa'.repeat(20), 'hex');
22
+ return connection;
23
+ }
24
+
25
+ test('createTicketCommand uses the configured fleet contract in ticketv2', async () => {
26
+ const connection = makeConnection();
27
+
28
+ connection.setFleetContract('0x1111111111111111111111111111111111111111');
29
+ const command = await connection.createTicketCommand();
30
+
31
+ assert.equal(command[0], 'ticketv2');
32
+ assert.equal(command[2], 77);
33
+ assert.ok(Buffer.isBuffer(command[3]));
34
+ assert.equal(command[3].toString('hex'), '1111111111111111111111111111111111111111');
35
+ });
36
+
37
+ test('createTicketSignature changes when the fleet contract changes', async () => {
38
+ const connection = makeConnection();
39
+ const serverIdBuffer = Buffer.from('bb'.repeat(20), 'hex');
40
+ const totalConnections = 5;
41
+ const totalBytes = 123456;
42
+ const localAddress = 'client-a';
43
+ const epoch = 88;
44
+
45
+ const defaultSignature = await connection.createTicketSignature(
46
+ serverIdBuffer,
47
+ totalConnections,
48
+ totalBytes,
49
+ localAddress,
50
+ epoch,
51
+ );
52
+
53
+ connection.setFleetContract('0x2222222222222222222222222222222222222222');
54
+ const updatedSignature = await connection.createTicketSignature(
55
+ serverIdBuffer,
56
+ totalConnections,
57
+ totalBytes,
58
+ localAddress,
59
+ epoch,
60
+ );
61
+
62
+ assert.notDeepEqual(updatedSignature, defaultSignature);
63
+ });
64
+
65
+ test('DiodeConnection defaults to the existing fleet contract', async () => {
66
+ const connection = makeConnection();
67
+
68
+ const command = await connection.createTicketCommand();
69
+
70
+ assert.equal(command[3].toString('hex'), DEFAULT_FLEET_CONTRACT.slice(2));
71
+ });
@@ -0,0 +1,399 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const EventEmitter = require('events');
4
+ const net = require('net');
5
+ const tls = require('tls');
6
+ const dgram = require('dgram');
7
+
8
+ const PublishPort = require('../publishPort');
9
+
10
+ class FakeStreamSocket extends EventEmitter {
11
+ constructor() {
12
+ super();
13
+ this.destroyed = false;
14
+ this.remoteAddress = undefined;
15
+ this.remotePort = undefined;
16
+ }
17
+
18
+ setNoDelay() {}
19
+ pause() {}
20
+ write() {}
21
+ end() {}
22
+ destroy() {
23
+ this.destroyed = true;
24
+ }
25
+ pipe(destination) {
26
+ return destination;
27
+ }
28
+ }
29
+
30
+ class FakeDatagramSocket extends EventEmitter {
31
+ constructor() {
32
+ super();
33
+ this.connectCalls = [];
34
+ this.sendCalls = [];
35
+ this.closed = false;
36
+ this.remoteAddress = undefined;
37
+ this.remotePort = undefined;
38
+ }
39
+
40
+ connect(port, host, callback) {
41
+ this.connectCalls.push({ port, host });
42
+ if (typeof callback === 'function') {
43
+ callback();
44
+ }
45
+ }
46
+
47
+ send(...args) {
48
+ if (args.length >= 3 && typeof args[1] === 'number' && typeof args[2] === 'string') {
49
+ this.sendCalls.push({ data: args[0], port: args[1], address: args[2] });
50
+ } else {
51
+ this.sendCalls.push({ data: args[0] });
52
+ }
53
+ const callback = args.find((arg) => typeof arg === 'function');
54
+ if (callback) {
55
+ callback(null);
56
+ }
57
+ }
58
+
59
+ close() {
60
+ this.closed = true;
61
+ }
62
+
63
+ setRecvBufferSize() {}
64
+ setSendBufferSize() {}
65
+ }
66
+
67
+ class FakeTlsSocket extends EventEmitter {
68
+ setNoDelay() {}
69
+ pipe(destination) {
70
+ return destination;
71
+ }
72
+ }
73
+
74
+ class FakeConnection extends EventEmitter {
75
+ constructor() {
76
+ super();
77
+ this.connections = new Map();
78
+ this.sentResponses = [];
79
+ this.sentErrors = [];
80
+ this.portCloseCalls = [];
81
+ this.portSendCalls = [];
82
+ this.RPC = {
83
+ sendResponse: async (...args) => {
84
+ this.sentResponses.push(args);
85
+ },
86
+ sendError: async (...args) => {
87
+ this.sentErrors.push(args);
88
+ },
89
+ portClose: async (...args) => {
90
+ this.portCloseCalls.push(args);
91
+ },
92
+ portSend: async (...args) => {
93
+ this.portSendCalls.push(args);
94
+ },
95
+ };
96
+ }
97
+
98
+ addConnection(ref, connectionInfo) {
99
+ this.connections.set(ref.toString('hex'), connectionInfo);
100
+ }
101
+
102
+ getConnection(ref) {
103
+ return this.connections.get(ref.toString('hex'));
104
+ }
105
+
106
+ deleteConnection(ref) {
107
+ return this.connections.delete(ref.toString('hex'));
108
+ }
109
+
110
+ getClientSocket() {
111
+ return null;
112
+ }
113
+
114
+ getServerRelayHost() {
115
+ return 'relay.example';
116
+ }
117
+
118
+ getDeviceCertificate() {
119
+ return 'fake-cert';
120
+ }
121
+
122
+ getEthereumAddress() {
123
+ return '0x' + 'aa'.repeat(20);
124
+ }
125
+
126
+ getPrivateKey() {
127
+ return Buffer.alloc(32, 1);
128
+ }
129
+ }
130
+
131
+ function makeRef(value = '01') {
132
+ return Buffer.from(value.padStart(2, '0'), 'hex');
133
+ }
134
+
135
+ function makeSessionId(value = '02') {
136
+ return Buffer.from(value.padStart(2, '0'), 'hex');
137
+ }
138
+
139
+ function makeDeviceId(hexByte) {
140
+ const byte = hexByte.length === 1 ? hexByte.repeat(2) : hexByte;
141
+ return Buffer.from(byte.repeat(20), 'hex');
142
+ }
143
+
144
+ test('PublishPort array input defaults host to 127.0.0.1', () => {
145
+ const connection = new FakeConnection();
146
+ const publishPort = new PublishPort(connection, [8080]);
147
+
148
+ assert.deepEqual(publishPort.getPublishedPorts(), {
149
+ 8080: { mode: 'public', whitelist: [], host: '127.0.0.1' },
150
+ });
151
+
152
+ publishPort.stopListening();
153
+ });
154
+
155
+ test('PublishPort preserves explicit host in object config', () => {
156
+ const connection = new FakeConnection();
157
+ const publishPort = new PublishPort(connection, {
158
+ 8080: { mode: 'private', whitelist: ['0xabc'], host: ' backend.internal ' },
159
+ });
160
+
161
+ assert.deepEqual(publishPort.getPublishedPorts(), {
162
+ 8080: { mode: 'private', whitelist: ['0xabc'], host: 'backend.internal' },
163
+ });
164
+
165
+ publishPort.stopListening();
166
+ });
167
+
168
+ test('PublishPort rejects invalid host values', () => {
169
+ const connection = new FakeConnection();
170
+
171
+ assert.throws(() => {
172
+ new PublishPort(connection, { 8080: { mode: 'public', host: ' ' } });
173
+ }, TypeError);
174
+
175
+ assert.throws(() => {
176
+ new PublishPort(connection, { 8080: { mode: 'public', host: 42 } });
177
+ }, TypeError);
178
+ });
179
+
180
+ test('TCP publish connects to configured host', () => {
181
+ const connection = new FakeConnection();
182
+ const publishPort = new PublishPort(connection, {
183
+ 8080: { mode: 'public', host: '192.168.1.10' },
184
+ });
185
+ const originalConnect = net.connect;
186
+ const connectCalls = [];
187
+
188
+ net.connect = (options, callback) => {
189
+ const socket = new FakeStreamSocket();
190
+ connectCalls.push(options);
191
+ process.nextTick(() => {
192
+ if (typeof callback === 'function') {
193
+ callback();
194
+ }
195
+ });
196
+ return socket;
197
+ };
198
+
199
+ try {
200
+ publishPort.handleTCPConnection(makeSessionId(), makeRef(), 8080, '0x' + '11'.repeat(20), publishPort.getPublishedPorts()[8080], connection);
201
+ } finally {
202
+ net.connect = originalConnect;
203
+ publishPort.stopListening();
204
+ }
205
+
206
+ assert.equal(connectCalls.length, 1);
207
+ assert.deepEqual(connectCalls[0], { port: 8080, host: '192.168.1.10' });
208
+ });
209
+
210
+ test('TLS publish backend socket connects to configured host', () => {
211
+ const connection = new FakeConnection();
212
+ const publishPort = new PublishPort(connection, {
213
+ 8443: { mode: 'public', host: 'backend.internal' },
214
+ });
215
+ const originalConnect = net.connect;
216
+ const originalTlsSocket = tls.TLSSocket;
217
+ const connectCalls = [];
218
+
219
+ net.connect = (options, callback) => {
220
+ const socket = new FakeStreamSocket();
221
+ connectCalls.push(options);
222
+ process.nextTick(() => {
223
+ if (typeof callback === 'function') {
224
+ callback();
225
+ }
226
+ });
227
+ return socket;
228
+ };
229
+ tls.TLSSocket = FakeTlsSocket;
230
+
231
+ try {
232
+ publishPort.handleTLSConnection(makeSessionId(), makeRef('03'), 8443, '0x' + '11'.repeat(20), publishPort.getPublishedPorts()[8443], connection);
233
+ } finally {
234
+ net.connect = originalConnect;
235
+ tls.TLSSocket = originalTlsSocket;
236
+ publishPort.stopListening();
237
+ }
238
+
239
+ assert.equal(connectCalls.length, 1);
240
+ assert.deepEqual(connectCalls[0], { port: 8443, host: 'backend.internal' });
241
+ });
242
+
243
+ test('UDP publish stores and sends to configured host', () => {
244
+ const connection = new FakeConnection();
245
+ const publishPort = new PublishPort(connection, {
246
+ 5353: { mode: 'public', host: '192.168.1.20' },
247
+ });
248
+ const originalCreateSocket = dgram.createSocket;
249
+ const sockets = [];
250
+
251
+ dgram.createSocket = () => {
252
+ const socket = new FakeDatagramSocket();
253
+ sockets.push(socket);
254
+ return socket;
255
+ };
256
+
257
+ try {
258
+ const ref = makeRef('04');
259
+ publishPort.handleUDPConnection(makeSessionId('05'), ref, 5353, '0x' + '11'.repeat(20), publishPort.getPublishedPorts()[5353], connection);
260
+ publishPort.handlePortSend(makeSessionId('06'), ['portsend', ref, Buffer.from('hello')], connection);
261
+
262
+ const connectionInfo = connection.getConnection(ref);
263
+ assert.deepEqual(connectionInfo.remoteInfo, { port: 5353, address: '192.168.1.20' });
264
+ assert.equal(sockets[0].sendCalls.length, 1);
265
+ assert.equal(sockets[0].sendCalls[0].address, '192.168.1.20');
266
+ assert.equal(sockets[0].sendCalls[0].port, 5353);
267
+ } finally {
268
+ dgram.createSocket = originalCreateSocket;
269
+ publishPort.stopListening();
270
+ }
271
+ });
272
+
273
+ test('native TCP publish connects local socket to configured host', () => {
274
+ const connection = new FakeConnection();
275
+ const publishPort = new PublishPort(connection, {
276
+ 8089: { mode: 'public', host: '10.0.0.8' },
277
+ });
278
+ const originalConnect = net.connect;
279
+ const connectCalls = [];
280
+
281
+ net.connect = (options, callback) => {
282
+ const socket = new FakeStreamSocket();
283
+ connectCalls.push(options);
284
+ process.nextTick(() => {
285
+ if (typeof callback === 'function') {
286
+ callback();
287
+ }
288
+ socket.emit('connect');
289
+ });
290
+ return socket;
291
+ };
292
+
293
+ try {
294
+ publishPort.handleNativeTCPRelay(
295
+ makeSessionId('07'),
296
+ 41000,
297
+ { physicalPort: 41000, port: 8089, host: '10.0.0.8', deviceId: '0x' + '11'.repeat(20), ready: false, session: null },
298
+ connection
299
+ );
300
+ } finally {
301
+ net.connect = originalConnect;
302
+ publishPort.stopListening();
303
+ }
304
+
305
+ assert.equal(connectCalls.length, 2);
306
+ assert.deepEqual(connectCalls[0], { host: 'relay.example', port: 41000 });
307
+ assert.deepEqual(connectCalls[1], { port: 8089, host: '10.0.0.8' });
308
+ });
309
+
310
+ test('native UDP publish connects local socket to configured host', () => {
311
+ const connection = new FakeConnection();
312
+ const publishPort = new PublishPort(connection, {
313
+ 8090: { mode: 'public', host: 'backend.internal' },
314
+ });
315
+ const originalCreateSocket = dgram.createSocket;
316
+ const sockets = [];
317
+
318
+ dgram.createSocket = () => {
319
+ const socket = new FakeDatagramSocket();
320
+ sockets.push(socket);
321
+ return socket;
322
+ };
323
+
324
+ try {
325
+ publishPort.handleNativeUDPRelay(
326
+ makeSessionId('08'),
327
+ 41001,
328
+ { physicalPort: 41001, port: 8090, host: 'backend.internal', deviceId: '0x' + '11'.repeat(20), ready: false, session: null },
329
+ connection
330
+ );
331
+ } finally {
332
+ dgram.createSocket = originalCreateSocket;
333
+ publishPort.stopListening();
334
+ }
335
+
336
+ assert.equal(sockets.length, 2);
337
+ assert.deepEqual(sockets[0].connectCalls[0], { port: 41001, host: 'relay.example' });
338
+ assert.deepEqual(sockets[1].connectCalls[0], { port: 8090, host: 'backend.internal' });
339
+ });
340
+
341
+ test('handlePortOpen preserves localhost default when host is omitted', () => {
342
+ const connection = new FakeConnection();
343
+ const publishPort = new PublishPort(connection, [8081]);
344
+ const originalConnect = net.connect;
345
+ const connectCalls = [];
346
+
347
+ net.connect = (options, callback) => {
348
+ const socket = new FakeStreamSocket();
349
+ connectCalls.push(options);
350
+ process.nextTick(() => {
351
+ if (typeof callback === 'function') {
352
+ callback();
353
+ }
354
+ });
355
+ return socket;
356
+ };
357
+
358
+ try {
359
+ publishPort.handlePortOpen(
360
+ makeSessionId('09'),
361
+ ['portopen', '8081', makeRef('0a'), makeDeviceId('1')],
362
+ connection
363
+ );
364
+ } finally {
365
+ net.connect = originalConnect;
366
+ publishPort.stopListening();
367
+ }
368
+
369
+ assert.deepEqual(connectCalls[0], { port: 8081, host: '127.0.0.1' });
370
+ });
371
+
372
+ test('handlePortOpen rejects non-whitelisted devices before connecting', () => {
373
+ const connection = new FakeConnection();
374
+ const publishPort = new PublishPort(connection, {
375
+ 3000: { mode: 'private', whitelist: ['0x' + '22'.repeat(20)], host: '192.168.1.30' },
376
+ });
377
+ const originalConnect = net.connect;
378
+ let connectCalled = false;
379
+
380
+ net.connect = () => {
381
+ connectCalled = true;
382
+ return new FakeStreamSocket();
383
+ };
384
+
385
+ try {
386
+ publishPort.handlePortOpen(
387
+ makeSessionId('0b'),
388
+ ['portopen', '3000', makeRef('0c'), makeDeviceId('1')],
389
+ connection
390
+ );
391
+ } finally {
392
+ net.connect = originalConnect;
393
+ publishPort.stopListening();
394
+ }
395
+
396
+ assert.equal(connectCalled, false);
397
+ assert.equal(connection.sentErrors.length, 1);
398
+ assert.equal(connection.sentErrors[0][2], 'Device not whitelisted');
399
+ });
package/utils.js CHANGED
@@ -199,6 +199,30 @@ function ensureDirectoryExistence(filePath) {
199
199
  fs.mkdirSync(dirname);
200
200
  }
201
201
 
202
+ const DEFAULT_FLEET_CONTRACT = '0x6000000000000000000000000000000000000000';
203
+
204
+ function normalizeFleetContractAddress(value) {
205
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
206
+ const buffer = toBufferView(value);
207
+ if (buffer.length !== 20) {
208
+ throw new Error('fleetContract must be a 20-byte EVM address');
209
+ }
210
+ return `0x${buffer.toString('hex')}`.toLowerCase();
211
+ }
212
+
213
+ if (typeof value !== 'string') {
214
+ throw new Error('fleetContract must be a 20-byte EVM address hex string');
215
+ }
216
+
217
+ const trimmed = value.trim();
218
+ const hex = trimmed.toLowerCase().startsWith('0x') ? trimmed.slice(2) : trimmed;
219
+ if (!/^[0-9a-fA-F]{40}$/.test(hex)) {
220
+ throw new Error('fleetContract must be a 20-byte EVM address hex string');
221
+ }
222
+
223
+ return `0x${hex.toLowerCase()}`;
224
+ }
225
+
202
226
  module.exports = {
203
227
  makeReadable,
204
228
  parseRequestId,
@@ -209,4 +233,6 @@ module.exports = {
209
233
  loadOrGenerateKeyPair,
210
234
  ensureDirectoryExistence,
211
235
  toBufferView,
236
+ DEFAULT_FLEET_CONTRACT,
237
+ normalizeFleetContractAddress,
212
238
  };