diodejs 0.4.0 → 0.4.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.
@@ -0,0 +1,1376 @@
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 EventEmitter = require('events');
7
+ const { WebSocketServer } = require('ws');
8
+
9
+ const DiodeClientManager = require('../clientManager');
10
+ const { fetchNetworkDirectory } = require('../networkDiscoveryClient');
11
+
12
+ const networkSnapshot = JSON.parse(
13
+ fs.readFileSync(path.join(__dirname, 'fixtures', 'dio-network-snapshot.json'), 'utf8')
14
+ );
15
+
16
+ function delay(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+
20
+ function makeTempDir() {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'diode-relay-test-'));
22
+ }
23
+
24
+ function makeScoreCache(scoreCachePath, relays, options = {}) {
25
+ fs.mkdirSync(path.dirname(scoreCachePath), { recursive: true });
26
+ fs.writeFileSync(scoreCachePath, JSON.stringify({
27
+ version: options.version || 1,
28
+ updatedAt: Date.now(),
29
+ discoveryState: options.discoveryState,
30
+ relays,
31
+ }, null, 2), 'utf8');
32
+ }
33
+
34
+ function extractHosts(candidates) {
35
+ return candidates.map((candidate) => (typeof candidate === 'string' ? candidate : candidate.hostKey));
36
+ }
37
+
38
+ async function extractStartupHosts(manager) {
39
+ return extractHosts(await manager._buildStartupCandidates());
40
+ }
41
+
42
+ class FakeConnection extends EventEmitter {
43
+ constructor(hostKey, options = {}) {
44
+ super();
45
+ const parts = /^(.*):(\d+)$/.exec(hostKey);
46
+ this.host = parts ? parts[1] : hostKey;
47
+ this.port = parts ? Number(parts[2]) : 41046;
48
+ this.socket = { destroyed: false };
49
+ this.closeCount = 0;
50
+ this.serverEthereumAddress = options.serverEthereumAddress || '0x' + Buffer.from(hostKey).toString('hex').slice(0, 40).padEnd(40, '0');
51
+ this.RPC = {
52
+ ping: async () => {
53
+ await delay(options.pingDelayMs || 0);
54
+ return options.pingResult !== false;
55
+ },
56
+ getObject: options.getObject || (async () => null),
57
+ getNode: options.getNode || (async () => null),
58
+ };
59
+ }
60
+
61
+ getServerEthereumAddress() {
62
+ return this.serverEthereumAddress;
63
+ }
64
+
65
+ setLocalAddressProvider(provider) {
66
+ this.localAddressProvider = provider;
67
+ }
68
+
69
+ close() {
70
+ this.closeCount += 1;
71
+ this.socket.destroyed = true;
72
+ }
73
+
74
+ async _ensureConnected() {
75
+ this.socket.destroyed = false;
76
+ }
77
+ }
78
+
79
+ class TestClientManager extends DiodeClientManager {
80
+ constructor(options = {}, hostBehaviors = new Map(), networkNodes = null) {
81
+ super(options);
82
+ this.hostBehaviors = hostBehaviors;
83
+ this.networkNodes = networkNodes;
84
+ this.ensureCalls = [];
85
+ }
86
+
87
+ async _ensureConnection(hostEntry) {
88
+ const hostKey = hostEntry;
89
+ this.ensureCalls.push(hostKey);
90
+
91
+ if (this.connectionByHost.has(hostKey)) {
92
+ return this.connectionByHost.get(hostKey);
93
+ }
94
+
95
+ const behavior = this.hostBehaviors.get(hostKey) || {};
96
+ if (behavior.connectDelayMs) {
97
+ await delay(behavior.connectDelayMs);
98
+ }
99
+ if (behavior.connectError) {
100
+ throw behavior.connectError;
101
+ }
102
+
103
+ const connection = behavior.connection || new FakeConnection(hostKey, behavior);
104
+ if (!this.connectionByHost.has(hostKey)) {
105
+ this._registerConnection(connection, hostKey);
106
+ }
107
+ connection._managerConnectedAt = connection._managerConnectedAt || Date.now();
108
+ this._updateServerIdMapping(connection);
109
+ return connection;
110
+ }
111
+
112
+ async _fetchNetworkDiscoveryNodes() {
113
+ if (this.networkNodes !== null) {
114
+ return this.networkNodes;
115
+ }
116
+ return super._fetchNetworkDiscoveryNodes();
117
+ }
118
+ }
119
+
120
+ test('tested candidates rank by measured latency', () => {
121
+ const tempDir = makeTempDir();
122
+ const keyLocation = path.join(tempDir, 'keys.json');
123
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
124
+ makeScoreCache(scoreCachePath, {
125
+ 'as1.prenet.diode.io:41046': {
126
+ ewmaLatencyMs: 180,
127
+ lastProbeLatencyMs: 180,
128
+ successCount: 2,
129
+ failureCount: 0,
130
+ lastSuccessAt: Date.now(),
131
+ lastFailureAt: 0,
132
+ cooldownUntil: 0,
133
+ discoveredFrom: 'seed',
134
+ },
135
+ 'eu1.prenet.diode.io:41046': {
136
+ ewmaLatencyMs: 35,
137
+ lastProbeLatencyMs: 35,
138
+ successCount: 3,
139
+ failureCount: 0,
140
+ lastSuccessAt: Date.now(),
141
+ lastFailureAt: 0,
142
+ cooldownUntil: 0,
143
+ discoveredFrom: 'seed',
144
+ },
145
+ });
146
+
147
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath, networkDiscovery: { enabled: false } } });
148
+ const ranked = extractHosts(manager._rankRelayCandidates([
149
+ 'as1.prenet.diode.io:41046',
150
+ 'eu1.prenet.diode.io:41046',
151
+ ]));
152
+
153
+ assert.equal(ranked[0], 'eu1.prenet.diode.io:41046');
154
+ manager.close();
155
+ });
156
+
157
+ test('unknown relays rank behind known-good relays and ahead of cooldown relays', () => {
158
+ const tempDir = makeTempDir();
159
+ const keyLocation = path.join(tempDir, 'keys.json');
160
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null } });
161
+
162
+ manager.relayScores.set('known:41046', {
163
+ hostKey: 'known:41046',
164
+ ewmaLatencyMs: 10,
165
+ lastProbeLatencyMs: 10,
166
+ successCount: 1,
167
+ failureCount: 0,
168
+ lastSuccessAt: Date.now(),
169
+ lastFailureAt: 0,
170
+ cooldownUntil: 0,
171
+ discoveredFrom: 'seed',
172
+ });
173
+ manager.relayScores.set('cooldown:41046', {
174
+ hostKey: 'cooldown:41046',
175
+ ewmaLatencyMs: 5,
176
+ lastProbeLatencyMs: 5,
177
+ successCount: 1,
178
+ failureCount: 1,
179
+ lastSuccessAt: Date.now(),
180
+ lastFailureAt: Date.now(),
181
+ cooldownUntil: Date.now() + 60000,
182
+ discoveredFrom: 'seed',
183
+ });
184
+
185
+ const ranked = extractHosts(manager._rankRelayCandidates(['unknown:41046', 'cooldown:41046', 'known:41046']));
186
+ assert.deepEqual(ranked, ['known:41046', 'unknown:41046', 'cooldown:41046']);
187
+ manager.close();
188
+ });
189
+
190
+ test('connect probes all explicit hosts before resolving', async () => {
191
+ const tempDir = makeTempDir();
192
+ const keyLocation = path.join(tempDir, 'keys.json');
193
+ const manager = new TestClientManager({
194
+ keyLocation,
195
+ hosts: ['fast:41046', 'fail:41046', 'unused:41046'],
196
+ relaySelection: {
197
+ scoreCachePath: null,
198
+ startupConcurrency: 2,
199
+ minReadyConnections: 1,
200
+ probeTimeoutMs: 200,
201
+ },
202
+ }, new Map([
203
+ ['fast:41046', { connectDelayMs: 20, pingDelayMs: 40 }],
204
+ ['fail:41046', { connectDelayMs: 5, pingDelayMs: 5, pingResult: false }],
205
+ ['unused:41046', { connectDelayMs: 1, pingDelayMs: 1 }],
206
+ ]));
207
+
208
+ const startedAt = Date.now();
209
+ await manager.connect();
210
+ const elapsedMs = Date.now() - startedAt;
211
+
212
+ assert.ok(elapsedMs >= 50, `expected bounded startup probe wait, got ${elapsedMs}ms`);
213
+ assert.deepEqual(manager.ensureCalls.sort(), ['fail:41046', 'fast:41046', 'unused:41046']);
214
+ manager.close();
215
+ });
216
+
217
+ test('getNearestConnection returns the lowest-latency connected relay', () => {
218
+ const tempDir = makeTempDir();
219
+ const keyLocation = path.join(tempDir, 'keys.json');
220
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null } });
221
+
222
+ const slow = new FakeConnection('slow:41046');
223
+ const fast = new FakeConnection('fast:41046');
224
+ const unknown = new FakeConnection('unknown:41046');
225
+ manager._registerConnection(slow, 'slow:41046');
226
+ manager._registerConnection(fast, 'fast:41046');
227
+ manager._registerConnection(unknown, 'unknown:41046');
228
+ slow._managerConnectedAt = 1;
229
+ fast._managerConnectedAt = 2;
230
+ unknown._managerConnectedAt = 0;
231
+
232
+ manager.relayScores.set('slow:41046', {
233
+ hostKey: 'slow:41046',
234
+ ewmaLatencyMs: 90,
235
+ lastProbeLatencyMs: 90,
236
+ successCount: 1,
237
+ failureCount: 0,
238
+ lastSuccessAt: Date.now(),
239
+ lastFailureAt: 0,
240
+ cooldownUntil: 0,
241
+ discoveredFrom: 'seed',
242
+ });
243
+ manager.relayScores.set('fast:41046', {
244
+ hostKey: 'fast:41046',
245
+ ewmaLatencyMs: 15,
246
+ lastProbeLatencyMs: 15,
247
+ successCount: 1,
248
+ failureCount: 0,
249
+ lastSuccessAt: Date.now(),
250
+ lastFailureAt: 0,
251
+ cooldownUntil: 0,
252
+ discoveredFrom: 'seed',
253
+ });
254
+
255
+ assert.equal(manager.getNearestConnection(), fast);
256
+ assert.equal(manager.getNearestConnection(), fast);
257
+ manager.close();
258
+ });
259
+
260
+ test('failed probes place relays into cooldown', () => {
261
+ const tempDir = makeTempDir();
262
+ const keyLocation = path.join(tempDir, 'keys.json');
263
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null } });
264
+
265
+ const startedAt = Date.now();
266
+ manager._recordRelayProbeFailure('relay:41046', new Error('boom'));
267
+ const score = manager.relayScores.get('relay:41046');
268
+
269
+ assert.ok(score.cooldownUntil > startedAt);
270
+ const ranked = extractHosts(manager._rankRelayCandidates(['relay:41046', 'unknown:41046']));
271
+ assert.deepEqual(ranked, ['unknown:41046', 'relay:41046']);
272
+ manager.close();
273
+ });
274
+
275
+ test('score cache load ignores corrupt files', () => {
276
+ const tempDir = makeTempDir();
277
+ const keyLocation = path.join(tempDir, 'keys.json');
278
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
279
+ fs.writeFileSync(scoreCachePath, '{not valid json', 'utf8');
280
+
281
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath } });
282
+ assert.equal(manager.relayScores.size, 0);
283
+ manager.close();
284
+ });
285
+
286
+ test('score cache write persists relay metadata', () => {
287
+ const tempDir = makeTempDir();
288
+ const keyLocation = path.join(tempDir, 'keys.json');
289
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
290
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath } });
291
+
292
+ manager._recordRelayProbeSuccess('persisted:41046', 42, 'target');
293
+ manager._flushRelayScores();
294
+
295
+ const written = JSON.parse(fs.readFileSync(scoreCachePath, 'utf8'));
296
+ assert.equal(written.version, 2);
297
+ assert.equal(written.relays['persisted:41046'].ewmaLatencyMs, 42);
298
+ assert.equal(written.relays['persisted:41046'].discoveredFrom, 'target');
299
+ manager.close();
300
+ });
301
+
302
+ test('on-demand target relay resolution records a new relay score', async () => {
303
+ const tempDir = makeTempDir();
304
+ const keyLocation = path.join(tempDir, 'keys.json');
305
+ const primary = new FakeConnection('primary:41046', {
306
+ getObject: async () => ({ serverIdHex: '0xaabb' }),
307
+ getNode: async () => ({ host: 'target', edgePort: 41046, serverPort: 41046 }),
308
+ });
309
+ const target = new FakeConnection('target:41046', { serverEthereumAddress: '0xaabb', pingDelayMs: 5 });
310
+ const manager = new TestClientManager({ keyLocation, relaySelection: { scoreCachePath: null } }, new Map([
311
+ ['target:41046', { connection: target, pingDelayMs: 5 }],
312
+ ]));
313
+
314
+ manager._registerConnection(primary, 'primary:41046');
315
+ primary._managerConnectedAt = 1;
316
+ manager.relayScores.set('primary:41046', {
317
+ hostKey: 'primary:41046',
318
+ ewmaLatencyMs: 10,
319
+ lastProbeLatencyMs: 10,
320
+ successCount: 1,
321
+ failureCount: 0,
322
+ lastSuccessAt: Date.now(),
323
+ lastFailureAt: 0,
324
+ cooldownUntil: 0,
325
+ discoveredFrom: 'seed',
326
+ });
327
+
328
+ const connection = await manager.getConnectionForDevice('0x01');
329
+ const score = manager.relayScores.get('target:41046');
330
+
331
+ assert.equal(connection, target);
332
+ assert.ok(score);
333
+ assert.equal(score.discoveredFrom, 'target');
334
+ assert.ok(score.successCount >= 1);
335
+ manager.close();
336
+ });
337
+
338
+ test('slow target relay resolution stores shortened device cache ttl', async () => {
339
+ const tempDir = makeTempDir();
340
+ const keyLocation = path.join(tempDir, 'keys.json');
341
+ const primary = new FakeConnection('primary:41046', {
342
+ getObject: async () => ({ serverIdHex: '0xccdd' }),
343
+ getNode: async () => ({ host: 'slow-target', edgePort: 41046, serverPort: 41046 }),
344
+ });
345
+ const target = new FakeConnection('slow-target:41046', { serverEthereumAddress: '0xccdd', pingDelayMs: 30 });
346
+ const manager = new TestClientManager({
347
+ keyLocation,
348
+ relaySelection: {
349
+ scoreCachePath: null,
350
+ slowRelayThresholdMs: 20,
351
+ slowDeviceRetryTtlMs: 1234,
352
+ probeTimeoutMs: 200,
353
+ },
354
+ }, new Map([
355
+ ['slow-target:41046', { connection: target, pingDelayMs: 30 }],
356
+ ]));
357
+
358
+ manager._registerConnection(primary, 'primary:41046');
359
+ primary._managerConnectedAt = 1;
360
+ manager.relayScores.set('primary:41046', {
361
+ hostKey: 'primary:41046',
362
+ ewmaLatencyMs: 10,
363
+ lastProbeLatencyMs: 10,
364
+ successCount: 1,
365
+ failureCount: 0,
366
+ lastSuccessAt: Date.now(),
367
+ lastFailureAt: 0,
368
+ cooldownUntil: 0,
369
+ discoveredFrom: 'seed',
370
+ });
371
+
372
+ await manager.getConnectionForDevice('0x02');
373
+ const entry = manager.deviceRelayCache.get('02');
374
+
375
+ assert.equal(entry.ttlMs, 1234);
376
+ manager.close();
377
+ });
378
+
379
+ test('device relay reconciliation switches to a better target relay from an alternate control relay', async () => {
380
+ const tempDir = makeTempDir();
381
+ const keyLocation = path.join(tempDir, 'keys.json');
382
+ const primary = new FakeConnection('primary:41046', {
383
+ getObject: async () => ({ serverIdHex: '0xaaaa' }),
384
+ getNode: async () => ({ host: 'slow-target', edgePort: 41046, serverPort: 41046 }),
385
+ });
386
+ const alternate = new FakeConnection('alternate:41046', {
387
+ getObject: async () => ({ serverIdHex: '0xbbbb' }),
388
+ getNode: async () => ({ host: 'fast-target', edgePort: 41046, serverPort: 41046 }),
389
+ });
390
+ const fastTarget = new FakeConnection('fast-target:41046', { serverEthereumAddress: '0xbbbb', pingDelayMs: 20 });
391
+ const slowTarget = new FakeConnection('slow-target:41046', { serverEthereumAddress: '0xaaaa', pingDelayMs: 250 });
392
+ const manager = new TestClientManager({
393
+ keyLocation,
394
+ relaySelection: {
395
+ scoreCachePath: null,
396
+ probeTimeoutMs: 1000,
397
+ slowRelayThresholdMs: 100,
398
+ deviceRelayReconciliation: {
399
+ enabled: true,
400
+ maxControlRelays: 1,
401
+ timeoutMs: 200,
402
+ minLatencyDeltaMs: 50,
403
+ slowdownFactor: 2,
404
+ },
405
+ },
406
+ }, new Map([
407
+ ['slow-target:41046', { connection: slowTarget, pingDelayMs: 250 }],
408
+ ['fast-target:41046', { connection: fastTarget, pingDelayMs: 20 }],
409
+ ]));
410
+
411
+ manager._registerConnection(primary, 'primary:41046');
412
+ manager._registerConnection(alternate, 'alternate:41046');
413
+ primary._managerConnectedAt = 1;
414
+ alternate._managerConnectedAt = 2;
415
+ manager.relayScores.set('primary:41046', {
416
+ hostKey: 'primary:41046',
417
+ ewmaLatencyMs: 10,
418
+ lastProbeLatencyMs: 10,
419
+ successCount: 1,
420
+ failureCount: 0,
421
+ lastSuccessAt: Date.now(),
422
+ lastFailureAt: 0,
423
+ cooldownUntil: 0,
424
+ discoveredFrom: 'seed',
425
+ });
426
+ manager.relayScores.set('alternate:41046', {
427
+ hostKey: 'alternate:41046',
428
+ ewmaLatencyMs: 12,
429
+ lastProbeLatencyMs: 12,
430
+ successCount: 1,
431
+ failureCount: 0,
432
+ lastSuccessAt: Date.now(),
433
+ lastFailureAt: 0,
434
+ cooldownUntil: 0,
435
+ discoveredFrom: 'seed',
436
+ });
437
+
438
+ const connection = await manager.getConnectionForDevice('0x03');
439
+ const cacheEntry = manager.deviceRelayCache.get('03');
440
+
441
+ assert.equal(connection, fastTarget);
442
+ assert.equal(cacheEntry.serverIdHex, '0xbbbb');
443
+ assert.equal(cacheEntry.hostKey, 'fast-target:41046');
444
+ assert.ok(manager.ensureCalls.includes('slow-target:41046'));
445
+ assert.ok(manager.ensureCalls.includes('fast-target:41046'));
446
+ manager.close();
447
+ });
448
+
449
+ test('device relay reconciliation does not query alternate control relays when target relay is not suspiciously slow', async () => {
450
+ const tempDir = makeTempDir();
451
+ const keyLocation = path.join(tempDir, 'keys.json');
452
+ let alternateGetObjectCalls = 0;
453
+ const primary = new FakeConnection('primary:41046', {
454
+ getObject: async () => ({ serverIdHex: '0xaaaa' }),
455
+ getNode: async () => ({ host: 'acceptable-target', edgePort: 41046, serverPort: 41046 }),
456
+ });
457
+ const alternate = new FakeConnection('alternate:41046', {
458
+ getObject: async () => {
459
+ alternateGetObjectCalls += 1;
460
+ return { serverIdHex: '0xbbbb' };
461
+ },
462
+ getNode: async () => ({ host: 'fast-target', edgePort: 41046, serverPort: 41046 }),
463
+ });
464
+ const acceptableTarget = new FakeConnection('acceptable-target:41046', { serverEthereumAddress: '0xaaaa', pingDelayMs: 40 });
465
+ const manager = new TestClientManager({
466
+ keyLocation,
467
+ relaySelection: {
468
+ scoreCachePath: null,
469
+ probeTimeoutMs: 500,
470
+ slowRelayThresholdMs: 100,
471
+ deviceRelayReconciliation: {
472
+ enabled: true,
473
+ maxControlRelays: 1,
474
+ timeoutMs: 200,
475
+ minLatencyDeltaMs: 50,
476
+ slowdownFactor: 2,
477
+ },
478
+ },
479
+ }, new Map([
480
+ ['acceptable-target:41046', { connection: acceptableTarget, pingDelayMs: 40 }],
481
+ ]));
482
+
483
+ manager._registerConnection(primary, 'primary:41046');
484
+ manager._registerConnection(alternate, 'alternate:41046');
485
+ primary._managerConnectedAt = 1;
486
+ alternate._managerConnectedAt = 2;
487
+ manager.relayScores.set('primary:41046', {
488
+ hostKey: 'primary:41046',
489
+ ewmaLatencyMs: 15,
490
+ lastProbeLatencyMs: 15,
491
+ successCount: 1,
492
+ failureCount: 0,
493
+ lastSuccessAt: Date.now(),
494
+ lastFailureAt: 0,
495
+ cooldownUntil: 0,
496
+ discoveredFrom: 'seed',
497
+ });
498
+ manager.relayScores.set('alternate:41046', {
499
+ hostKey: 'alternate:41046',
500
+ ewmaLatencyMs: 20,
501
+ lastProbeLatencyMs: 20,
502
+ successCount: 1,
503
+ failureCount: 0,
504
+ lastSuccessAt: Date.now(),
505
+ lastFailureAt: 0,
506
+ cooldownUntil: 0,
507
+ discoveredFrom: 'seed',
508
+ });
509
+
510
+ const connection = await manager.getConnectionForDevice('0x04');
511
+ const cacheEntry = manager.deviceRelayCache.get('04');
512
+
513
+ assert.equal(connection, acceptableTarget);
514
+ assert.equal(cacheEntry.serverIdHex, '0xaaaa');
515
+ assert.equal(alternateGetObjectCalls, 0);
516
+ manager.close();
517
+ });
518
+
519
+ test('explicit host mode does not add cached relays to startup candidates', async () => {
520
+ const tempDir = makeTempDir();
521
+ const keyLocation = path.join(tempDir, 'keys.json');
522
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
523
+ makeScoreCache(scoreCachePath, {
524
+ 'cached:41046': {
525
+ ewmaLatencyMs: 10,
526
+ lastProbeLatencyMs: 10,
527
+ successCount: 1,
528
+ failureCount: 0,
529
+ lastSuccessAt: Date.now(),
530
+ lastFailureAt: 0,
531
+ cooldownUntil: 0,
532
+ discoveredFrom: 'target',
533
+ },
534
+ });
535
+
536
+ const manager = new DiodeClientManager({
537
+ keyLocation,
538
+ host: 'explicit:41046',
539
+ relaySelection: { scoreCachePath },
540
+ });
541
+
542
+ assert.deepEqual(await extractStartupHosts(manager), ['explicit:41046']);
543
+ manager.close();
544
+ });
545
+
546
+ test('explicit hosts mode only keeps configured relays in startup coverage', async () => {
547
+ const tempDir = makeTempDir();
548
+ const keyLocation = path.join(tempDir, 'keys.json');
549
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
550
+ makeScoreCache(scoreCachePath, {
551
+ 'cached:41046': {
552
+ ewmaLatencyMs: 10,
553
+ lastProbeLatencyMs: 10,
554
+ successCount: 1,
555
+ failureCount: 0,
556
+ lastSuccessAt: Date.now(),
557
+ lastFailureAt: 0,
558
+ cooldownUntil: 0,
559
+ discoveredFrom: 'target',
560
+ },
561
+ });
562
+
563
+ const manager = new DiodeClientManager({
564
+ keyLocation,
565
+ hosts: ['b:41046', 'a:41046'],
566
+ relaySelection: { scoreCachePath },
567
+ });
568
+ manager.relayScores.set('a:41046', {
569
+ hostKey: 'a:41046',
570
+ ewmaLatencyMs: 20,
571
+ lastProbeLatencyMs: 20,
572
+ successCount: 1,
573
+ failureCount: 0,
574
+ lastSuccessAt: Date.now(),
575
+ lastFailureAt: 0,
576
+ cooldownUntil: 0,
577
+ discoveredFrom: 'configured',
578
+ });
579
+
580
+ const startupCandidates = await manager._buildStartupCandidates();
581
+ assert.deepEqual(extractHosts(startupCandidates), ['b:41046', 'a:41046']);
582
+ assert.deepEqual(extractHosts(manager._getInitialCoverageCandidates(startupCandidates)), ['b:41046', 'a:41046']);
583
+ manager.close();
584
+ });
585
+
586
+ test('default mode probes all default seeds before final pruning', async () => {
587
+ const tempDir = makeTempDir();
588
+ const keyLocation = path.join(tempDir, 'keys.json');
589
+ const manager = new TestClientManager({
590
+ keyLocation,
591
+ relaySelection: {
592
+ scoreCachePath: null,
593
+ startupConcurrency: 3,
594
+ warmConnectionBudget: 3,
595
+ networkDiscovery: { enabled: false },
596
+ },
597
+ }, new Map([
598
+ ['as1.prenet.diode.io:41046', { pingDelayMs: 60 }],
599
+ ['as2.prenet.diode.io:41046', { pingDelayMs: 55 }],
600
+ ['us1.prenet.diode.io:41046', { pingDelayMs: 40 }],
601
+ ['us2.prenet.diode.io:41046', { pingDelayMs: 35 }],
602
+ ['eu1.prenet.diode.io:41046', { pingDelayMs: 5 }],
603
+ ['eu2.prenet.diode.io:41046', { pingDelayMs: 25 }],
604
+ ]));
605
+
606
+ await manager.connect();
607
+
608
+ assert.deepEqual(new Set(manager.ensureCalls), new Set([
609
+ 'as1.prenet.diode.io:41046',
610
+ 'as2.prenet.diode.io:41046',
611
+ 'us1.prenet.diode.io:41046',
612
+ 'us2.prenet.diode.io:41046',
613
+ 'eu1.prenet.diode.io:41046',
614
+ 'eu2.prenet.diode.io:41046',
615
+ ]));
616
+ assert.equal(manager.getNearestConnection()._managerHostKey, 'eu1.prenet.diode.io:41046');
617
+ assert.equal(manager.getConnections().length, 3);
618
+ assert.deepEqual(new Set(manager.getConnections().map((connection) => manager._getRegionKey(connection._managerHostKey))), new Set([
619
+ 'as',
620
+ 'us',
621
+ 'eu',
622
+ ]));
623
+ manager.close();
624
+ });
625
+
626
+ test('region-diverse ordering interleaves default seeds', async () => {
627
+ const tempDir = makeTempDir();
628
+ const keyLocation = path.join(tempDir, 'keys.json');
629
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null, networkDiscovery: { enabled: false } } });
630
+
631
+ const ordered = extractHosts(manager._getInitialCoverageCandidates(await manager._buildStartupCandidates()));
632
+ assert.deepEqual(ordered, [
633
+ 'as1.prenet.diode.io:41046',
634
+ 'us1.prenet.diode.io:41046',
635
+ 'eu1.prenet.diode.io:41046',
636
+ 'as2.prenet.diode.io:41046',
637
+ 'us2.prenet.diode.io:41046',
638
+ 'eu2.prenet.diode.io:41046',
639
+ ]);
640
+ manager.close();
641
+ });
642
+
643
+ test('cached strong relays do not suppress first-pass sampling of default seeds', async () => {
644
+ const tempDir = makeTempDir();
645
+ const keyLocation = path.join(tempDir, 'keys.json');
646
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
647
+ makeScoreCache(scoreCachePath, {
648
+ 'cached:41046': {
649
+ ewmaLatencyMs: 5,
650
+ lastProbeLatencyMs: 5,
651
+ successCount: 4,
652
+ failureCount: 0,
653
+ lastSuccessAt: Date.now(),
654
+ lastFailureAt: 0,
655
+ cooldownUntil: 0,
656
+ discoveredFrom: 'target',
657
+ },
658
+ });
659
+
660
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath, networkDiscovery: { enabled: false } } });
661
+ const ordered = extractHosts(manager._getInitialCoverageCandidates(await manager._buildStartupCandidates()));
662
+
663
+ assert.ok(!ordered.includes('cached:41046'));
664
+ assert.equal(ordered.length, 6);
665
+ manager.close();
666
+ });
667
+
668
+ test('network discovery client fetches dio_network over websocket', async () => {
669
+ const port = 19000 + Math.floor(Math.random() * 1000);
670
+ const server = new WebSocketServer({ port });
671
+ server.on('connection', (socket) => {
672
+ socket.on('message', (message) => {
673
+ const parsed = JSON.parse(message.toString('utf8'));
674
+ socket.send(JSON.stringify({
675
+ jsonrpc: '2.0',
676
+ id: parsed.id,
677
+ result: networkSnapshot.slice(0, 2),
678
+ }));
679
+ });
680
+ });
681
+
682
+ try {
683
+ const result = await fetchNetworkDirectory({
684
+ endpoint: `ws://127.0.0.1:${port}`,
685
+ method: 'dio_network',
686
+ timeoutMs: 500,
687
+ });
688
+ assert.equal(result.length, 2);
689
+ assert.equal(result[0].node_id, networkSnapshot[0].node_id);
690
+ } finally {
691
+ await new Promise((resolve) => server.close(resolve));
692
+ }
693
+ });
694
+
695
+ test('network discovery normalizes public server entries from snapshot fixtures', async () => {
696
+ const tempDir = makeTempDir();
697
+ const manager = new TestClientManager({
698
+ keyLocation: path.join(tempDir, 'keys.json'),
699
+ relaySelection: {
700
+ scoreCachePath: null,
701
+ networkDiscovery: { enabled: true },
702
+ },
703
+ }, new Map(), networkSnapshot);
704
+
705
+ const candidates = await manager._loadNetworkDiscoveryCandidates();
706
+ assert.deepEqual(extractHosts(candidates), ['144.126.157.138:41046', '194.233.80.251:41046']);
707
+ assert.equal(candidates[0].nodeIdHex, networkSnapshot[0].node_id);
708
+ assert.equal(candidates[0].name, 'pause_chalk@diode-us2b');
709
+ manager.close();
710
+ });
711
+
712
+ test('network discovery can include private addresses when enabled', async () => {
713
+ const tempDir = makeTempDir();
714
+ const manager = new TestClientManager({
715
+ keyLocation: path.join(tempDir, 'keys.json'),
716
+ relaySelection: {
717
+ scoreCachePath: null,
718
+ networkDiscovery: { enabled: true, includePrivateAddresses: true },
719
+ },
720
+ }, new Map(), networkSnapshot);
721
+
722
+ const candidates = await manager._loadNetworkDiscoveryCandidates();
723
+ assert.ok(candidates.some((candidate) => candidate.hostKey === '192.168.100.4:41046'));
724
+ manager.close();
725
+ });
726
+
727
+ test('network discovery timeout falls back to seed candidates only', async () => {
728
+ const tempDir = makeTempDir();
729
+ const manager = new TestClientManager({
730
+ keyLocation: path.join(tempDir, 'keys.json'),
731
+ relaySelection: {
732
+ scoreCachePath: null,
733
+ networkDiscovery: { enabled: true, timeoutMs: 10 },
734
+ },
735
+ });
736
+ manager._fetchNetworkDiscoveryNodes = async () => {
737
+ await delay(50);
738
+ return networkSnapshot;
739
+ };
740
+
741
+ const candidates = await manager._loadNetworkDiscoveryCandidates();
742
+ assert.deepEqual(candidates, []);
743
+ manager.close();
744
+ });
745
+
746
+ test('startup candidate assembly includes network candidates in default mode', async () => {
747
+ const tempDir = makeTempDir();
748
+ const manager = new TestClientManager({
749
+ keyLocation: path.join(tempDir, 'keys.json'),
750
+ relaySelection: {
751
+ scoreCachePath: null,
752
+ networkDiscovery: { enabled: true },
753
+ },
754
+ }, new Map(), networkSnapshot);
755
+
756
+ const startupHosts = await extractStartupHosts(manager);
757
+ assert.ok(startupHosts.includes('144.126.157.138:41046'));
758
+ assert.ok(startupHosts.includes('194.233.80.251:41046'));
759
+ manager.close();
760
+ });
761
+
762
+ test('explicit host skips built-in network discovery', async () => {
763
+ const tempDir = makeTempDir();
764
+ const manager = new TestClientManager({
765
+ keyLocation: path.join(tempDir, 'keys.json'),
766
+ host: 'explicit:41046',
767
+ relaySelection: {
768
+ scoreCachePath: null,
769
+ networkDiscovery: { enabled: true },
770
+ },
771
+ }, new Map(), networkSnapshot);
772
+
773
+ assert.deepEqual(await manager._loadNetworkDiscoveryCandidates(), []);
774
+ manager.close();
775
+ });
776
+
777
+ test('explicit hosts skip built-in network discovery', async () => {
778
+ const tempDir = makeTempDir();
779
+ const manager = new TestClientManager({
780
+ keyLocation: path.join(tempDir, 'keys.json'),
781
+ hosts: ['explicit-a:41046'],
782
+ relaySelection: {
783
+ scoreCachePath: null,
784
+ networkDiscovery: { enabled: true },
785
+ },
786
+ }, new Map(), networkSnapshot);
787
+
788
+ assert.deepEqual(await manager._loadNetworkDiscoveryCandidates(), []);
789
+ manager.close();
790
+ });
791
+
792
+ test('startup waits for required seeds plus bounded network sample', async () => {
793
+ const tempDir = makeTempDir();
794
+ const keyLocation = path.join(tempDir, 'keys.json');
795
+ const manager = new TestClientManager({
796
+ keyLocation,
797
+ relaySelection: {
798
+ scoreCachePath: null,
799
+ startupConcurrency: 4,
800
+ networkDiscovery: { enabled: true, startupProbeCount: 1, backgroundBatchSize: 0 },
801
+ },
802
+ }, new Map([
803
+ ['as1.prenet.diode.io:41046', { pingDelayMs: 40 }],
804
+ ['as2.prenet.diode.io:41046', { pingDelayMs: 35 }],
805
+ ['us1.prenet.diode.io:41046', { pingDelayMs: 30 }],
806
+ ['us2.prenet.diode.io:41046', { pingDelayMs: 25 }],
807
+ ['eu1.prenet.diode.io:41046', { pingDelayMs: 10 }],
808
+ ['eu2.prenet.diode.io:41046', { pingDelayMs: 15 }],
809
+ ['144.126.157.138:41046', { pingDelayMs: 5 }],
810
+ ]), networkSnapshot);
811
+
812
+ await manager.connect();
813
+
814
+ assert.ok(manager.ensureCalls.includes('144.126.157.138:41046'));
815
+ assert.equal(manager._lastNetworkDiscoveryStats.startupProbeCount, 1);
816
+ manager.close();
817
+ });
818
+
819
+ test('live network discovery reduces startup seed coverage to one seed per region', async () => {
820
+ const tempDir = makeTempDir();
821
+ const keyLocation = path.join(tempDir, 'keys.json');
822
+ const manager = new TestClientManager({
823
+ keyLocation,
824
+ relaySelection: {
825
+ scoreCachePath: null,
826
+ startupConcurrency: 6,
827
+ continueProbingUntestedSeeds: false,
828
+ networkDiscovery: { enabled: true, startupProbeCount: 1, backgroundBatchSize: 0 },
829
+ },
830
+ }, new Map([
831
+ ['as1.prenet.diode.io:41046', { pingDelayMs: 40 }],
832
+ ['us1.prenet.diode.io:41046', { pingDelayMs: 30 }],
833
+ ['eu1.prenet.diode.io:41046', { pingDelayMs: 10 }],
834
+ ['144.126.157.138:41046', { pingDelayMs: 5 }],
835
+ ]), networkSnapshot);
836
+
837
+ await manager.connect();
838
+
839
+ assert.deepEqual(new Set(manager.ensureCalls), new Set([
840
+ 'as1.prenet.diode.io:41046',
841
+ 'us1.prenet.diode.io:41046',
842
+ 'eu1.prenet.diode.io:41046',
843
+ '144.126.157.138:41046',
844
+ ]));
845
+ manager.close();
846
+ });
847
+
848
+ test('untested network candidates outrank cache-only candidates', () => {
849
+ const tempDir = makeTempDir();
850
+ const manager = new DiodeClientManager({ keyLocation: path.join(tempDir, 'keys.json'), relaySelection: { scoreCachePath: null } });
851
+
852
+ const ranked = extractHosts(manager._rankRelayCandidates([
853
+ manager._createCandidate('network-a:41046', 'network', 0, { retries: 0, lastSeenAt: 10 }),
854
+ manager._createCandidate('cache-a:41046', 'cache', 1),
855
+ ]));
856
+
857
+ assert.deepEqual(ranked, ['network-a:41046', 'cache-a:41046']);
858
+ manager.close();
859
+ });
860
+
861
+ test('network membership is not reused if the next discovery response omits it', async () => {
862
+ const tempDir = makeTempDir();
863
+ const keyLocation = path.join(tempDir, 'keys.json');
864
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
865
+ makeScoreCache(scoreCachePath, {
866
+ '144.126.157.138:41046': {
867
+ ewmaLatencyMs: 10,
868
+ lastProbeLatencyMs: 10,
869
+ successCount: 2,
870
+ failureCount: 0,
871
+ lastSuccessAt: Date.now(),
872
+ lastFailureAt: 0,
873
+ cooldownUntil: 0,
874
+ discoveredFrom: 'network',
875
+ },
876
+ }, { version: 2, discoveryState: { networkCursor: 1 } });
877
+
878
+ const manager = new TestClientManager({
879
+ keyLocation,
880
+ relaySelection: {
881
+ scoreCachePath,
882
+ networkDiscovery: { enabled: true },
883
+ },
884
+ }, new Map(), [networkSnapshot[2]]);
885
+
886
+ const hosts = await extractStartupHosts(manager);
887
+ assert.ok(!hosts.includes('144.126.157.138:41046'));
888
+ assert.ok(hosts.includes('194.233.80.251:41046'));
889
+ manager.close();
890
+ });
891
+
892
+ test('scored network candidates are reused when rediscovered', async () => {
893
+ const tempDir = makeTempDir();
894
+ const keyLocation = path.join(tempDir, 'keys.json');
895
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
896
+ makeScoreCache(scoreCachePath, {
897
+ '144.126.157.138:41046': {
898
+ ewmaLatencyMs: 11,
899
+ lastProbeLatencyMs: 11,
900
+ successCount: 2,
901
+ failureCount: 0,
902
+ lastSuccessAt: Date.now(),
903
+ lastFailureAt: 0,
904
+ cooldownUntil: 0,
905
+ discoveredFrom: 'network',
906
+ },
907
+ }, { version: 2 });
908
+
909
+ const manager = new TestClientManager({
910
+ keyLocation,
911
+ relaySelection: {
912
+ scoreCachePath,
913
+ networkDiscovery: { enabled: true, startupProbeCount: 1 },
914
+ },
915
+ }, new Map(), [networkSnapshot[0], networkSnapshot[2]]);
916
+
917
+ const candidates = await manager._buildStartupCandidates();
918
+ const selected = manager._selectStartupNetworkCandidates(candidates);
919
+ assert.equal(selected[0].hostKey, '144.126.157.138:41046');
920
+ manager.close();
921
+ });
922
+
923
+ test('network cursor rotates startup network samples across runs', async () => {
924
+ const tempDir = makeTempDir();
925
+ const keyLocation = path.join(tempDir, 'keys.json');
926
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
927
+ makeScoreCache(scoreCachePath, {}, { version: 2, discoveryState: { networkCursor: 1 } });
928
+ const rotatingSnapshot = [
929
+ { ...networkSnapshot[0], node_id: '0xaaa1', node: ['server', '10.10.10.10', '0xa056', '0xc76f', '1.9.3', [['name', 'skip-private']]], connected: true },
930
+ networkSnapshot[0],
931
+ networkSnapshot[2],
932
+ ];
933
+ const manager = new TestClientManager({
934
+ keyLocation,
935
+ relaySelection: {
936
+ scoreCachePath,
937
+ networkDiscovery: { enabled: true, startupProbeCount: 1, includePrivateAddresses: false },
938
+ },
939
+ }, new Map(), rotatingSnapshot);
940
+
941
+ const candidates = await manager._buildStartupCandidates();
942
+ const selected = manager._selectStartupNetworkCandidates(candidates);
943
+ assert.equal(selected[0].hostKey, '194.233.80.251:41046');
944
+ manager.close();
945
+ });
946
+
947
+ test('successful network probes are persisted with discoveredFrom network', () => {
948
+ const tempDir = makeTempDir();
949
+ const keyLocation = path.join(tempDir, 'keys.json');
950
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
951
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath } });
952
+
953
+ manager._recordRelayProbeSuccess('144.126.157.138:41046', 20, 'network');
954
+ manager._flushRelayScores();
955
+
956
+ const written = JSON.parse(fs.readFileSync(scoreCachePath, 'utf8'));
957
+ assert.equal(written.relays['144.126.157.138:41046'].discoveredFrom, 'network');
958
+ manager.close();
959
+ });
960
+
961
+ test('target-discovered relays are preserved during pruning', async () => {
962
+ const tempDir = makeTempDir();
963
+ const keyLocation = path.join(tempDir, 'keys.json');
964
+ const primary = new FakeConnection('primary:41046', {
965
+ getObject: async () => ({ serverIdHex: '0xffee' }),
966
+ getNode: async () => ({ host: 'target-relay', edgePort: 41046, serverPort: 41046 }),
967
+ });
968
+ const target = new FakeConnection('target-relay:41046', { serverEthereumAddress: '0xffee', pingDelayMs: 5 });
969
+ const manager = new TestClientManager({
970
+ keyLocation,
971
+ relaySelection: { scoreCachePath: null, warmConnectionBudget: 1 },
972
+ }, new Map([
973
+ ['target-relay:41046', { connection: target, pingDelayMs: 5 }],
974
+ ]));
975
+
976
+ manager._registerConnection(primary, 'primary:41046');
977
+ primary._managerConnectedAt = 1;
978
+ manager.relayScores.set('primary:41046', {
979
+ hostKey: 'primary:41046',
980
+ ewmaLatencyMs: 10,
981
+ lastProbeLatencyMs: 10,
982
+ successCount: 1,
983
+ failureCount: 0,
984
+ lastSuccessAt: Date.now(),
985
+ lastFailureAt: 0,
986
+ cooldownUntil: 0,
987
+ discoveredFrom: 'seed',
988
+ });
989
+ manager._startupCoverageComplete = true;
990
+
991
+ await manager.getConnectionForDevice('0x03');
992
+ manager._pruneIdleConnections();
993
+
994
+ assert.ok(manager.connectionByHost.has('target-relay:41046'));
995
+ manager.close();
996
+ });
997
+
998
+ test('provider candidates are loaded and normalized from string entries', async () => {
999
+ const tempDir = makeTempDir();
1000
+ const keyLocation = path.join(tempDir, 'keys.json');
1001
+ const manager = new DiodeClientManager({
1002
+ keyLocation,
1003
+ relaySelection: {
1004
+ scoreCachePath: null,
1005
+ networkDiscovery: { enabled: false },
1006
+ discoveryProvider: async () => ['provider-relay.example:41046'],
1007
+ },
1008
+ });
1009
+
1010
+ const candidates = await manager._buildStartupCandidates();
1011
+ const providerCandidate = candidates.find((candidate) => candidate.hostKey === 'provider-relay.example:41046');
1012
+
1013
+ assert.ok(providerCandidate);
1014
+ assert.equal(providerCandidate.source, 'provider');
1015
+ assert.equal(providerCandidate.priority, 100);
1016
+ manager.close();
1017
+ });
1018
+
1019
+ test('provider candidates are loaded and normalized from object entries', async () => {
1020
+ const tempDir = makeTempDir();
1021
+ const keyLocation = path.join(tempDir, 'keys.json');
1022
+ const manager = new DiodeClientManager({
1023
+ keyLocation,
1024
+ relaySelection: {
1025
+ scoreCachePath: null,
1026
+ networkDiscovery: { enabled: false },
1027
+ discoveryProvider: async () => [{ host: '1.2.3.4', port: 41046, priority: 5, region: 'eu', metadata: { tag: 'x' } }],
1028
+ },
1029
+ });
1030
+
1031
+ const candidates = await manager._buildStartupCandidates();
1032
+ const providerCandidate = candidates.find((candidate) => candidate.hostKey === '1.2.3.4:41046');
1033
+
1034
+ assert.ok(providerCandidate);
1035
+ assert.equal(providerCandidate.priority, 5);
1036
+ assert.equal(providerCandidate.region, 'eu');
1037
+ assert.deepEqual(providerCandidate.metadata, { tag: 'x' });
1038
+ manager.close();
1039
+ });
1040
+
1041
+ test('invalid provider entries are ignored without breaking startup', async () => {
1042
+ const tempDir = makeTempDir();
1043
+ const keyLocation = path.join(tempDir, 'keys.json');
1044
+ const manager = new DiodeClientManager({
1045
+ keyLocation,
1046
+ relaySelection: {
1047
+ scoreCachePath: null,
1048
+ networkDiscovery: { enabled: false },
1049
+ discoveryProvider: async () => [null, 123, {}, 'valid-provider:41046'],
1050
+ },
1051
+ });
1052
+
1053
+ const candidates = await manager._buildStartupCandidates();
1054
+ assert.ok(candidates.some((candidate) => candidate.hostKey === 'valid-provider:41046'));
1055
+ manager.close();
1056
+ });
1057
+
1058
+ test('provider timeout falls back to seed candidates only', async () => {
1059
+ const tempDir = makeTempDir();
1060
+ const keyLocation = path.join(tempDir, 'keys.json');
1061
+ const manager = new DiodeClientManager({
1062
+ keyLocation,
1063
+ relaySelection: {
1064
+ scoreCachePath: null,
1065
+ networkDiscovery: { enabled: false },
1066
+ discoveryProviderTimeoutMs: 10,
1067
+ discoveryProvider: async () => {
1068
+ await delay(50);
1069
+ return ['too-late:41046'];
1070
+ },
1071
+ },
1072
+ });
1073
+
1074
+ const candidates = await manager._buildStartupCandidates();
1075
+ assert.ok(!candidates.some((candidate) => candidate.hostKey === 'too-late:41046'));
1076
+ manager.close();
1077
+ });
1078
+
1079
+ test('provider error falls back cleanly', async () => {
1080
+ const tempDir = makeTempDir();
1081
+ const keyLocation = path.join(tempDir, 'keys.json');
1082
+ const manager = new DiodeClientManager({
1083
+ keyLocation,
1084
+ relaySelection: {
1085
+ scoreCachePath: null,
1086
+ networkDiscovery: { enabled: false },
1087
+ discoveryProvider: async () => {
1088
+ throw new Error('provider failed');
1089
+ },
1090
+ },
1091
+ });
1092
+
1093
+ const candidates = await manager._buildStartupCandidates();
1094
+ assert.equal(candidates.length, 6);
1095
+ manager.close();
1096
+ });
1097
+
1098
+ test('provider candidates are deduplicated against seed candidates', async () => {
1099
+ const tempDir = makeTempDir();
1100
+ const keyLocation = path.join(tempDir, 'keys.json');
1101
+ const manager = new DiodeClientManager({
1102
+ keyLocation,
1103
+ relaySelection: {
1104
+ scoreCachePath: null,
1105
+ networkDiscovery: { enabled: false },
1106
+ discoveryProvider: async () => [
1107
+ 'eu1.prenet.diode.io:41046',
1108
+ { host: 'provider-only', port: 41046, priority: 1 },
1109
+ ],
1110
+ },
1111
+ });
1112
+
1113
+ const candidates = await manager._buildStartupCandidates();
1114
+ assert.equal(candidates.filter((candidate) => candidate.hostKey === 'eu1.prenet.diode.io:41046').length, 1);
1115
+ assert.ok(candidates.some((candidate) => candidate.hostKey === 'provider-only:41046'));
1116
+ manager.close();
1117
+ });
1118
+
1119
+ test('provider candidates outrank cache-only candidates when untested', () => {
1120
+ const tempDir = makeTempDir();
1121
+ const keyLocation = path.join(tempDir, 'keys.json');
1122
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath: null, networkDiscovery: { enabled: false } } });
1123
+
1124
+ const ranked = extractHosts(manager._rankRelayCandidates([
1125
+ manager._createCandidate('provider-a:41046', 'provider', 0, { priority: 10 }),
1126
+ manager._createCandidate('cache-a:41046', 'cache', 1),
1127
+ ]));
1128
+
1129
+ assert.deepEqual(ranked, ['provider-a:41046', 'cache-a:41046']);
1130
+ manager.close();
1131
+ });
1132
+
1133
+ test('explicit host ignores provider by default', async () => {
1134
+ const tempDir = makeTempDir();
1135
+ const keyLocation = path.join(tempDir, 'keys.json');
1136
+ const manager = new DiodeClientManager({
1137
+ keyLocation,
1138
+ host: 'explicit:41046',
1139
+ relaySelection: {
1140
+ scoreCachePath: null,
1141
+ networkDiscovery: { enabled: false },
1142
+ discoveryProvider: async () => ['provider-relay:41046'],
1143
+ },
1144
+ });
1145
+
1146
+ assert.deepEqual(await extractStartupHosts(manager), ['explicit:41046']);
1147
+ manager.close();
1148
+ });
1149
+
1150
+ test('opt-in allows provider with explicit host', async () => {
1151
+ const tempDir = makeTempDir();
1152
+ const keyLocation = path.join(tempDir, 'keys.json');
1153
+ const manager = new DiodeClientManager({
1154
+ keyLocation,
1155
+ host: 'explicit:41046',
1156
+ relaySelection: {
1157
+ scoreCachePath: null,
1158
+ networkDiscovery: { enabled: false },
1159
+ useProviderWithExplicitHost: true,
1160
+ discoveryProvider: async () => ['provider-relay:41046'],
1161
+ },
1162
+ });
1163
+
1164
+ assert.deepEqual(await extractStartupHosts(manager), ['explicit:41046', 'provider-relay:41046']);
1165
+ manager.close();
1166
+ });
1167
+
1168
+ test('explicit hosts ignore provider by default', async () => {
1169
+ const tempDir = makeTempDir();
1170
+ const keyLocation = path.join(tempDir, 'keys.json');
1171
+ const manager = new DiodeClientManager({
1172
+ keyLocation,
1173
+ hosts: ['explicit-a:41046'],
1174
+ relaySelection: {
1175
+ scoreCachePath: null,
1176
+ networkDiscovery: { enabled: false },
1177
+ discoveryProvider: async () => ['provider-relay:41046'],
1178
+ },
1179
+ });
1180
+
1181
+ assert.deepEqual(await extractStartupHosts(manager), ['explicit-a:41046']);
1182
+ manager.close();
1183
+ });
1184
+
1185
+ test('opt-in allows provider with explicit hosts', async () => {
1186
+ const tempDir = makeTempDir();
1187
+ const keyLocation = path.join(tempDir, 'keys.json');
1188
+ const manager = new DiodeClientManager({
1189
+ keyLocation,
1190
+ hosts: ['explicit-a:41046'],
1191
+ relaySelection: {
1192
+ scoreCachePath: null,
1193
+ networkDiscovery: { enabled: false },
1194
+ useProviderWithExplicitHosts: true,
1195
+ discoveryProvider: async () => ['provider-relay:41046'],
1196
+ },
1197
+ });
1198
+
1199
+ assert.deepEqual(await extractStartupHosts(manager), ['explicit-a:41046', 'provider-relay:41046']);
1200
+ manager.close();
1201
+ });
1202
+
1203
+ test('provider-sourced relays participate in region-diverse warm retention', () => {
1204
+ const tempDir = makeTempDir();
1205
+ const keyLocation = path.join(tempDir, 'keys.json');
1206
+ const manager = new DiodeClientManager({
1207
+ keyLocation,
1208
+ relaySelection: { scoreCachePath: null, warmConnectionBudget: 3, networkDiscovery: { enabled: false } },
1209
+ });
1210
+
1211
+ const eu = new FakeConnection('eu-fast:41046');
1212
+ const us = new FakeConnection('provider-us:41046');
1213
+ const as = new FakeConnection('as-mid:41046');
1214
+ const extra = new FakeConnection('eu-extra:41046');
1215
+
1216
+ manager._registerConnection(eu, 'eu-fast:41046');
1217
+ manager._registerConnection(us, 'provider-us:41046');
1218
+ manager._registerConnection(as, 'as-mid:41046');
1219
+ manager._registerConnection(extra, 'eu-extra:41046');
1220
+ manager._startupCoverageComplete = true;
1221
+ manager._setCandidateMetadata('provider-us:41046', { region: 'us', priority: 1 });
1222
+
1223
+ manager.relayScores.set('eu-fast:41046', {
1224
+ hostKey: 'eu-fast:41046',
1225
+ ewmaLatencyMs: 10,
1226
+ lastProbeLatencyMs: 10,
1227
+ successCount: 1,
1228
+ failureCount: 0,
1229
+ lastSuccessAt: Date.now(),
1230
+ lastFailureAt: 0,
1231
+ cooldownUntil: 0,
1232
+ discoveredFrom: 'seed',
1233
+ });
1234
+ manager.relayScores.set('provider-us:41046', {
1235
+ hostKey: 'provider-us:41046',
1236
+ ewmaLatencyMs: 12,
1237
+ lastProbeLatencyMs: 12,
1238
+ successCount: 1,
1239
+ failureCount: 0,
1240
+ lastSuccessAt: Date.now(),
1241
+ lastFailureAt: 0,
1242
+ cooldownUntil: 0,
1243
+ discoveredFrom: 'provider',
1244
+ });
1245
+ manager.relayScores.set('as-mid:41046', {
1246
+ hostKey: 'as-mid:41046',
1247
+ ewmaLatencyMs: 20,
1248
+ lastProbeLatencyMs: 20,
1249
+ successCount: 1,
1250
+ failureCount: 0,
1251
+ lastSuccessAt: Date.now(),
1252
+ lastFailureAt: 0,
1253
+ cooldownUntil: 0,
1254
+ discoveredFrom: 'seed',
1255
+ });
1256
+ manager.relayScores.set('eu-extra:41046', {
1257
+ hostKey: 'eu-extra:41046',
1258
+ ewmaLatencyMs: 15,
1259
+ lastProbeLatencyMs: 15,
1260
+ successCount: 1,
1261
+ failureCount: 0,
1262
+ lastSuccessAt: Date.now(),
1263
+ lastFailureAt: 0,
1264
+ cooldownUntil: 0,
1265
+ discoveredFrom: 'seed',
1266
+ });
1267
+
1268
+ manager._pruneIdleConnections();
1269
+
1270
+ const retained = new Set(manager.getConnections().map((connection) => connection._managerHostKey));
1271
+ assert.deepEqual(retained, new Set(['eu-fast:41046', 'provider-us:41046', 'as-mid:41046']));
1272
+ assert.equal(extra.closeCount, 1);
1273
+ manager.close();
1274
+ });
1275
+
1276
+ test('provider-sourced successful probes are persisted with discoveredFrom provider', () => {
1277
+ const tempDir = makeTempDir();
1278
+ const keyLocation = path.join(tempDir, 'keys.json');
1279
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
1280
+ const manager = new DiodeClientManager({ keyLocation, relaySelection: { scoreCachePath } });
1281
+
1282
+ manager._recordRelayProbeSuccess('provider-persisted:41046', 20, 'provider');
1283
+ manager._flushRelayScores();
1284
+
1285
+ const written = JSON.parse(fs.readFileSync(scoreCachePath, 'utf8'));
1286
+ assert.equal(written.relays['provider-persisted:41046'].discoveredFrom, 'provider');
1287
+ manager.close();
1288
+ });
1289
+
1290
+ test('provider membership is not reused if provider omits prior relay on next startup', async () => {
1291
+ const tempDir = makeTempDir();
1292
+ const keyLocation = path.join(tempDir, 'keys.json');
1293
+ const scoreCachePath = path.join(tempDir, 'relay-scores.json');
1294
+ makeScoreCache(scoreCachePath, {
1295
+ 'provider-old:41046': {
1296
+ ewmaLatencyMs: 10,
1297
+ lastProbeLatencyMs: 10,
1298
+ successCount: 2,
1299
+ failureCount: 0,
1300
+ lastSuccessAt: Date.now(),
1301
+ lastFailureAt: 0,
1302
+ cooldownUntil: 0,
1303
+ discoveredFrom: 'provider',
1304
+ },
1305
+ });
1306
+
1307
+ const manager = new DiodeClientManager({
1308
+ keyLocation,
1309
+ relaySelection: {
1310
+ scoreCachePath,
1311
+ networkDiscovery: { enabled: false },
1312
+ discoveryProvider: async () => ['provider-new:41046'],
1313
+ },
1314
+ });
1315
+
1316
+ const hosts = await extractStartupHosts(manager);
1317
+ assert.ok(!hosts.includes('provider-old:41046'));
1318
+ assert.ok(hosts.includes('provider-new:41046'));
1319
+ manager.close();
1320
+ });
1321
+
1322
+ test('closing a duplicate relay alias remaps serverId to the surviving connection', async () => {
1323
+ const tempDir = makeTempDir();
1324
+ const keyLocation = path.join(tempDir, 'keys.json');
1325
+ const manager = new TestClientManager({
1326
+ keyLocation,
1327
+ relaySelection: { scoreCachePath: null, warmConnectionBudget: 1 },
1328
+ });
1329
+
1330
+ const serverId = '0x1111222233334444555566667777888899990000';
1331
+ const hostnameConnection = new FakeConnection('us2.prenet.diode.io:41046', {
1332
+ serverEthereumAddress: serverId,
1333
+ getObject: async () => ({ serverIdHex: serverId }),
1334
+ });
1335
+ const providerConnection = new FakeConnection('144.126.157.138:41046', {
1336
+ serverEthereumAddress: serverId,
1337
+ });
1338
+
1339
+ manager._registerConnection(hostnameConnection, 'us2.prenet.diode.io:41046');
1340
+ manager._registerConnection(providerConnection, '144.126.157.138:41046');
1341
+ hostnameConnection._managerConnectedAt = 1;
1342
+ providerConnection._managerConnectedAt = 2;
1343
+ manager._updateServerIdMapping(hostnameConnection);
1344
+ manager._updateServerIdMapping(providerConnection);
1345
+ manager._startupCoverageComplete = true;
1346
+
1347
+ manager.relayScores.set('us2.prenet.diode.io:41046', {
1348
+ hostKey: 'us2.prenet.diode.io:41046',
1349
+ ewmaLatencyMs: 15,
1350
+ lastProbeLatencyMs: 15,
1351
+ successCount: 1,
1352
+ failureCount: 0,
1353
+ lastSuccessAt: Date.now(),
1354
+ lastFailureAt: 0,
1355
+ cooldownUntil: 0,
1356
+ discoveredFrom: 'seed',
1357
+ });
1358
+ manager.relayScores.set('144.126.157.138:41046', {
1359
+ hostKey: '144.126.157.138:41046',
1360
+ ewmaLatencyMs: 50,
1361
+ lastProbeLatencyMs: 50,
1362
+ successCount: 1,
1363
+ failureCount: 0,
1364
+ lastSuccessAt: Date.now(),
1365
+ lastFailureAt: 0,
1366
+ cooldownUntil: 0,
1367
+ discoveredFrom: 'provider',
1368
+ });
1369
+
1370
+ manager._pruneIdleConnections();
1371
+
1372
+ assert.equal(manager.serverIdToConnection.get(serverId), hostnameConnection);
1373
+ const resolved = await manager.getConnectionForDevice('0x01');
1374
+ assert.equal(resolved, hostnameConnection);
1375
+ manager.close();
1376
+ });